Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ def authenticate_identity!
# verification flows — controllers serving those flows use this as a
# before_action so users land on their case instead of a legacy page
def redirect_to_open_manual_case
return unless Flipper.enabled?(VerificationCase::FLIPPER_FLAG, current_identity)
return unless current_identity.verification_cases.open_cases.exists?

redirect_to manual_verification_path
Expand Down
4 changes: 2 additions & 2 deletions app/controllers/backend/verification_cases_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ def show
end

# staff entry point: user emailed identity@, staff opens a case.
# enables the flipper flag + sends the single-use link.
# the open case itself grants the flipper flag (group gate), so all
# that's left is sending the single-use link.
def create
authorize VerificationCase

Expand All @@ -73,7 +74,6 @@ def create
opened_by: current_user,
skip_persona: params[:skip_persona] == "1"
)
@case.enable_flag!
deliver_link!

@case.log_event!(:case_opened, actor: current_user, request: request,
Expand Down
7 changes: 1 addition & 6 deletions app/controllers/manual_verifications_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# the user side of a manual verification call case. gated three ways:
# logged in (ApplicationController), flipper flag on the identity, and
# logged in (ApplicationController), an open case on the identity, and
# a single-use emailed link consumed on first visit.
class ManualVerificationsController < ApplicationController
before_action :set_case
Expand Down Expand Up @@ -175,11 +175,6 @@ def acknowledge_call_capture
private

def set_case
unless Flipper.enabled?(VerificationCase::FLIPPER_FLAG, current_identity)
render(:link_invalid, status: :forbidden) and return if params[:token].present?
redirect_to root_path and return
end

@case = current_identity.verification_cases.open_cases.order(created_at: :desc).first
return if @case

Expand Down
19 changes: 1 addition & 18 deletions app/models/verification_case.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ class VerificationCase < ApplicationRecord
include PublicIdentifiable
set_public_id_prefix "vcase"

FLIPPER_FLAG = :manual_verification_call_2026_07_03
ACCESS_TOKEN_TTL = 7.days

belongs_to :identity
Expand Down Expand Up @@ -127,20 +126,15 @@ class VerificationCase < ApplicationRecord

event :approve do
transitions from: :call_held, to: :approved
after { close_out! }
end

event :deny do
transitions from: :call_held, to: :denied
after { close_out! }
end

event :withdraw do
transitions from: [ :requested, :link_sent, :docs_submitted, :call_scheduled, :call_held ], to: :withdrawn
after do
close_out!
invalidate_access_token!
end
after { invalidate_access_token! }
end
end

Expand All @@ -159,11 +153,6 @@ def destroy_fully!
end
def decided? = approved? || denied?

# -- feature flag ------------------------------------------------------

def enable_flag! = Flipper.enable(FLIPPER_FLAG, identity)
def revoke_flag! = Flipper.disable(FLIPPER_FLAG, identity)

# -- single-use access link (mirrors Identity::V2LoginCode) -------------

# once the link has been opened, the authenticated session is what gets
Expand Down Expand Up @@ -305,10 +294,4 @@ def log_event!(key, actor: nil, data: {}, request: nil)
def reset_capture!
update!(persona_inquiry_id: nil, persona_session_token: nil)
end

private

# decision time: drop the flag. documents are retained (break-glass
# gated) — the ManualVerificationCall itself is created by the decision flow.
def close_out! = revoke_flag!
end
3 changes: 1 addition & 2 deletions config/flipper_features.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,4 @@ shared:
dev_allow_manual_flow_2026_05_27: enables the manual document verification flow for an actor
disable_slack_invites_2025_12_08: suppresses Slack SCIM provisioning for an actor
dev_force_manual_review_2026_05_27: forces verifications for an actor into manual review
dev_force_deny_verification_2026_05_27: auto-denies verifications for an actor
manual_verification_call_2026_07_03: enables the manual verification call flow for an actor with an open case
dev_force_deny_verification_2026_05_27: auto-denies verifications for an actor
8 changes: 2 additions & 6 deletions spec/models/verification_case_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,23 +52,20 @@
expect { kase.deny! }.to raise_error(AASM::InvalidTransition)
end

it "revokes the flag on decision and keeps documents" do
it "keeps documents on decision" do
kase = create(:verification_case, :call_held)
doc = create(:verification_case_document, verification_case: kase)
Flipper.enable(described_class::FLIPPER_FLAG, kase.identity)

kase.approve!

expect(Flipper.enabled?(described_class::FLIPPER_FLAG, kase.identity)).to be(false)
expect(doc.reload.file).to be_attached
end
end

describe "#withdraw" do
it "closes from any open state, drops the flag, and kills the link" do
it "closes from any open state and kills the link" do
%i[requested link_sent docs_submitted call_scheduled call_held].each do |state|
kase = create(:verification_case, state == :requested ? nil : state, access_token: "tok", access_token_expires_at: 1.day.from_now)
Flipper.enable(described_class::FLIPPER_FLAG, kase.identity)

kase.withdraw!

Expand All @@ -78,7 +75,6 @@
expect(kase).not_to be_decided
expect(kase.access_token).to be_nil
expect(kase.consume_access_token!("tok")).to be(false)
expect(Flipper.enabled?(described_class::FLIPPER_FLAG, kase.identity)).to be(false)
end
end

Expand Down
9 changes: 2 additions & 7 deletions spec/requests/backend/verification_cases_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,8 @@
allow_any_instance_of(Backend::ApplicationController).to receive(:require_2fa!).and_return(true)
end

after { Flipper.disable(VerificationCase::FLIPPER_FLAG) }

describe "POST /backend/verification_cases" do
it "opens a case, enables the flag, and emails the single-use link" do
it "opens a case and emails the single-use link" do
target = create(:identity)

expect {
Expand All @@ -22,7 +20,6 @@
kase = target.verification_cases.sole
expect(kase).to be_link_sent
expect(kase.skip_persona).to be(false)
expect(Flipper.enabled?(VerificationCase::FLIPPER_FLAG, target)).to be(true)
end

it "opens a skip-persona case when asked" do
Expand Down Expand Up @@ -315,10 +312,9 @@ def guardian_pings = enqueued_jobs.select { |j| j[:job] == Slack::NotifyGuardian
end

describe "POST /backend/verification_cases/:id/withdraw" do
it "closes the case, revokes the flag, invalidates the link, and logs the reason" do
it "closes the case, invalidates the link, and logs the reason" do
kase = create(:verification_case, :call_scheduled)
token = kase.access_token
Flipper.enable(VerificationCase::FLIPPER_FLAG, kase.identity)

post withdraw_backend_verification_case_path(kase), params: { reason: "opened on the wrong account" }

Expand All @@ -327,7 +323,6 @@ def guardian_pings = enqueued_jobs.select { |j| j[:job] == Slack::NotifyGuardian
expect(kase.verification).to be_nil
expect(kase.access_token).to be_nil
expect(kase.consume_access_token!(token)).to be(false)
expect(Flipper.enabled?(VerificationCase::FLIPPER_FLAG, kase.identity)).to be(false)

event = kase.events.find_by(key: "case_withdrawn")
expect(event.actor).to eq(verifier)
Expand Down
19 changes: 2 additions & 17 deletions spec/requests/manual_verifications_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,24 +15,15 @@
allow_any_instance_of(ApplicationController).to receive(:identity_signed_in?).and_return(true)
end

after { Flipper.disable(VerificationCase::FLIPPER_FLAG) }

describe "gating" do
it "bounces users without the flipper flag" do
get manual_verification_path
expect(response).to redirect_to(root_path)
end

it "bounces flagged users with no open case" do
Flipper.enable(VerificationCase::FLIPPER_FLAG, identity)
it "bounces users with no open case" do
get manual_verification_path
expect(response).to redirect_to(root_path)
end

it "requires the single-use token on first visit" do
kase = create(:verification_case, identity: identity, status: :link_sent)
kase.generate_access_token!
Flipper.enable(VerificationCase::FLIPPER_FLAG, identity)

get manual_verification_path
expect(response).to have_http_status(:forbidden)
Expand All @@ -41,7 +32,6 @@
it "consumes a valid token then allows session access" do
kase = create(:verification_case, identity: identity, status: :link_sent)
token = kase.generate_access_token!
Flipper.enable(VerificationCase::FLIPPER_FLAG, identity)

get manual_verification_path(token: token)
expect(response).to redirect_to(manual_verification_path)
Expand All @@ -56,7 +46,6 @@
it "shows the invalid-link page to a user following the old emailed link" do
kase = create(:verification_case, identity: identity, status: :link_sent)
token = kase.generate_access_token!
Flipper.enable(VerificationCase::FLIPPER_FLAG, identity)

kase.withdraw!

Expand All @@ -78,7 +67,6 @@
it "puts the user back at document submission, needing the new link first" do
kase = create(:verification_case, identity: identity, status: :docs_submitted, document_class: "government_id",
access_token_used_at: 1.hour.ago)
Flipper.enable(VerificationCase::FLIPPER_FLAG, identity)
kase.request_redo!
token = kase.rotate_access_link!(reopen_gate: true)

Expand All @@ -101,10 +89,7 @@
access_token_used_at: Time.current)
end

before do
kase
Flipper.enable(VerificationCase::FLIPPER_FLAG, identity)
end
before { kase }

it "shows document class selection first" do
get manual_verification_path
Expand Down
Loading