|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +require "rails_helper" |
| 4 | +require "rails/generators" |
| 5 | +require "generators/active_admin/oidc/install/install_generator" |
| 6 | + |
| 7 | +# The install generator is what a host app runs once: |
| 8 | +# |
| 9 | +# bundle add activeadmin-oidc |
| 10 | +# bin/rails generate active_admin:oidc:install |
| 11 | +# |
| 12 | +# It must produce a working, editable starting point — initializer with |
| 13 | +# a commented-out Zitadel preset and sample `on_login` snippets, a |
| 14 | +# migration adding `provider`/`uid`/`oidc_raw_info` + a unique index to |
| 15 | +# `admin_users`, and a published login view override. It must be |
| 16 | +# idempotent (running twice does nothing bad) and refuse to run if the |
| 17 | +# host app doesn't have `AdminUser` or `devise`/`activeadmin`. |
| 18 | +RSpec.describe ActiveAdmin::Oidc::Generators::InstallGenerator do |
| 19 | + let(:destination_root) { File.expand_path("../../tmp/generator_test", __dir__) } |
| 20 | + |
| 21 | + before do |
| 22 | + FileUtils.rm_rf(destination_root) |
| 23 | + FileUtils.mkdir_p(destination_root) |
| 24 | + # Stand up a minimal fake host-app tree so the generator can find |
| 25 | + # config/routes.rb, config/initializers, db/migrate, Gemfile.lock |
| 26 | + # and the AdminUser model it needs to refuse-to-run without. |
| 27 | + FileUtils.mkdir_p(File.join(destination_root, "config/initializers")) |
| 28 | + FileUtils.mkdir_p(File.join(destination_root, "db/migrate")) |
| 29 | + FileUtils.mkdir_p(File.join(destination_root, "app/models")) |
| 30 | + File.write( |
| 31 | + File.join(destination_root, "config/routes.rb"), |
| 32 | + <<~RUBY |
| 33 | + Rails.application.routes.draw do |
| 34 | + ActiveAdmin.routes(self) |
| 35 | + end |
| 36 | + RUBY |
| 37 | + ) |
| 38 | + File.write( |
| 39 | + File.join(destination_root, "app/models/admin_user.rb"), |
| 40 | + "class AdminUser < ApplicationRecord; end\n" |
| 41 | + ) |
| 42 | + File.write( |
| 43 | + File.join(destination_root, "Gemfile.lock"), |
| 44 | + <<~LOCK |
| 45 | + GEM |
| 46 | + specs: |
| 47 | + devise (5.0.3) |
| 48 | + activeadmin (3.5.1) |
| 49 | + LOCK |
| 50 | + ) |
| 51 | + end |
| 52 | + |
| 53 | + def run_generator(args = []) |
| 54 | + # Instantiate directly rather than going through `start` so that |
| 55 | + # `Thor::Error` raised in preflight propagates to the spec instead |
| 56 | + # of being caught by Thor's CLI wrapper. Redirect $stdout so the |
| 57 | + # "create file" log lines don't pollute rspec output. |
| 58 | + generator = described_class.new(args, [], destination_root: destination_root) |
| 59 | + orig_stdout = $stdout |
| 60 | + $stdout = StringIO.new |
| 61 | + begin |
| 62 | + generator.invoke_all |
| 63 | + ensure |
| 64 | + $stdout = orig_stdout |
| 65 | + end |
| 66 | + end |
| 67 | + |
| 68 | + describe "initializer" do |
| 69 | + before { run_generator } |
| 70 | + |
| 71 | + let(:initializer) do |
| 72 | + File.read(File.join(destination_root, "config/initializers/activeadmin_oidc.rb")) |
| 73 | + end |
| 74 | + |
| 75 | + it "creates the initializer at config/initializers/activeadmin_oidc.rb" do |
| 76 | + expect(File).to exist( |
| 77 | + File.join(destination_root, "config/initializers/activeadmin_oidc.rb") |
| 78 | + ) |
| 79 | + end |
| 80 | + |
| 81 | + it "contains an ActiveAdmin::Oidc.configure block" do |
| 82 | + expect(initializer).to include("ActiveAdmin::Oidc.configure do |c|") |
| 83 | + end |
| 84 | + |
| 85 | + it "includes a commented-out Zitadel preset line" do |
| 86 | + expect(initializer).to match(/^\s*#\s*c\.preset\s+:zitadel/) |
| 87 | + end |
| 88 | + |
| 89 | + it "includes commented-out ENV reads for issuer/client_id/client_secret" do |
| 90 | + expect(initializer).to match(/#\s*c\.issuer\s*=\s*ENV/) |
| 91 | + expect(initializer).to match(/#\s*c\.client_id\s*=\s*ENV/) |
| 92 | + expect(initializer).to match(/#\s*c\.client_secret\s*=\s*ENV/) |
| 93 | + end |
| 94 | + |
| 95 | + it "includes a sample on_login snippet for Zitadel nested roles claim" do |
| 96 | + expect(initializer).to include("urn:zitadel:iam:org:project:roles") |
| 97 | + end |
| 98 | + |
| 99 | + it "includes a sample on_login snippet for a department-style assignment" do |
| 100 | + expect(initializer).to match(/department/i) |
| 101 | + end |
| 102 | + end |
| 103 | + |
| 104 | + describe "migration" do |
| 105 | + before { run_generator } |
| 106 | + |
| 107 | + let(:migration_path) do |
| 108 | + Dir[File.join(destination_root, "db/migrate/*_add_oidc_to_admin_users.rb")].first |
| 109 | + end |
| 110 | + |
| 111 | + let(:migration) { File.read(migration_path) } |
| 112 | + |
| 113 | + it "creates a timestamped migration adding oidc columns to admin_users" do |
| 114 | + expect(migration_path).not_to be_nil |
| 115 | + end |
| 116 | + |
| 117 | + it "adds provider, uid, and oidc_raw_info columns" do |
| 118 | + expect(migration).to match(/add_column\s+:admin_users,\s*:provider,\s*:string/) |
| 119 | + expect(migration).to match(/add_column\s+:admin_users,\s*:uid,\s*:string/) |
| 120 | + expect(migration).to match(/add_column\s+:admin_users,\s*:oidc_raw_info/) |
| 121 | + end |
| 122 | + |
| 123 | + it "does not add any authorization column (no :role, :roles, :department)" do |
| 124 | + expect(migration).not_to match(/:roles?\b/) |
| 125 | + expect(migration).not_to match(/:department/) |
| 126 | + end |
| 127 | + |
| 128 | + it "adds a unique index on (provider, uid)" do |
| 129 | + expect(migration).to match( |
| 130 | + /add_index\s+:admin_users,\s*\[:provider,\s*:uid\],\s*unique:\s*true/ |
| 131 | + ) |
| 132 | + end |
| 133 | + end |
| 134 | + |
| 135 | + describe "login view override" do |
| 136 | + before { run_generator } |
| 137 | + |
| 138 | + it "publishes app/views/active_admin/devise/sessions/new.html.erb" do |
| 139 | + expect(File).to exist( |
| 140 | + File.join(destination_root, "app/views/active_admin/devise/sessions/new.html.erb") |
| 141 | + ) |
| 142 | + end |
| 143 | + end |
| 144 | + |
| 145 | + describe "idempotency" do |
| 146 | + it "does not duplicate the initializer when run twice" do |
| 147 | + run_generator |
| 148 | + initializer_path = File.join(destination_root, "config/initializers/activeadmin_oidc.rb") |
| 149 | + first = File.read(initializer_path) |
| 150 | + |
| 151 | + run_generator |
| 152 | + second = File.read(initializer_path) |
| 153 | + |
| 154 | + expect(second).to eq(first) |
| 155 | + end |
| 156 | + |
| 157 | + it "does not create a second migration when run twice" do |
| 158 | + run_generator |
| 159 | + first_migrations = Dir[File.join(destination_root, "db/migrate/*_add_oidc_to_admin_users.rb")] |
| 160 | + expect(first_migrations.size).to eq(1) |
| 161 | + |
| 162 | + run_generator |
| 163 | + second_migrations = Dir[File.join(destination_root, "db/migrate/*_add_oidc_to_admin_users.rb")] |
| 164 | + expect(second_migrations.size).to eq(1) |
| 165 | + end |
| 166 | + end |
| 167 | + |
| 168 | + describe "preflight checks" do |
| 169 | + it "aborts with a clear error if AdminUser model is missing" do |
| 170 | + FileUtils.rm(File.join(destination_root, "app/models/admin_user.rb")) |
| 171 | + |
| 172 | + expect { run_generator }.to raise_error(Thor::Error, /AdminUser/) |
| 173 | + end |
| 174 | + |
| 175 | + it "aborts with a clear error if devise is not in Gemfile.lock" do |
| 176 | + File.write( |
| 177 | + File.join(destination_root, "Gemfile.lock"), |
| 178 | + "GEM\n specs:\n activeadmin (3.5.1)\n" |
| 179 | + ) |
| 180 | + |
| 181 | + expect { run_generator }.to raise_error(Thor::Error, /devise/) |
| 182 | + end |
| 183 | + |
| 184 | + it "aborts with a clear error if activeadmin is not in Gemfile.lock" do |
| 185 | + File.write( |
| 186 | + File.join(destination_root, "Gemfile.lock"), |
| 187 | + "GEM\n specs:\n devise (5.0.3)\n" |
| 188 | + ) |
| 189 | + |
| 190 | + expect { run_generator }.to raise_error(Thor::Error, /activeadmin/) |
| 191 | + end |
| 192 | + end |
| 193 | +end |
0 commit comments