familia 2.0.0.pre3 → 2.0.0.pre5

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.
Files changed (135) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -0
  3. data/.rubocop_todo.yml +17 -17
  4. data/CLAUDE.md +3 -3
  5. data/Gemfile +5 -1
  6. data/Gemfile.lock +18 -3
  7. data/README.md +36 -157
  8. data/TEST_COVERAGE.md +40 -0
  9. data/docs/overview.md +359 -0
  10. data/docs/wiki/API-Reference.md +270 -0
  11. data/docs/wiki/Encrypted-Fields-Overview.md +64 -0
  12. data/docs/wiki/Home.md +49 -0
  13. data/docs/wiki/Implementation-Guide.md +183 -0
  14. data/docs/wiki/Security-Model.md +143 -0
  15. data/lib/familia/base.rb +18 -27
  16. data/lib/familia/connection.rb +6 -5
  17. data/lib/familia/{datatype → data_type}/commands.rb +2 -5
  18. data/lib/familia/{datatype → data_type}/serialization.rb +8 -10
  19. data/lib/familia/{datatype → data_type}/types/hashkey.rb +2 -2
  20. data/lib/familia/{datatype → data_type}/types/list.rb +17 -18
  21. data/lib/familia/{datatype → data_type}/types/sorted_set.rb +17 -17
  22. data/lib/familia/{datatype → data_type}/types/string.rb +2 -1
  23. data/lib/familia/{datatype → data_type}/types/unsorted_set.rb +17 -18
  24. data/lib/familia/{datatype.rb → data_type.rb} +10 -12
  25. data/lib/familia/encryption/manager.rb +102 -0
  26. data/lib/familia/encryption/provider.rb +49 -0
  27. data/lib/familia/encryption/providers/aes_gcm_provider.rb +103 -0
  28. data/lib/familia/encryption/providers/secure_xchacha20_poly1305_provider.rb +184 -0
  29. data/lib/familia/encryption/providers/xchacha20_poly1305_provider.rb +118 -0
  30. data/lib/familia/encryption/registry.rb +50 -0
  31. data/lib/familia/encryption.rb +178 -0
  32. data/lib/familia/encryption_request_cache.rb +68 -0
  33. data/lib/familia/features/encrypted_fields/encrypted_field_type.rb +153 -0
  34. data/lib/familia/features/encrypted_fields.rb +28 -0
  35. data/lib/familia/features/expiration.rb +107 -77
  36. data/lib/familia/features/quantization.rb +5 -9
  37. data/lib/familia/features/relatable_objects.rb +2 -4
  38. data/lib/familia/features/safe_dump.rb +14 -17
  39. data/lib/familia/features/transient_fields/redacted_string.rb +159 -0
  40. data/lib/familia/features/transient_fields/single_use_redacted_string.rb +62 -0
  41. data/lib/familia/features/transient_fields/transient_field_type.rb +139 -0
  42. data/lib/familia/features/transient_fields.rb +47 -0
  43. data/lib/familia/features.rb +40 -24
  44. data/lib/familia/field_type.rb +270 -0
  45. data/lib/familia/horreum/connection.rb +8 -11
  46. data/lib/familia/horreum/{commands.rb → database_commands.rb} +7 -19
  47. data/lib/familia/horreum/definition_methods.rb +453 -0
  48. data/lib/familia/horreum/{class_methods.rb → management_methods.rb} +19 -229
  49. data/lib/familia/horreum/serialization.rb +46 -18
  50. data/lib/familia/horreum/settings.rb +10 -2
  51. data/lib/familia/horreum/utils.rb +9 -10
  52. data/lib/familia/horreum.rb +18 -10
  53. data/lib/familia/logging.rb +14 -14
  54. data/lib/familia/settings.rb +39 -3
  55. data/lib/familia/utils.rb +45 -0
  56. data/lib/familia/version.rb +1 -1
  57. data/lib/familia.rb +2 -1
  58. data/try/core/base_enhancements_try.rb +115 -0
  59. data/try/core/connection_try.rb +0 -1
  60. data/try/core/errors_try.rb +0 -1
  61. data/try/core/familia_extended_try.rb +3 -4
  62. data/try/core/familia_try.rb +0 -1
  63. data/try/core/pools_try.rb +2 -2
  64. data/try/core/secure_identifier_try.rb +0 -1
  65. data/try/core/settings_try.rb +0 -1
  66. data/try/core/utils_try.rb +0 -1
  67. data/try/{datatypes → data_types}/boolean_try.rb +1 -2
  68. data/try/{datatypes → data_types}/datatype_base_try.rb +2 -3
  69. data/try/{datatypes → data_types}/hash_try.rb +1 -2
  70. data/try/{datatypes → data_types}/list_try.rb +1 -2
  71. data/try/{datatypes → data_types}/set_try.rb +1 -2
  72. data/try/{datatypes → data_types}/sorted_set_try.rb +1 -2
  73. data/try/{datatypes → data_types}/string_try.rb +1 -2
  74. data/try/debugging/README.md +32 -0
  75. data/try/debugging/cache_behavior_tracer.rb +91 -0
  76. data/try/debugging/encryption_method_tracer.rb +138 -0
  77. data/try/debugging/provider_diagnostics.rb +110 -0
  78. data/try/edge_cases/hash_symbolization_try.rb +0 -1
  79. data/try/edge_cases/json_serialization_try.rb +0 -1
  80. data/try/edge_cases/reserved_keywords_try.rb +42 -11
  81. data/try/encryption/config_persistence_try.rb +192 -0
  82. data/try/encryption/encryption_core_try.rb +328 -0
  83. data/try/encryption/instance_variable_scope_try.rb +31 -0
  84. data/try/encryption/module_loading_try.rb +28 -0
  85. data/try/encryption/providers/aes_gcm_provider_try.rb +178 -0
  86. data/try/encryption/providers/xchacha20_poly1305_provider_try.rb +169 -0
  87. data/try/encryption/roundtrip_validation_try.rb +28 -0
  88. data/try/encryption/secure_memory_handling_try.rb +125 -0
  89. data/try/features/encrypted_fields_core_try.rb +117 -0
  90. data/try/features/encrypted_fields_integration_try.rb +220 -0
  91. data/try/features/encrypted_fields_no_cache_security_try.rb +205 -0
  92. data/try/features/encrypted_fields_security_try.rb +370 -0
  93. data/try/features/encryption_fields/aad_protection_try.rb +53 -0
  94. data/try/features/encryption_fields/context_isolation_try.rb +120 -0
  95. data/try/features/encryption_fields/error_conditions_try.rb +116 -0
  96. data/try/features/encryption_fields/fresh_key_derivation_try.rb +122 -0
  97. data/try/features/encryption_fields/fresh_key_try.rb +163 -0
  98. data/try/features/encryption_fields/key_rotation_try.rb +117 -0
  99. data/try/features/encryption_fields/memory_security_try.rb +37 -0
  100. data/try/features/encryption_fields/missing_current_key_version_try.rb +23 -0
  101. data/try/features/encryption_fields/nonce_uniqueness_try.rb +54 -0
  102. data/try/features/encryption_fields/thread_safety_try.rb +199 -0
  103. data/try/features/expiration_try.rb +0 -1
  104. data/try/features/feature_dependencies_try.rb +159 -0
  105. data/try/features/quantization_try.rb +0 -1
  106. data/try/features/real_feature_integration_try.rb +148 -0
  107. data/try/features/relatable_objects_try.rb +0 -1
  108. data/try/features/safe_dump_advanced_try.rb +0 -1
  109. data/try/features/safe_dump_try.rb +0 -1
  110. data/try/features/transient_fields/redacted_string_try.rb +248 -0
  111. data/try/features/transient_fields/refresh_reset_try.rb +164 -0
  112. data/try/features/transient_fields/simple_refresh_test.rb +50 -0
  113. data/try/features/transient_fields/single_use_redacted_string_try.rb +310 -0
  114. data/try/features/transient_fields_core_try.rb +181 -0
  115. data/try/features/transient_fields_integration_try.rb +260 -0
  116. data/try/helpers/test_helpers.rb +42 -0
  117. data/try/horreum/base_try.rb +157 -3
  118. data/try/horreum/class_methods_try.rb +27 -36
  119. data/try/horreum/enhanced_conflict_handling_try.rb +176 -0
  120. data/try/horreum/field_categories_try.rb +118 -0
  121. data/try/horreum/field_definition_try.rb +96 -0
  122. data/try/horreum/initialization_try.rb +0 -1
  123. data/try/horreum/relations_try.rb +0 -1
  124. data/try/horreum/serialization_persistent_fields_try.rb +165 -0
  125. data/try/horreum/serialization_try.rb +2 -3
  126. data/try/memory/memory_basic_test.rb +73 -0
  127. data/try/memory/memory_detailed_test.rb +121 -0
  128. data/try/memory/memory_docker_ruby_dump.sh +80 -0
  129. data/try/memory/memory_search_for_string.rb +83 -0
  130. data/try/memory/test_actual_redactedstring_protection.rb +38 -0
  131. data/try/models/customer_safe_dump_try.rb +0 -1
  132. data/try/models/customer_try.rb +0 -1
  133. data/try/models/datatype_base_try.rb +1 -2
  134. data/try/models/familia_object_try.rb +0 -1
  135. metadata +85 -18
