diff --git a/db/migrate/20260420080000_nullify_auth_code_fk_on_delete.rb b/db/migrate/20260420080000_nullify_auth_code_fk_on_delete.rb new file mode 100644 index 0000000..517d9f1 --- /dev/null +++ b/db/migrate/20260420080000_nullify_auth_code_fk_on_delete.rb @@ -0,0 +1,20 @@ +class NullifyAuthCodeFkOnDelete < ActiveRecord::Migration[8.1] + # When an OidcAuthorizationCode is deleted (e.g. by OidcTokenCleanupJob), + # null out the FK on any descendant tokens instead of blocking the delete + # on the default RESTRICT. Token rows survive for the audit trail. + def up + remove_foreign_key :oidc_access_tokens, :oidc_authorization_codes + add_foreign_key :oidc_access_tokens, :oidc_authorization_codes, on_delete: :nullify + + remove_foreign_key :oidc_refresh_tokens, :oidc_authorization_codes + add_foreign_key :oidc_refresh_tokens, :oidc_authorization_codes, on_delete: :nullify + end + + def down + remove_foreign_key :oidc_access_tokens, :oidc_authorization_codes + add_foreign_key :oidc_access_tokens, :oidc_authorization_codes + + remove_foreign_key :oidc_refresh_tokens, :oidc_authorization_codes + add_foreign_key :oidc_refresh_tokens, :oidc_authorization_codes + end +end diff --git a/db/schema.rb b/db/schema.rb index 3614d53..85f953d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_04_20_073319) do +ActiveRecord::Schema[8.1].define(version: 2026_04_20_080000) do create_table "active_storage_attachments", force: :cascade do |t| t.bigint "blob_id", null: false t.datetime "created_at", null: false @@ -278,13 +278,13 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_20_073319) do add_foreign_key "application_user_claims", "applications", on_delete: :cascade add_foreign_key "application_user_claims", "users", on_delete: :cascade add_foreign_key "oidc_access_tokens", "applications" - add_foreign_key "oidc_access_tokens", "oidc_authorization_codes" + add_foreign_key "oidc_access_tokens", "oidc_authorization_codes", on_delete: :nullify add_foreign_key "oidc_access_tokens", "users" add_foreign_key "oidc_authorization_codes", "applications" add_foreign_key "oidc_authorization_codes", "users" add_foreign_key "oidc_refresh_tokens", "applications" add_foreign_key "oidc_refresh_tokens", "oidc_access_tokens" - add_foreign_key "oidc_refresh_tokens", "oidc_authorization_codes" + add_foreign_key "oidc_refresh_tokens", "oidc_authorization_codes", on_delete: :nullify add_foreign_key "oidc_refresh_tokens", "users" add_foreign_key "oidc_user_consents", "applications" add_foreign_key "oidc_user_consents", "users" diff --git a/test/jobs/oidc_token_cleanup_job_test.rb b/test/jobs/oidc_token_cleanup_job_test.rb index 4822f0f..ef25c36 100644 --- a/test/jobs/oidc_token_cleanup_job_test.rb +++ b/test/jobs/oidc_token_cleanup_job_test.rb @@ -1,7 +1,51 @@ require "test_helper" class OidcTokenCleanupJobTest < ActiveJob::TestCase - # test "the truth" do - # assert true - # end + include ActiveSupport::Testing::TimeHelpers + + # Regression: deleting an old authorization code while a descendant token + # still references it must not blow up on the FK. We rely on ON DELETE + # SET NULL so the token row survives (audit trail) with a NULL FK. + test "deletes old authorization codes whose descendant tokens still reference them" do + user = User.create!(email_address: "cleanup_test@example.com", password: "password123") + application = Application.create!( + name: "Cleanup Test App", + slug: "cleanup-test-app", + app_type: "oidc", + redirect_uris: ["http://localhost/cb"].to_json, + active: true + ) + + auth_code = nil + travel_to(10.days.ago) do + auth_code = OidcAuthorizationCode.create!( + application: application, + user: user, + redirect_uri: "http://localhost/cb", + scope: "openid" + ) + end + + token = OidcAccessToken.create!( + application: application, + user: user, + scope: "openid", + oidc_authorization_code: auth_code + ) + + OidcTokenCleanupJob.new.perform + + assert_not OidcAuthorizationCode.exists?(auth_code.id), + "old authorization code should be deleted" + assert OidcAccessToken.exists?(token.id), + "token row should survive for audit trail" + assert_nil token.reload.oidc_authorization_code_id, + "token FK should be nullified by ON DELETE SET NULL" + ensure + OidcRefreshToken.where(application: application).delete_all if application + OidcAccessToken.where(application: application).delete_all if application + OidcAuthorizationCode.where(application: application).delete_all if application + user&.destroy + application&.destroy + end end