Kulcare Docs
Feature QA checklists

WhatsApp 2FA Cutover QA Guide

Scope: Manual verification that the 2FA WhatsApp verification-code path now flows through Shared::Notifications::Whatsapp::SendCode (domains/) instead of WhatsApp::SendCode (legacy parts/), and that the audit log lands in shared.notification_logs with 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 +91 number on dev. The dispatcher is hard-coded to skip every Wati send in Rails.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

BeforeAfter
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'
end

3. 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 the from <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 in PORTED_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 sidekiq should 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

ScenarioHow to triggerExpected behavior
Symbol message_typeWhatsApp::SendCode.deliver('+919876543210', :two_factor_verify, code: '123456')Routes to new dispatcher (shim coerces symbol → string). [LegacyDeprecation] warn line present.
Non-Indian numberShared::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 environmentSame call on RAILS_ENV=developmentReturns Result(success?: false, error: /disabled in dev/). No Wati request issued, no notification_logs row.
Staging non-test numberOn staging, call with a phone not in wati_test_numbersReturns Result(success?: false, error: /Non-test number in staging/).
Dummy patient numberPhone whose first 4 digits match Rails.application.credentials.dummy_patient_prefixReturns Result(success?: false, error: /Dummy patient phone/).
Missing code paramShared::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_logs row with template_id='2_fa_verify', channel='whatsapp', status='sent'
  • §4 each unported type still produces a msg_logs row 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)

On this page