ff1 1.2.0 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ # FF1 Format Preserving Encryption Configuration
4
+ FF1::ActiveRecord.configure do |config|
5
+ # Global encryption key - REQUIRED
6
+ # Should be 16, 24, or 32 bytes for AES-128/192/256
7
+ # Store securely using Rails credentials or environment variables
8
+ #
9
+ # Example using Rails credentials:
10
+ # config.global_key = Rails.application.credentials.ff1_encryption_key
11
+ #
12
+ # Example using environment variable:
13
+ # config.global_key = ENV['FF1_ENCRYPTION_KEY']&.b
14
+ #
15
+ # For development/testing only (DO NOT use in production):
16
+ config.global_key = SecureRandom.bytes(32) if Rails.env.development? || Rails.env.test?
17
+
18
+ # Default encryption mode for all models
19
+ # :reversible - can decrypt data (default)
20
+ # :irreversible - cannot decrypt data (for GDPR compliance)
21
+ config.default_mode = FF1::Modes::REVERSIBLE
22
+
23
+ # Column names for soft delete functionality
24
+ config.deleted_at_column = :deleted_at
25
+ config.ff1_deleted_column = :ff1_deleted
26
+
27
+ # Hide soft-deleted records from default queries (default: true)
28
+ # Set to false to include soft-deleted records in queries by default
29
+ config.hide_deleted_by_default = true
30
+ end
31
+
32
+ # Example model integration:
33
+ #
34
+ # class User < ApplicationRecord
35
+ # include FF1::ActiveRecord
36
+ #
37
+ # # Encrypt email and phone with reversible encryption
38
+ # ff1_encrypt :email, :phone, mode: :reversible
39
+ #
40
+ # # Encrypt SSN with irreversible encryption
41
+ # ff1_encrypt :ssn, mode: :irreversible
42
+ # end
43
+ #
44
+ # Usage:
45
+ # user = User.create(email: 'user@example.com', ssn: '123-45-6789')
46
+ # puts user.email # Automatically decrypted for reversible columns
47
+ # puts user.ssn # Shows "[ENCRYPTED]" for irreversible columns
48
+ #
49
+ # # GDPR compliant deletion
50
+ # user.destroy # Soft deletes with irreversible encryption
51
+ #
52
+ # # Query scopes
53
+ # User.ff1_active # Non-deleted users
54
+ # User.ff1_deleted # Soft-deleted users
55
+ # User.ff1_all # All users (active + deleted)
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= migration_class %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ def change
5
+ add_column :<%= table_name %>, :<%= deleted_at_column %>, :datetime, null: true
6
+ add_column :<%= table_name %>, :<%= ff1_deleted_column %>, :boolean, null: false, default: false
7
+
8
+ <% if add_indexes? %>
9
+ add_index :<%= table_name %>, :<%= deleted_at_column %>
10
+ add_index :<%= table_name %>, :<%= ff1_deleted_column %>
11
+ add_index :<%= table_name %>, [:<%= ff1_deleted_column %>, :<%= deleted_at_column %>],
12
+ name: 'index_<%= table_name %>_on_ff1_deletion'
13
+ <% end %>
14
+ end
15
+ end
@@ -0,0 +1,382 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ begin
6
+ require 'active_record'
7
+ require 'sqlite3'
8
+ # Explicitly load FF1 ActiveRecord integration
9
+ FF1.load_active_record_integration!
10
+ rescue LoadError => e
11
+ puts "Skipping ActiveRecord tests: #{e.message}"
12
+ return
13
+ end
14
+
15
+ # Setup in-memory SQLite database for testing
16
+ begin
17
+ ActiveRecord::Base.establish_connection(
18
+ adapter: 'sqlite3',
19
+ database: ':memory:'
20
+ )
21
+ rescue => e
22
+ puts "Skipping ActiveRecord tests due to connection error: #{e.message}"
23
+ return
24
+ end
25
+
26
+ # Define test schema
27
+ ActiveRecord::Schema.define do
28
+ create_table :users do |t|
29
+ t.string :name
30
+ t.string :email
31
+ t.string :phone
32
+ t.string :ssn
33
+ t.datetime :deleted_at
34
+ t.boolean :ff1_deleted, default: false
35
+ t.timestamps
36
+ end
37
+
38
+ create_table :posts do |t|
39
+ t.string :title
40
+ t.text :content
41
+ t.integer :user_id
42
+ t.datetime :deleted_at
43
+ t.boolean :ff1_deleted, default: false
44
+ t.timestamps
45
+ end
46
+ end
47
+
48
+ # Test models
49
+ class User < ActiveRecord::Base
50
+ include FF1::ActiveRecord
51
+
52
+ ff1_encrypt :email, :phone, mode: :reversible
53
+ ff1_encrypt :ssn, mode: :irreversible
54
+
55
+ has_many :posts, dependent: :destroy
56
+ end
57
+
58
+ class Post < ActiveRecord::Base
59
+ include FF1::ActiveRecord
60
+
61
+ ff1_encrypt :content, mode: :reversible
62
+
63
+ belongs_to :user
64
+ end
65
+
66
+ RSpec.describe FF1::ActiveRecord do
67
+ let(:encryption_key) { SecureRandom.bytes(32) }
68
+
69
+ before(:all) do
70
+ FF1::ActiveRecord.configure do |config|
71
+ config.global_key = SecureRandom.bytes(32)
72
+ config.default_mode = FF1::Modes::REVERSIBLE
73
+ end
74
+ end
75
+
76
+ before(:each) do
77
+ # Recreate database schema for each test to ensure clean state
78
+ begin
79
+ ActiveRecord::Schema.define do
80
+ drop_table :users if table_exists? :users
81
+ drop_table :posts if table_exists? :posts
82
+
83
+ create_table :users do |t|
84
+ t.string :name
85
+ t.string :email
86
+ t.string :phone
87
+ t.string :ssn
88
+ t.datetime :deleted_at
89
+ t.boolean :ff1_deleted, default: false
90
+ t.timestamps
91
+ end
92
+
93
+ create_table :posts do |t|
94
+ t.string :title
95
+ t.text :content
96
+ t.integer :user_id
97
+ t.datetime :deleted_at
98
+ t.boolean :ff1_deleted, default: false
99
+ t.timestamps
100
+ end
101
+ end
102
+ rescue => e
103
+ puts "Schema setup error: #{e.message}"
104
+ end
105
+ end
106
+
107
+ describe 'configuration' do
108
+ it 'allows global configuration' do
109
+ FF1::ActiveRecord.configure do |config|
110
+ config.global_key = encryption_key
111
+ config.default_mode = FF1::Modes::IRREVERSIBLE
112
+ end
113
+
114
+ expect(FF1::ActiveRecord.configuration.global_key).to eq(encryption_key)
115
+ expect(FF1::ActiveRecord.configuration.default_mode).to eq(FF1::Modes::IRREVERSIBLE)
116
+ end
117
+ end
118
+
119
+ describe 'column encryption configuration' do
120
+ it 'configures encrypted columns correctly' do
121
+ expect(User.ff1_encrypted_columns[:email][:mode]).to eq(FF1::Modes::REVERSIBLE)
122
+ expect(User.ff1_encrypted_columns[:phone][:mode]).to eq(FF1::Modes::REVERSIBLE)
123
+ expect(User.ff1_encrypted_columns[:ssn][:mode]).to eq(FF1::Modes::IRREVERSIBLE)
124
+ end
125
+
126
+ it 'identifies encrypted columns' do
127
+ expect(User.ff1_encrypted_column?(:email)).to be true
128
+ expect(User.ff1_encrypted_column?(:name)).to be false
129
+ end
130
+ end
131
+
132
+ describe 'automatic encryption/decryption' do
133
+ let(:user_data) do
134
+ {
135
+ name: 'John Doe',
136
+ email: 'john@example.com',
137
+ phone: '555-1234',
138
+ ssn: '123-45-6789'
139
+ }
140
+ end
141
+
142
+ it 'encrypts data on save' do
143
+ user = User.new(user_data)
144
+ user.save!
145
+
146
+ # Check that data is encrypted in database
147
+ raw_user = User.connection.select_one("SELECT * FROM users WHERE id = #{user.id}")
148
+ expect(raw_user['email']).not_to eq('john@example.com')
149
+ expect(raw_user['phone']).not_to eq('555-1234')
150
+ expect(raw_user['ssn']).not_to eq('123-45-6789')
151
+
152
+ # But name should not be encrypted
153
+ expect(raw_user['name']).to eq('John Doe')
154
+ end
155
+
156
+ it 'decrypts reversible data on access' do
157
+ user = User.create!(user_data)
158
+
159
+ # Reversible columns should decrypt correctly
160
+ expect(user.email).to eq('john@example.com')
161
+ expect(user.phone).to eq('555-1234')
162
+ end
163
+
164
+ it 'returns placeholder for irreversible data' do
165
+ user = User.create!(user_data)
166
+
167
+ # Irreversible columns should return placeholder
168
+ expect(user.ssn).to eq('[ENCRYPTED]')
169
+ end
170
+
171
+ it 'handles text columns correctly' do
172
+ post = Post.create!(title: 'Test Post', content: 'This is secret content')
173
+
174
+ # Text content should be encrypted/decrypted properly
175
+ expect(post.content).to eq('This is secret content')
176
+
177
+ # Check encryption in database
178
+ raw_post = Post.connection.select_one("SELECT * FROM posts WHERE id = #{post.id}")
179
+ expect(raw_post['content']).not_to eq('This is secret content')
180
+ expect(raw_post['content']).to match(/\A[A-Za-z0-9+\/]+=*\z/) # Base64 pattern
181
+ end
182
+
183
+ it 'handles nil and empty values' do
184
+ user = User.create!(name: 'Test User', email: nil, phone: '', ssn: '123-45-6789')
185
+
186
+ expect(user.email).to be_nil
187
+ expect(user.phone).to eq('')
188
+ expect(user.ssn).to eq('[ENCRYPTED]')
189
+ end
190
+
191
+ it 'detects attribute changes correctly' do
192
+ user = User.create!(user_data)
193
+
194
+ expect(user.ff1_attribute_changed?('email')).to be false
195
+
196
+ user.email = 'newemail@example.com'
197
+ expect(user.ff1_attribute_changed?('email')).to be true
198
+
199
+ user.save!
200
+ expect(user.ff1_attribute_changed?('email')).to be false
201
+ end
202
+ end
203
+
204
+ describe 'soft delete functionality' do
205
+ let(:user) { User.create!(name: 'John Doe', email: 'john@example.com', ssn: '123-45-6789') }
206
+
207
+ it 'performs soft delete instead of hard delete' do
208
+ user # force creation of user before the expectation
209
+ expect { user.destroy }.not_to change { User.ff1_all.count }
210
+
211
+ user.reload
212
+ expect(user.ff1_deleted?).to be true
213
+ expect(user.ff1_deleted_at).to be_within(1.second).of(Time.current)
214
+ end
215
+
216
+ it 'irreversibly encrypts data during soft delete' do
217
+ original_email = user.email
218
+ user.destroy
219
+
220
+ # Email should now be encrypted differently (irreversibly)
221
+ raw_user = User.connection.select_one("SELECT * FROM users WHERE id = #{user.id}")
222
+ expect(raw_user['email']).not_to eq(original_email)
223
+
224
+ # Even after reload, should not be able to decrypt
225
+ user.reload
226
+ expect(user.email).to eq('[ENCRYPTED]')
227
+ end
228
+
229
+ it 'can restore soft-deleted records' do
230
+ user.destroy
231
+ expect(user.ff1_deleted?).to be true
232
+
233
+ user.ff1_restore
234
+ expect(user.ff1_deleted?).to be false
235
+ expect(user.ff1_deleted_at).to be_nil
236
+ end
237
+
238
+ it 'supports hard destroy when needed' do
239
+ user_id = user.id
240
+ user.ff1_hard_destroy!
241
+
242
+ expect { User.find(user_id) }.to raise_error(ActiveRecord::RecordNotFound)
243
+ end
244
+ end
245
+
246
+ describe 'query scopes' do
247
+ let!(:active_user) { User.create!(name: 'Active User', email: 'active@example.com') }
248
+ let!(:deleted_user) { User.create!(name: 'Deleted User', email: 'deleted@example.com') }
249
+
250
+ before do
251
+ deleted_user.destroy
252
+ end
253
+
254
+ it 'ff1_active scope returns only active records' do
255
+ expect(User.ff1_active).to include(active_user)
256
+ expect(User.ff1_active).not_to include(deleted_user)
257
+ end
258
+
259
+ it 'ff1_deleted scope returns only deleted records' do
260
+ expect(User.ff1_deleted).not_to include(active_user)
261
+ expect(User.ff1_deleted).to include(deleted_user)
262
+ end
263
+
264
+ it 'ff1_all scope returns all records' do
265
+ expect(User.ff1_all).to include(active_user)
266
+ expect(User.ff1_all).to include(deleted_user)
267
+ end
268
+
269
+ it 'ff1_recently_deleted scope works with time ranges' do
270
+ expect(User.ff1_recently_deleted(within: 1.hour)).to include(deleted_user)
271
+
272
+ # Sleep to ensure the deletion was more than 1 second ago
273
+ sleep(1.1)
274
+
275
+ expect(User.ff1_recently_deleted(within: 1.second)).not_to include(deleted_user)
276
+ end
277
+
278
+ it 'provides count methods' do
279
+ expect(User.ff1_active_count).to eq(1)
280
+ expect(User.ff1_deleted_count).to eq(1)
281
+ end
282
+
283
+ it 'provides find methods' do
284
+ expect(User.ff1_find_active(active_user.id)).to eq(active_user)
285
+ expect(User.ff1_find_deleted(deleted_user.id)).to eq(deleted_user)
286
+ expect { User.ff1_find_active(deleted_user.id) }.to raise_error(ActiveRecord::RecordNotFound)
287
+ end
288
+
289
+ it 'provides statistics' do
290
+ stats = User.ff1_stats
291
+
292
+ expect(stats[:total_records]).to eq(2)
293
+ expect(stats[:active_records]).to eq(1)
294
+ expect(stats[:deleted_records]).to eq(1)
295
+ expect(stats[:deletion_rate]).to eq(50.0)
296
+ expect(stats[:encrypted_columns]).to include(:email, :phone, :ssn)
297
+ end
298
+ end
299
+
300
+ describe 'bulk operations' do
301
+ let!(:users) do
302
+ 3.times.map do |i|
303
+ User.create!(name: "User #{i}", email: "user#{i}@example.com")
304
+ end
305
+ end
306
+
307
+ it 'supports bulk soft delete' do
308
+ count = User.ff1_destroy_all(name: ['User 0', 'User 1'])
309
+
310
+ expect(count).to eq(2)
311
+ expect(User.ff1_active_count).to eq(1)
312
+ expect(User.ff1_deleted_count).to eq(2)
313
+ end
314
+
315
+ it 'supports purging old deleted records' do
316
+ # Soft delete all users
317
+ users.each(&:destroy)
318
+
319
+ # Simulate old deletion by updating timestamps
320
+ User.ff1_deleted.update_all(deleted_at: 2.months.ago)
321
+
322
+ purged_count = User.ff1_purge_deleted(older_than: 1.month)
323
+ expect(purged_count).to eq(3)
324
+ expect(User.ff1_all.count).to eq(0)
325
+ end
326
+
327
+ it 'supports batch processing' do
328
+ processed = []
329
+ User.ff1_find_each(batch_size: 2) do |user|
330
+ processed << user.id
331
+ end
332
+
333
+ expect(processed.length).to eq(3)
334
+ end
335
+ end
336
+
337
+ describe 'error handling' do
338
+ before do
339
+ # Temporarily break the global key to test error handling
340
+ allow(FF1::ActiveRecord.configuration).to receive(:global_key).and_return(nil)
341
+ end
342
+
343
+ it 'raises appropriate errors for missing keys' do
344
+ expect {
345
+ User.new(email: 'test@example.com').save!
346
+ }.to raise_error(FF1::Error, /No encryption key provided/)
347
+ end
348
+ end
349
+
350
+ describe 'thread safety' do
351
+ it 'handles concurrent access safely' do
352
+ # SQLite in-memory databases don't work well with threading
353
+ # This test verifies that our encryption logic is thread-safe
354
+ # by creating users sequentially but with concurrent-like operations
355
+
356
+ users = []
357
+ errors = []
358
+
359
+ 10.times do |i|
360
+ begin
361
+ user = User.create!(
362
+ name: "User #{i}",
363
+ email: "user#{i}@example.com",
364
+ phone: "555-000#{i}"
365
+ )
366
+ users << user
367
+ rescue => e
368
+ errors << "User #{i} failed: #{e.message}"
369
+ end
370
+ end
371
+
372
+ # All users should be created successfully
373
+ expect(errors).to be_empty, "Errors occurred: #{errors.join(', ')}"
374
+ expect(users.length).to eq(10)
375
+
376
+ users.each do |user|
377
+ expect(user.email).to match(/user\d+@example\.com/)
378
+ expect(user.phone).to match(/555-000\d/)
379
+ end
380
+ end
381
+ end
382
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ begin
6
+ require 'rails/generators'
7
+ require 'generators/ff1/install_generator'
8
+ require 'tempfile'
9
+ require 'fileutils'
10
+ # Explicitly load FF1 ActiveRecord integration
11
+ FF1.load_active_record_integration!
12
+ rescue LoadError => e
13
+ puts "Skipping generator tests: #{e.message}"
14
+ return
15
+ end
16
+
17
+ RSpec.describe FF1::Generators::InstallGenerator, type: :generator do
18
+ include FileUtils
19
+
20
+ let(:temp_dir) { Dir.mktmpdir }
21
+ let(:destination_root) { temp_dir }
22
+
23
+ before do
24
+ # Save current directory first
25
+ @original_dir = Dir.pwd
26
+
27
+ # Setup Rails-like directory structure
28
+ mkdir_p File.join(temp_dir, 'config', 'initializers')
29
+ mkdir_p File.join(temp_dir, 'db', 'migrate')
30
+ mkdir_p File.join(temp_dir, 'app', 'models')
31
+
32
+ # Create a more complete Rails mock with Generators module
33
+ actions_mock = Module.new do
34
+ def template(source, destination, config = {})
35
+ # Simple template method - just copy content with basic ERB processing
36
+ source_path = File.join(File.expand_path('../../../lib/generators/ff1/templates', __dir__), source)
37
+ if File.exist?(source_path)
38
+ content = File.read(source_path)
39
+ # Basic ERB processing for migration templates
40
+ if source.end_with?('.erb')
41
+ content = content.gsub(/<%= migration_class %>/, config[:migration_class] || 'TestMigration')
42
+ content = content.gsub(/<%= table_name %>/, config[:table_name] || 'test_table')
43
+ content = content.gsub(/<%= deleted_at_column %>/, config[:deleted_at_column] || 'deleted_at')
44
+ content = content.gsub(/<%= ff1_deleted_column %>/, config[:ff1_deleted_column] || 'ff1_deleted')
45
+
46
+ # Handle conditional blocks - check if indexes should be added
47
+ should_add_indexes = config.key?(:add_indexes) ? config[:add_indexes] : true
48
+
49
+ if should_add_indexes
50
+ content = content.gsub(/<% if add_indexes\? %>/, '')
51
+ content = content.gsub(/<% end %>/, '')
52
+ else
53
+ # Remove the index section if add_indexes? is false
54
+ content = content.gsub(/<% if add_indexes\? %>.*?<% end %>/m, '')
55
+ end
56
+
57
+ # Handle ActiveRecord version
58
+ content = content.gsub(/<%= ActiveRecord::Migration\.current_version %>/, '7.0')
59
+ end
60
+ File.write(destination, content)
61
+ else
62
+ # Fallback for missing templates
63
+ File.write(destination, "# Generated migration\n")
64
+ end
65
+ end
66
+
67
+ def migration_template(source, destination, config = {})
68
+ template(source, destination, config)
69
+ end
70
+ end
71
+
72
+ migration_mock = Module.new
73
+
74
+ generators_mock = Module.new do
75
+ const_set('Migration', migration_mock)
76
+ const_set('Actions', actions_mock)
77
+ end
78
+
79
+ rails_mock = Module.new do
80
+ def self.root
81
+ Pathname.new(Dir.pwd)
82
+ end
83
+
84
+ const_set('Generators', generators_mock)
85
+ end
86
+
87
+ # Replace Rails constant for the duration of the test
88
+ stub_const('Rails', rails_mock)
89
+
90
+ # Extend the generator class with our mocked Actions module
91
+ FF1::Generators::InstallGenerator.send(:include, actions_mock)
92
+
93
+ # Change to temp directory for generator
94
+ Dir.chdir(temp_dir)
95
+ end
96
+
97
+ after do
98
+ Dir.chdir(@original_dir) if @original_dir
99
+ FileUtils.rm_rf(temp_dir) if temp_dir && Dir.exist?(temp_dir)
100
+ end
101
+
102
+ describe 'initializer generation' do
103
+ it 'generates initializer when no models specified' do
104
+ generator = described_class.new([])
105
+
106
+ expect { generator.generate_migration }.not_to raise_error
107
+
108
+ initializer_path = File.join('config', 'initializers', 'ff1.rb')
109
+ expect(File.exist?(initializer_path)).to be true
110
+
111
+ content = File.read(initializer_path)
112
+ expect(content).to include('FF1::ActiveRecord.configure')
113
+ expect(content).to include('config.global_key')
114
+ expect(content).to include('SecureRandom.bytes(32)')
115
+ end
116
+ end
117
+
118
+ describe 'model migration generation' do
119
+ before do
120
+ # Create a mock User model
121
+ user_model = <<~RUBY
122
+ class User < ApplicationRecord
123
+ include FF1::ActiveRecord
124
+ ff1_encrypt :email, :phone
125
+ end
126
+ RUBY
127
+
128
+ File.write(File.join('app', 'models', 'user.rb'), user_model)
129
+
130
+ # Mock the User class
131
+ stub_const('User', Class.new)
132
+ end
133
+
134
+ it 'generates migration for specified model' do
135
+ generator = described_class.new(['User'])
136
+
137
+ expect { generator.generate_migration }.not_to raise_error
138
+
139
+ # Check migration file was created
140
+ migration_files = Dir.glob(File.join('db', 'migrate', '*_add_ff1_columns_to_users.rb'))
141
+ expect(migration_files).not_to be_empty
142
+
143
+ migration_content = File.read(migration_files.first)
144
+ expect(migration_content).to include('add_column :users, :deleted_at, :datetime')
145
+ expect(migration_content).to include('add_column :users, :ff1_deleted, :boolean')
146
+ expect(migration_content).to include('add_index :users, :deleted_at')
147
+ expect(migration_content).to include('add_index :users, :ff1_deleted')
148
+ end
149
+
150
+ it 'generates migrations for multiple models' do
151
+ generator = described_class.new(['User', 'Post'])
152
+
153
+ expect { generator.generate_migration }.not_to raise_error
154
+
155
+ user_migration = Dir.glob(File.join('db', 'migrate', '*_add_ff1_columns_to_users.rb'))
156
+ post_migration = Dir.glob(File.join('db', 'migrate', '*_add_ff1_columns_to_posts.rb'))
157
+
158
+ expect(user_migration).not_to be_empty
159
+ expect(post_migration).not_to be_empty
160
+ end
161
+
162
+ it 'respects custom column names' do
163
+ generator = described_class.new(
164
+ ['User'],
165
+ deleted_at_column: 'removed_at',
166
+ ff1_deleted_column: 'is_encrypted'
167
+ )
168
+
169
+ expect { generator.generate_migration }.not_to raise_error
170
+
171
+ migration_files = Dir.glob(File.join('db', 'migrate', '*_add_ff1_columns_to_users.rb'))
172
+ migration_content = File.read(migration_files.first)
173
+
174
+ expect(migration_content).to include('add_column :users, :removed_at, :datetime')
175
+ expect(migration_content).to include('add_column :users, :is_encrypted, :boolean')
176
+ end
177
+
178
+ it 'can skip indexes when requested' do
179
+ generator = described_class.new(['User'], add_indexes: false)
180
+
181
+ expect { generator.generate_migration }.not_to raise_error
182
+
183
+ migration_files = Dir.glob(File.join('db', 'migrate', '*_add_ff1_columns_to_users.rb'))
184
+ migration_content = File.read(migration_files.first)
185
+
186
+ expect(migration_content).not_to include('add_index')
187
+ end
188
+
189
+ it 'handles non-existent models gracefully' do
190
+ generator = described_class.new(['NonExistentModel'])
191
+
192
+ expect { generator.generate_migration }.not_to raise_error
193
+
194
+ migration_files = Dir.glob(File.join('db', 'migrate', '*_add_ff1_columns_to_non_existent_models.rb'))
195
+ expect(migration_files).not_to be_empty
196
+ end
197
+ end
198
+
199
+ describe 'migration template' do
200
+ it 'generates valid ActiveRecord migration' do
201
+ generator = described_class.new(['TestModel'])
202
+ generator.generate_migration
203
+
204
+ migration_files = Dir.glob(File.join('db', 'migrate', '*_add_ff1_columns_to_test_models.rb'))
205
+ migration_content = File.read(migration_files.first)
206
+
207
+ # Should be valid Ruby code
208
+ expect { eval(migration_content) }.not_to raise_error
209
+
210
+ # Should include proper migration structure
211
+ expect(migration_content).to include('class AddFf1ColumnsToTestModels < ActiveRecord::Migration')
212
+ expect(migration_content).to include('def change')
213
+ end
214
+
215
+ it 'includes proper timestamp in filename' do
216
+ generator = described_class.new(['User'])
217
+
218
+ # Mock timestamp
219
+ fixed_time = Time.new(2023, 1, 1, 12, 0, 0)
220
+ allow(Time).to receive(:now).and_return(fixed_time)
221
+
222
+ generator.generate_migration
223
+
224
+ migration_files = Dir.glob(File.join('db', 'migrate', '20230101120000_add_ff1_columns_to_users.rb'))
225
+ expect(migration_files).not_to be_empty
226
+ end
227
+ end
228
+
229
+ describe 'initializer template' do
230
+ it 'includes comprehensive configuration examples' do
231
+ generator = described_class.new([])
232
+ generator.generate_migration
233
+
234
+ initializer_content = File.read(File.join('config', 'initializers', 'ff1.rb'))
235
+
236
+ expect(initializer_content).to include('Rails.application.credentials.ff1_encryption_key')
237
+ expect(initializer_content).to include("ENV['FF1_ENCRYPTION_KEY']")
238
+ expect(initializer_content).to include('config.default_mode = FF1::Modes::REVERSIBLE')
239
+ expect(initializer_content).to include('config.deleted_at_column = :deleted_at')
240
+ expect(initializer_content).to include('config.ff1_deleted_column = :ff1_deleted')
241
+
242
+ # Should include usage examples
243
+ expect(initializer_content).to include('class User < ApplicationRecord')
244
+ expect(initializer_content).to include('include FF1::ActiveRecord')
245
+ expect(initializer_content).to include('ff1_encrypt :email, :phone')
246
+ expect(initializer_content).to include('user.destroy # Soft deletes')
247
+ expect(initializer_content).to include('User.ff1_active')
248
+ end
249
+ end
250
+ end