WhatsApp 2FA Cutover QA Guide
Scope: Manual verification that the 2FA WhatsApp verification-code path now flows through
Shared::Notifications::Whatsapp::SendCode(domains/) instead ofWhatsApp::SendCode(legacy parts/), and that the audit log lands inshared.notification_logswith the right shape.Prerequisites: Backend running, dev or staging Rails console access, a test phone number registered in
wati_test_numbers(staging only) or any India+91number on dev. The dispatcher is hard-coded to skip every Wati send inRails.env.development?so you will not see a real WhatsApp message in dev — verification on dev is "did the code path reach the dispatcher and log correctly," not "did Wati deliver."Related: Migration plan · Tracking epic #1494 · Foundation PR #1493 · Cutover PR #1497
1. What changed
| Before | After |
|---|---|
2FA WhatsApp code sent via WhatsApp::SendCode.deliver(phone, 'two_factor_verify', code: 'XXXXXX') | Same caller, but now routes through Shared::Notifications::Whatsapp::SendCode.deliver(to:, message_type: 'two_factor_verify', params:, options:) |
Audit row in public.msg_logs (no tenant FK, no RLS) | Audit row in shared.notification_logs (tenant_id FK, RLS-forced) — or Rails.logger.info line when pre-auth (no tenant context) |
| No deprecation signal | [LegacyDeprecation] warn line on every call routed through the legacy shim |
Three other WhatsApp SendCode types (verification, reset_password, two_factor_setup) still flow through the legacy path. Each ships in its own slice — they are out of scope for this QA pass.
2. Test environment setup
2.1 Verify the migration ran
Both dev and staging:
RAILS_ENV=<env> bundle exec rails runner '
detail = Shared::MsgDetail.find_by(
class_name: "Shared::Notifications::Whatsapp::SendCode::TwoFactorVerify",
default_language: "en"
)
puts detail ? "OK template=#{detail.whatsapp_template}" : "MISSING — run db:migrate"
'Expect: OK template=2_fa_verify.
2.2 (Staging only) confirm the test phone is whitelisted
bundle exec rails runner 'puts Rails.application.credentials.wati_test_numbers.to_s.split(",").map(&:strip).inspect'Your test phone must appear in that list (without country-code prefix — e.g. 9876543210, not +919876543210). If missing, add it to Rails credentials and redeploy before continuing.
2.3 Pick a 2FA-enabled user
You need a user with 2FA enabled and SMS as the method. On dev:
user = Identity::User.find_by(phone_number: '<10-digit>') # or email lookup
setting = Identity::TwoFactorSetting.find_or_create_by!(user: user) do |s|
s.enabled = true
s.method = 'sms'
end3. Happy path — 2FA sign-in triggers a WhatsApp send
3.1 Trigger 2FA delivery from the Rails console
service = Identity::Sessions::CheckTwoFactorService.new(user: user, device_details: {})
result = service.send(:send_sms_code, Identity::TwoFactorSetting.find_by(user: user))
puts result.success? ? "DELIVERY OK" : "FAIL: #{result.errors&.join(', ')}"If you'd rather drive it through the API, make a normal POST /identity/sessions login that triggers the 2FA challenge — the controller routes into CheckTwoFactorService for the chosen 2FA-enabled user.
3.2 Look for the deprecation log line
The cutover replaces the dispatcher but the surrounding fallback in check_two_factor_service.rb still calls SMS first. The WhatsApp call also fires via the new domains/ dispatcher — not through the legacy shim, so you should not see a [LegacyDeprecation] line for THIS path.
# Dev
tail -f log/development.log | grep -E '\[LegacyDeprecation\]|WhatsappService'Expect:
- ✅
[WhatsappService] pre-auth WhatsApp to=***3210 tmpl=2_fa_verify(when tenant_id is nil — which it is for 2FA, see §3.4) - ❌ No
[LegacyDeprecation]warning. If you see one, a legacy caller path was hit instead of the cutover. Capture thefrom <path>:<line>portion of the warning and file a follow-up.
3.3 Verify the audit row (or the lack of one)
-- Connect to the env-appropriate DB
SELECT id, channel, recipient, template_id, status, tenant_id, created_at
FROM shared.notification_logs
WHERE channel = 'whatsapp'
AND template_id = '2_fa_verify'
ORDER BY created_at DESC
LIMIT 5;Important: For 2FA flows, tenant_id is nil (see §3.4). WhatsappService therefore skips the notification_logs insert and falls back to Rails.logger.info only. You will not see a row here for a 2FA-initiated send — that is the documented pre-auth behavior, not a bug.
To force an authenticated test that does write to notification_logs, simulate the dispatcher directly:
Shared::Notifications::Whatsapp::SendCode.deliver(
to: '+919876543210',
message_type: 'two_factor_verify',
params: { code: '123456' },
options: { tenant_id: Identity::Tenant.first.id } # any active tenant
)Then re-run the SQL above — expect one new row with status='sent' (after Wati responds; pending momentarily before).
3.4 Why tenant_id is nil for the real 2FA path
2FA fires during the sign-in bootstrap, before any tenant context is established. Identity::User does not have a tenant_id column — users span tenants via Identity::UserMembership. There is no single tenant to attach a 2FA SMS to, so we pass tenant_id: nil explicitly and rely on the Rails.logger.info fallback for audit. This matches the SMS pattern hardened in PR #1491 / issue #1458.
4. Legacy-path regression — unported types still work
The three remaining message types must still flow through the legacy WhatsApp::SendCode → WhatsApp::Base → msg_logs path.
For each of 'verification', 'reset_password', 'two_factor_setup':
WhatsApp::SendCode.deliver(
'+919876543210', # full E.164
'verification', # or 'reset_password' / 'two_factor_setup'
{ code: '654321' }
)Tail logs:
tail -f log/development.log | grep -E 'WhatsApp::Base|MsgLog|LegacyDeprecation'Expect:
- ❌ No
[LegacyDeprecation]line — these types are not yet inPORTED_MESSAGE_TYPES. - ✅ A
WhatsApp::Base.perform_later(...)enqueue line. - ✅ A new row in
public.msg_logs(legacy audit table) once the job runs (bundle exec sidekiqshould pick it up in dev).
SELECT id, msg_status, unique_identifier, created_at
FROM public.msg_logs
WHERE created_at > now() - interval '5 minutes'
ORDER BY created_at DESC LIMIT 5;5. Negative-path checks
| Scenario | How to trigger | Expected behavior |
|---|---|---|
| Symbol message_type | WhatsApp::SendCode.deliver('+919876543210', :two_factor_verify, code: '123456') | Routes to new dispatcher (shim coerces symbol → string). [LegacyDeprecation] warn line present. |
| Non-Indian number | Shared::Notifications::Whatsapp::SendCode.deliver(to: '+12125551234', message_type: 'two_factor_verify', params: { code: '111111' }) | Returns Result(success?: false, error: /India only/). No notification_logs row. |
| Dev environment | Same call on RAILS_ENV=development | Returns Result(success?: false, error: /disabled in dev/). No Wati request issued, no notification_logs row. |
| Staging non-test number | On staging, call with a phone not in wati_test_numbers | Returns Result(success?: false, error: /Non-test number in staging/). |
| Dummy patient number | Phone whose first 4 digits match Rails.application.credentials.dummy_patient_prefix | Returns Result(success?: false, error: /Dummy patient phone/). |
Missing code param | Shared::Notifications::Whatsapp::SendCode.deliver(to: '+919876543210', message_type: 'two_factor_verify', params: {}) | Raises ArgumentError: TwoFactorVerify requires params[:code] before any send is attempted. |
Unknown message_type | ... message_type: 'not_a_real_thing' ... | Rails.logger.error("[Whatsapp::Base] unknown message_type: ...") and no-op. |
6. Burnoff monitoring
The exit criterion for retiring parts/core/services/whats_app/send_code.rb (PR-FINAL of epic #1494) is zero [LegacyDeprecation] log traffic for 14 consecutive days across all four WhatsApp::SendCode message types.
6.1 Staging tail (during the burnoff window)
ssh kulcare-staging '
cd current
grep -h "\[LegacyDeprecation\] WhatsApp::SendCode" log/production.log* \
| awk -F"called from " "{print \$2}" \
| awk -F" — " "{print \$1}" \
| sort | uniq -c | sort -rn
'Output is count | path:line — these are the call sites still routing through the shim. If the count is zero for the ported types, the cutover is fully effective. Non-zero counts on unported types are expected until their slice ships.
6.2 Production check (post-deploy day-of)
Same grep, point at log/production.log on the prod hosts. Capture the output in the QA pass record so the burnoff trend is auditable.
7. Rollback
# Revert the cutover commit, keep the foundation (#1493) in place
git revert <cutover-merge-sha>
git push origin <release-branch>check_two_factor_service falls back to calling WhatsApp::SendCode.deliver directly — which is still defined (we shimmed it, didn't delete it). The seed migration's down removes the shared.msg_details row but the legacy public.msg_details row is untouched, so the legacy path still resolves a template name. No data corruption surface.
8. Sign-off checklist
- §2.1 seed migration verified in target env
- §3.1 dispatcher call exercised from console or sign-in API
- §3.2 no
[LegacyDeprecation]line emitted for the 2FA path - §3.3 authenticated repro writes a
shared.notification_logsrow withtemplate_id='2_fa_verify',channel='whatsapp',status='sent' - §4 each unported type still produces a
msg_logsrow via the legacy path - §5 all six negative-path checks return the expected failure mode
- §6.1 staging deprecation grep confirms only unported types appear (or empty)
- §7 rollback path verified (or documented as not required)