@@ -0,0 +1,310 @@
1
+ # try/features/transient_fields/single_use_redacted_string_try.rb
2
+
3
+ require_relative '../../helpers/test_helpers'
4
+
5
+ @otp_code = "123456"
6
+ @auth_token = "temp-auth-token-xyz"
7
+ @encryption_key = "encryption-key-abc123"
8
+ @empty_secret = ""
9
+ @long_secret = "x" * 50 # Test long string handling
10
+ @special_chars = "tëmp!@#$%"
11
+
12
+ ## Basic initialization creates SingleUseRedactedString instance
13
+ single_use = SingleUseRedactedString.new(@otp_code)
14
+ single_use.class
15
+ #=> SingleUseRedactedString
16
+
17
+ ## SingleUseRedactedString inherits from RedactedString
18
+ single_use_inheritance = SingleUseRedactedString.new(@auth_token)
19
+ single_use_inheritance.is_a?(RedactedString)
20
+ #=> true
21
+
22
+ ## Initialization accepts various input types
23
+ SingleUseRedactedString.new("string").class
24
+ #=> SingleUseRedactedString
25
+
26
+ SingleUseRedactedString.new(123).class # to_s conversion
27
+ #=> SingleUseRedactedString
28
+
29
+ SingleUseRedactedString.new(nil).class # nil handling
30
+ #=> SingleUseRedactedString
31
+
32
+ ## Fresh instance is not cleared initially
33
+ fresh_single_use = SingleUseRedactedString.new(@otp_code)
34
+ fresh_single_use.cleared?
35
+ #=> false
36
+
37
+ ## to_s returns redacted placeholder (inherited behavior)
38
+ single_use_to_s = SingleUseRedactedString.new(@auth_token)
39
+ single_use_to_s.to_s
40
+ #=> "[REDACTED]"
41
+
42
+ ## inspect returns same as to_s (inherited behavior)
43
+ single_use_inspect = SingleUseRedactedString.new(@encryption_key)
44
+ single_use_inspect.inspect
45
+ #=> "[REDACTED]"
46
+
47
+ ## String interpolation is redacted (inherited behavior)
48
+ single_use_interpolation = SingleUseRedactedString.new(@otp_code)
49
+ "OTP: #{single_use_interpolation}"
50
+ #=> "OTP: [REDACTED]"
51
+
52
+ ## Direct value() access raises SecurityError (overridden behavior)
53
+ single_use_direct_access = SingleUseRedactedString.new(@auth_token)
54
+ begin
55
+ single_use_direct_access.value
56
+ rescue SecurityError => e
57
+ e.message
58
+ end
59
+ #=> "Direct value access not allowed for single-use secrets. Use #expose with a block."
60
+
61
+ ## expose method requires block (inherited behavior)
62
+ single_use_no_block = SingleUseRedactedString.new(@otp_code)
63
+ begin
64
+ single_use_no_block.expose
65
+ rescue ArgumentError => e
66
+ e.message
67
+ end
68
+ #=> "Block required"
69
+
70
+ ## expose method provides access to original value
71
+ single_use_expose = SingleUseRedactedString.new("123456")
72
+ result = nil
73
+ single_use_expose.expose { |val| result = val.dup }
74
+ result
75
+ #=> "123456"
76
+
77
+ ## expose automatically clears value after use (key single-use behavior)
78
+ single_use_auto_clear = SingleUseRedactedString.new(@otp_code)
79
+ single_use_auto_clear.expose { |val| val.length }
80
+ single_use_auto_clear.cleared?
81
+ #=> true
82
+
83
+ ## Second expose attempt raises SecurityError after clearing
84
+ single_use_second_expose = SingleUseRedactedString.new(@auth_token)
85
+ single_use_second_expose.expose { |val| val.upcase } # First use
86
+ begin
87
+ single_use_second_expose.expose { |val| val } # Second attempt
88
+ rescue SecurityError => e
89
+ e.message
90
+ end
91
+ #=> "Value already cleared"
92
+
93
+ ## expose clears value even when exception occurs in block
94
+ single_use_exception = SingleUseRedactedString.new(@encryption_key)
95
+ begin
96
+ single_use_exception.expose { |val| raise "test error" }
97
+ rescue => e
98
+ # Exception occurred, but string should still be cleared
99
+ end
100
+ single_use_exception.cleared?
101
+ #=> true
102
+
103
+ ## expose clears value even when block returns early
104
+ single_use_early_return = SingleUseRedactedString.new(@otp_code)
105
+ result = single_use_early_return.expose do |val|
106
+ next "early" if val.length > 0
107
+ "normal"
108
+ end
109
+ single_use_early_return.cleared?
110
+ #=> true
111
+
112
+ ## Multiple values can be processed in the expose block
113
+ single_use_multiple_ops = SingleUseRedactedString.new("password123")
114
+ results = []
115
+ single_use_multiple_ops.expose do |val|
116
+ results << val.length
117
+ results << val.upcase
118
+ results << val.include?("123")
119
+ end
120
+ results
121
+ #=> [11, "PASSWORD123", true]
122
+
123
+ ## expose with empty string
124
+ single_use_empty = SingleUseRedactedString.new(@empty_secret)
125
+ result = nil
126
+ single_use_empty.expose { |val| result = val.dup }
127
+ result
128
+ #=> ""
129
+
130
+ ## Can manually expose the length of the value without duplicating
131
+ single_use_long = SingleUseRedactedString.new(@long_secret)
132
+ result = nil
133
+ single_use_long.expose { |val| result = val.length }
134
+ result
135
+ #=> 50
136
+
137
+ ## Can manually expose the length of the value by duplicating
138
+ single_use_long = SingleUseRedactedString.new(@long_secret)
139
+ result = nil
140
+ single_use_long.expose { |val| result = val.dup.length }
141
+ result
142
+ #=> 50
143
+
144
+ ## Cannot manually expose the value
145
+ single_use_special = SingleUseRedactedString.new(@special_chars)
146
+ result = nil
147
+ single_use_special.expose { |val| result = val }
148
+ result
149
+ #=> ""
150
+
151
+ ## Can manually expose the value with special characters by duplicating
152
+ single_use_special = SingleUseRedactedString.new(@special_chars)
153
+ result = nil
154
+ single_use_special.expose { |val| result = val.dup }
155
+ result
156
+ #=> "tëmp!@#$%"
157
+
158
+ ## Can manually expose the value with special characters by duplicating
159
+ single_use_special = SingleUseRedactedString.new(@special_chars)
160
+ result = nil
161
+ single_use_special.expose { |val| result = val.dup }
162
+ result
163
+ ##=> "tëmp!@#$%"
164
+
165
+ ## expose with special characters via raw method (IN TEST ONLY)
166
+ module SpecialTestonlyDirectAccess
167
+ using SingleUseRedactedStringTestHelper
168
+ single_use_special = SingleUseRedactedString.new("tëmp!@#$%")
169
+ # Use raw to access the internal value before expose clears it
170
+ single_use_special.raw
171
+ end
172
+ #=> "tëmp!@#$%"
173
+
174
+ ## Cleared single-use string maintains redacted appearance
175
+ single_use_appearance = SingleUseRedactedString.new(@auth_token)
176
+ single_use_appearance.expose { |val| val } # Use and clear
177
+ single_use_appearance.to_s
178
+ #=> "[REDACTED]"
179
+
180
+ ## Cleared single-use string inspect still redacted
181
+ single_use_inspect_cleared = SingleUseRedactedString.new(@otp_code)
182
+ single_use_inspect_cleared.expose { |val| val } # Use and clear
183
+ single_use_inspect_cleared.inspect
184
+ #=> "[REDACTED]"
185
+
186
+ ## Object equality works same as parent (inherited behavior)
187
+ single_use1 = SingleUseRedactedString.new(@auth_token)
188
+ single_use2 = SingleUseRedactedString.new(@auth_token)
189
+ single_use1 == single_use2
190
+ #=> false
191
+
192
+ ## Same object equality returns true (inherited behavior)
193
+ single_use_same = SingleUseRedactedString.new(@otp_code)
194
+ single_use_same == single_use_same
195
+ #=> true
196
+
197
+ ## eql? behaves same as == (inherited behavior)
198
+ single_use_eql1 = SingleUseRedactedString.new(@encryption_key)
199
+ single_use_eql2 = SingleUseRedactedString.new(@encryption_key)
200
+ single_use_eql1.eql?(single_use_eql2)
201
+ #=> false
202
+
203
+ ## Hash behavior consistent with parent (inherited behavior)
204
+ single_use_hash1 = SingleUseRedactedString.new(@auth_token)
205
+ single_use_hash2 = SingleUseRedactedString.new(@otp_code)
206
+ single_use_hash1.hash == single_use_hash2.hash
207
+ #=> true
208
+
209
+ ## Hash value consistent with RedactedString class (inherited behavior)
210
+ single_use_hash = SingleUseRedactedString.new(@encryption_key)
211
+ single_use_hash.hash == RedactedString.hash
212
+ #=> true
213
+
214
+ ## Cannot be used in string operations (inherited behavior)
215
+ single_use_no_concat = SingleUseRedactedString.new(@auth_token)
216
+ begin
217
+ result = single_use_no_concat + "suffix"
218
+ false # Should not reach here
219
+ rescue => e
220
+ true # Expected to raise error
221
+ end
222
+ #=> true
223
+
224
+ ## Not a String subclass (inherited behavior)
225
+ single_use_type = SingleUseRedactedString.new(@otp_code)
226
+ single_use_type.is_a?(String)
227
+ #=> false
228
+
229
+ ## Numeric input handling
230
+ single_use_numeric = SingleUseRedactedString.new(42)
231
+ result = nil
232
+ single_use_numeric.expose { |val| result = val.dup }
233
+ result
234
+ #=> "42"
235
+
236
+ ## Symbol input handling
237
+ single_use_symbol = SingleUseRedactedString.new(:secret)
238
+ result = nil
239
+ single_use_symbol.expose { |val| result = val.dup }
240
+ result
241
+ #=> "secret"
242
+
243
+ ## Nil input handling
244
+ single_use_nil = SingleUseRedactedString.new(nil)
245
+ result = nil
246
+ single_use_nil.expose { |val| result = val.dup }
247
+ result
248
+ #=> ""
249
+
250
+ ## Block can return different values
251
+ single_use_return_test = SingleUseRedactedString.new("test123")
252
+ result = single_use_return_test.expose { |val| "processed: #{val.length}" }
253
+ result
254
+ #=> "processed: 7"
255
+
256
+ ## clear! method works on SingleUseRedactedString (inherited behavior)
257
+ single_use_manual_clear = SingleUseRedactedString.new(@auth_token)
258
+ single_use_manual_clear.clear!
259
+ single_use_manual_clear.cleared?
260
+ #=> true
261
+
262
+ ## Manual clear! prevents subsequent expose
263
+ single_use_manual_then_expose = SingleUseRedactedString.new(@otp_code)
264
+ single_use_manual_then_expose.clear!
265
+ begin
266
+ single_use_manual_then_expose.expose { |val| val }
267
+ rescue SecurityError => e
268
+ e.message
269
+ end
270
+ #=> "Value already cleared"
271
+
272
+ ## freeze behavior after expose (automatic clearing freezes object)
273
+ single_use_freeze = SingleUseRedactedString.new(@encryption_key)
274
+ single_use_freeze.expose { |val| val }
275
+ single_use_freeze.frozen?
276
+ #=> true
277
+
278
+ ## Working with sensitive data patterns - OTP example
279
+ otp = SingleUseRedactedString.new("123456")
280
+ verification_result = otp.expose do |code|
281
+ # Simulate OTP verification
282
+ code == "123456" ? "valid" : "invalid"
283
+ end
284
+ # OTP is now unusable
285
+ [verification_result, otp.cleared?]
286
+ #=> ["valid", true]
287
+
288
+ ## Working with sensitive data patterns - temporary token example
289
+ temp_token = SingleUseRedactedString.new("temp-xyz-789")
290
+ auth_result = temp_token.expose do |token|
291
+ # Simulate authentication
292
+ { success: true, token_length: token.length }
293
+ end
294
+ # Token is now unusable
295
+ [auth_result[:success], temp_token.cleared?]
296
+ #=> [true, true]
297
+
298
+
299
+ # TEARDOWN
300
+
301
+ # Clean up any remaining test objects
302
+ @otp_code = nil
303
+ @auth_token = nil
304
+ @encryption_key = nil
305
+ @empty_secret = nil
306
+ @long_secret = nil
307
+ @special_chars = nil
308
+
309
+ # Force garbage collection to trigger any finalizers
310
+ GC.start
@@ -0,0 +1,181 @@
1
+ # try/features/transient_fields_core_try.rb
2
+
3
+ require_relative '../helpers/test_helpers'
4
+
5
+ class SecretService < Familia::Horreum
6
+ feature :transient_fields
7
+
8
+ field :name
9
+ field :endpoint_url
10
+ transient_field :api_key
11
+ transient_field :password
12
+ transient_field :secret_token, as: :token
13
+ end
14
+
15
+ @service = SecretService.new
16
+ @service.name = 'Test API Service'
17
+ @service.endpoint_url = 'https://api.example.com'
18
+ @service.api_key = 'sk-1234567890abcdef'
19
+ @service.password = 'super_secret_password'
20
+ @service.token = 'token-xyz789'
21
+
22
+ ## Class has correct field definitions
23
+ SecretService.fields.sort
24
+ #=> [:api_key, :endpoint_url, :name, :password, :secret_token]
25
+
26
+ ## Persistent fields exclude transient ones
27
+ SecretService.persistent_fields.sort
28
+ #=> [:endpoint_url, :name]
29
+
30
+ ## Transient field definitions have correct category
31
+ SecretService.field_types[:api_key].category
32
+ #=> :transient
33
+
34
+ ## Password field definition has correct category
35
+ SecretService.field_types[:password].category
36
+ #=> :transient
37
+
38
+ ## Secret token field definition has correct category
39
+ SecretService.field_types[:secret_token].category
40
+ #=> :transient
41
+
42
+ ## Regular field definition has correct category
43
+ SecretService.field_types[:name].category
44
+ #=> :field
45
+
46
+ ## Transient field stores RedactedString object for api_key
47
+ @service.api_key.class
48
+ #=> RedactedString
49
+
50
+ ## Transient field stores RedactedString object for password
51
+ @service.password.class
52
+ #=> RedactedString
53
+
54
+ ## Transient field stores RedactedString object for token alias
55
+ @service.token.class
56
+ #=> RedactedString
57
+
58
+ ## Regular field stores normal string value for name
59
+ @service.name.class
60
+ #=> String
61
+
62
+ ## Regular field stores normal string value for endpoint_url
63
+ @service.endpoint_url.class
64
+ #=> String
65
+
66
+ ## Transient field value is redacted in string representation
67
+ @service.api_key.to_s
68
+ #=> "[REDACTED]"
69
+
70
+ ## Transient field value is redacted in inspect output
71
+ @service.password.inspect
72
+ #=> "[REDACTED]"
73
+
74
+ ## Transient field can expose value securely through block
75
+ result = nil
76
+ @service.api_key.expose { |val| result = val.dup }
77
+ result
78
+ #=> "sk-1234567890abcdef"
79
+
80
+ ## Transient field with custom method name exposes value correctly
81
+ result = nil
82
+ @service.token.expose { |val| result = val.dup }
83
+ result
84
+ #=> "token-xyz789"
85
+
86
+ ## Setting transient field with existing RedactedString works
87
+ already_redacted = RedactedString.new('already_wrapped')
88
+ @service.password = already_redacted
89
+ @service.password.class
90
+ #=> RedactedString
91
+
92
+ ## Serialization to_h only includes persistent fields
93
+ hash_result = @service.to_h
94
+ hash_result.keys.sort
95
+ #=> [:endpoint_url, :name]
96
+
97
+ ## Serialization to_h excludes api_key transient field
98
+ hash_result = @service.to_h
99
+ hash_result.key?('api_key')
100
+ #=> false
101
+
102
+ ## Serialization to_h excludes password transient field
103
+ hash_result = @service.to_h
104
+ hash_result.key?('password')
105
+ #=> false
106
+
107
+ ## Serialization to_h excludes secret_token transient field
108
+ hash_result = @service.to_h
109
+ hash_result.key?('secret_token')
110
+ #=> false
111
+
112
+ ## Serialization to_a only includes persistent field values
113
+ array_result = @service.to_a
114
+ array_result.length
115
+ #=> 2
116
+
117
+ ## String interpolation with transient field shows redacted value
118
+ log_message = "Connecting to #{@service.name} with key: #{@service.api_key}"
119
+ log_message.include?('[REDACTED]')
120
+ #=> true
121
+
122
+ ## String interpolation with transient field hides actual value
123
+ log_message = "Connecting to #{@service.name} with key: #{@service.api_key}"
124
+ log_message.include?('sk-1234567890abcdef')
125
+ #=> false
126
+
127
+ ## Hash containing transient field shows redacted in string output
128
+ config_hash = {
129
+ service: @service.name,
130
+ key: @service.api_key,
131
+ url: @service.endpoint_url
132
+ }
133
+ config_hash.to_s.include?('[REDACTED]')
134
+ #=> true
135
+
136
+ ## Hash containing transient field hides actual value in string output
137
+ config_hash = {
138
+ service: @service.name,
139
+ key: @service.api_key,
140
+ url: @service.endpoint_url
141
+ }
142
+ config_hash.to_s.include?('sk-1234567890abcdef')
143
+ #=> false
144
+
145
+ ## Exception messages with transient fields are safe
146
+ begin
147
+ raise StandardError, "Failed to authenticate with key: #{@service.api_key}"
148
+ rescue StandardError => e
149
+ e.message.include?('[REDACTED]')
150
+ end
151
+ #=> true
152
+
153
+ ## Multiple transient field assignment creates RedactedString instances
154
+ new_service = SecretService.new
155
+ new_service.name = 'Another Service'
156
+ new_service.api_key = 'new-api-key-123'
157
+ new_service.password = 'new-password-456'
158
+ new_service.token = 'new-token-789'
159
+ [new_service.api_key, new_service.password, new_service.token].all? { |f| f.is_a?(RedactedString) }
160
+ #=> true
161
+
162
+ ## Transient field can be set to nil value
163
+ new_service = SecretService.new
164
+ new_service.api_key = nil
165
+ new_service.api_key
166
+ #=> nil
167
+
168
+ ## Persistent field definitions are correctly identified
169
+ SecretService.field_types.values.select(&:persistent?).map(&:name).sort
170
+ #=> [:endpoint_url, :name]
171
+
172
+ ## Transient field definitions are correctly identified
173
+ transient_fields = SecretService.field_types.values.reject(&:persistent?).map(&:name).sort
174
+ transient_fields
175
+ #=> [:api_key, :password, :secret_token]
176
+
177
+ # Clean up test objects
178
+ @service = nil
179
+
180
+ # Force garbage collection to trigger any finalizers
181
+ GC.start