familia 2.0.0.pre10 → 2.0.0.pre12
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +75 -12
- data/CLAUDE.md +4 -54
- data/Gemfile.lock +1 -1
- data/changelog.d/README.md +45 -34
- data/docs/archive/FAMILIA_RELATIONSHIPS.md +1 -1
- data/docs/archive/FAMILIA_UPDATE.md +1 -1
- data/docs/archive/README.md +15 -19
- data/docs/guides/Home.md +1 -1
- data/docs/guides/Implementation-Guide.md +1 -1
- data/docs/guides/relationships-methods.md +1 -1
- data/docs/migrating/.gitignore +2 -0
- data/docs/migrating/v2.0.0-pre.md +84 -0
- data/docs/migrating/v2.0.0-pre11.md +255 -0
- data/docs/migrating/v2.0.0-pre12.md +306 -0
- data/docs/migrating/v2.0.0-pre5.md +110 -0
- data/docs/migrating/v2.0.0-pre6.md +154 -0
- data/docs/migrating/v2.0.0-pre7.md +222 -0
- data/docs/overview.md +6 -7
- data/{examples/redis_command_validation_example.rb → docs/reference/auditing_database_commands.rb} +29 -32
- data/examples/{bit_encoding_integration.rb → permissions.rb} +30 -27
- data/examples/{relationships_basic.rb → relationships.rb} +2 -3
- data/examples/safe_dump.rb +281 -0
- data/familia.gemspec +4 -4
- data/lib/familia/base.rb +52 -0
- data/lib/familia/{encryption_request_cache.rb → encryption/request_cache.rb} +1 -1
- data/lib/familia/errors.rb +2 -0
- data/lib/familia/features/autoloader.rb +57 -0
- data/lib/familia/features/external_identifier.rb +310 -0
- data/lib/familia/features/object_identifier.rb +307 -0
- data/lib/familia/features/safe_dump.rb +66 -72
- data/lib/familia/features.rb +93 -5
- data/lib/familia/horreum/subclass/definition.rb +47 -3
- data/lib/familia/secure_identifier.rb +51 -75
- data/lib/familia/verifiable_identifier.rb +162 -0
- data/lib/familia/version.rb +1 -1
- data/lib/familia.rb +1 -0
- data/setup.cfg +1 -8
- data/try/core/secure_identifier_try.rb +47 -18
- data/try/core/verifiable_identifier_try.rb +171 -0
- data/try/features/{external_identifiers/external_identifiers_try.rb → external_identifier/external_identifier_try.rb} +25 -28
- data/try/features/feature_improvements_try.rb +126 -0
- data/try/features/{object_identifiers/object_identifiers_integration_try.rb → object_identifier/object_identifier_integration_try.rb} +28 -30
- data/try/features/{object_identifiers/object_identifiers_try.rb → object_identifier/object_identifier_try.rb} +13 -13
- data/try/features/real_feature_integration_try.rb +7 -6
- data/try/features/safe_dump/safe_dump_try.rb +8 -9
- data/try/helpers/test_helpers.rb +17 -17
- metadata +30 -22
- data/changelog.d/fragments/.keep +0 -0
- data/changelog.d/template.md.j2 +0 -29
- data/lib/familia/features/external_identifiers/external_identifier_field_type.rb +0 -120
- data/lib/familia/features/external_identifiers.rb +0 -111
- data/lib/familia/features/object_identifiers/object_identifier_field_type.rb +0 -91
- data/lib/familia/features/object_identifiers.rb +0 -194
@@ -0,0 +1,162 @@
|
|
1
|
+
# lib/familia/verifiable_identifier.rb
|
2
|
+
|
3
|
+
require 'openssl'
|
4
|
+
require_relative 'secure_identifier'
|
5
|
+
|
6
|
+
module Familia
|
7
|
+
# Creates and verifies identifiers that contain an embedded HMAC signature,
|
8
|
+
# allowing for stateless verification of an identifier's authenticity.
|
9
|
+
module VerifiableIdentifier
|
10
|
+
# By extending SecureIdentifier, we gain access to its instance methods
|
11
|
+
# (like generate_hex_id) as class methods on this module.
|
12
|
+
extend Familia::SecureIdentifier
|
13
|
+
|
14
|
+
# The secret key for HMAC generation, loaded from an environment variable.
|
15
|
+
#
|
16
|
+
# This key is the root of trust for verifying identifier authenticity. It must be
|
17
|
+
# a long, random, and cryptographically strong string.
|
18
|
+
#
|
19
|
+
# @!attribute [r] SECRET_KEY
|
20
|
+
# @return [String] The secret key.
|
21
|
+
#
|
22
|
+
# @note Security Considerations:
|
23
|
+
# - **Secrecy:** This key MUST be kept secret and secure, just like a database
|
24
|
+
# password or API key. Do not commit it to version control.
|
25
|
+
# - **Consistency:** All running instances of your application must use the
|
26
|
+
# exact same key, otherwise verification will fail across different servers.
|
27
|
+
# - **Rotation:** If this key is ever compromised, it must be rotated. Be
|
28
|
+
# aware that rotating the key will invalidate all previously generated
|
29
|
+
# verifiable identifiers.
|
30
|
+
#
|
31
|
+
# @example Generating and Setting the Key
|
32
|
+
# 1. Generate a new secure key in your terminal:
|
33
|
+
# $ openssl rand -hex 32
|
34
|
+
# > cafef00dcafef00dcafef00dcafef00dcafef00dcafef00d
|
35
|
+
#
|
36
|
+
# 2. Set it as an environment variable in your production environment:
|
37
|
+
# export VERIFIABLE_ID_HMAC_SECRET="cafef00dcafef00dcafef00dcafef00dcafef00dcafef00d"
|
38
|
+
#
|
39
|
+
SECRET_KEY = ENV.fetch('VERIFIABLE_ID_HMAC_SECRET', 'cafef00dcafef00dcafef00dcafef00dcafef00dcafef00d')
|
40
|
+
|
41
|
+
# The length of the random part of the ID in hex characters (256 bits).
|
42
|
+
RANDOM_HEX_LENGTH = 64
|
43
|
+
# The length of the HMAC tag in hex characters (64 bits).
|
44
|
+
# 64 bits is strong enough to prevent forgery (1 in 18 quintillion chance).
|
45
|
+
TAG_HEX_LENGTH = 16
|
46
|
+
|
47
|
+
# Generates a verifiable, base-36 encoded identifier.
|
48
|
+
#
|
49
|
+
# The final identifier contains a 256-bit random component and a 64-bit
|
50
|
+
# authentication tag.
|
51
|
+
#
|
52
|
+
# @param base [Integer] The base for encoding the output string.
|
53
|
+
# @return [String] A verifiable, signed identifier.
|
54
|
+
def self.generate_verifiable_id(base_or_scope = nil, scope: nil, base: 36)
|
55
|
+
# Handle backward compatibility with positional base argument
|
56
|
+
if base_or_scope.is_a?(Integer)
|
57
|
+
base = base_or_scope
|
58
|
+
# scope remains as passed in keyword argument
|
59
|
+
elsif base_or_scope.is_a?(String) || base_or_scope.nil?
|
60
|
+
scope = base_or_scope if scope.nil?
|
61
|
+
# base remains as passed in keyword argument or default
|
62
|
+
end
|
63
|
+
|
64
|
+
# Re-use generate_hex_id from the SecureIdentifier module.
|
65
|
+
random_hex = generate_hex_id
|
66
|
+
tag_hex = generate_tag(random_hex, scope: scope)
|
67
|
+
|
68
|
+
combined_hex = random_hex + tag_hex
|
69
|
+
|
70
|
+
# Re-use the min_length_for_bits helper from the SecureIdentifier module.
|
71
|
+
total_bits = (RANDOM_HEX_LENGTH + TAG_HEX_LENGTH) * 4
|
72
|
+
target_length = Familia::SecureIdentifier.min_length_for_bits(total_bits, base)
|
73
|
+
|
74
|
+
combined_hex.to_i(16).to_s(base).rjust(target_length, '0')
|
75
|
+
end
|
76
|
+
|
77
|
+
# Verifies the authenticity of a given identifier using a timing-safe comparison.
|
78
|
+
#
|
79
|
+
# @param verifiable_id [String] The identifier string to check.
|
80
|
+
# @param base [Integer] The base of the input string.
|
81
|
+
# @return [Boolean] True if the identifier is authentic, false otherwise.
|
82
|
+
def self.verified_identifier?(verifiable_id, base_or_scope = nil, scope: nil, base: 36)
|
83
|
+
# Handle backward compatibility with positional base argument
|
84
|
+
if base_or_scope.is_a?(Integer)
|
85
|
+
base = base_or_scope
|
86
|
+
# scope remains as passed in keyword argument
|
87
|
+
elsif base_or_scope.is_a?(String) || base_or_scope.nil?
|
88
|
+
scope = base_or_scope if scope.nil?
|
89
|
+
# base remains as passed in keyword argument or default
|
90
|
+
end
|
91
|
+
|
92
|
+
return false unless plausible_identifier?(verifiable_id, base)
|
93
|
+
|
94
|
+
expected_hex_length = (RANDOM_HEX_LENGTH + TAG_HEX_LENGTH)
|
95
|
+
combined_hex = verifiable_id.to_i(base).to_s(16).rjust(expected_hex_length, '0')
|
96
|
+
|
97
|
+
random_part = combined_hex[0...RANDOM_HEX_LENGTH]
|
98
|
+
tag_part = combined_hex[RANDOM_HEX_LENGTH..]
|
99
|
+
|
100
|
+
expected_tag = generate_tag(random_part, scope: scope)
|
101
|
+
OpenSSL.secure_compare(expected_tag, tag_part)
|
102
|
+
end
|
103
|
+
|
104
|
+
# Checks if an identifier is plausible (correct format and length) without
|
105
|
+
# performing cryptographic verification.
|
106
|
+
#
|
107
|
+
# This can be used as a fast pre-flight check to reject obviously
|
108
|
+
# malformed identifiers.
|
109
|
+
#
|
110
|
+
# @param identifier_str [String] The identifier string to check.
|
111
|
+
# @param base [Integer] The base of the input string.
|
112
|
+
# @return [Boolean] True if the identifier has a valid format, false otherwise.
|
113
|
+
def self.plausible_identifier?(identifier_str, base = 36)
|
114
|
+
return false unless identifier_str.is_a?(::String)
|
115
|
+
|
116
|
+
# 1. Check length
|
117
|
+
total_bits = (RANDOM_HEX_LENGTH + TAG_HEX_LENGTH) * 4
|
118
|
+
expected_length = Familia::SecureIdentifier.min_length_for_bits(total_bits, base)
|
119
|
+
return false unless identifier_str.length == expected_length
|
120
|
+
|
121
|
+
# 2. Check character set
|
122
|
+
# The most efficient way to check for invalid characters is to attempt
|
123
|
+
# conversion and rescue the error.
|
124
|
+
Integer(identifier_str, base)
|
125
|
+
true
|
126
|
+
rescue ArgumentError
|
127
|
+
false
|
128
|
+
end
|
129
|
+
|
130
|
+
class << self
|
131
|
+
private
|
132
|
+
|
133
|
+
# Generates the HMAC tag for a given message.
|
134
|
+
# @private
|
135
|
+
def generate_tag(message, scope: nil)
|
136
|
+
# Include scope in HMAC calculation for domain separation if provided.
|
137
|
+
# The scope parameter enables creating cryptographically isolated identifier
|
138
|
+
# namespaces (e.g., per-domain, per-tenant, per-application) while maintaining
|
139
|
+
# all security properties of the base system.
|
140
|
+
#
|
141
|
+
# Security considerations for scope values:
|
142
|
+
# - Any string content is cryptographically safe (HMAC handles arbitrary input)
|
143
|
+
# - No length restrictions (short scopes like "a" or long scopes work equally well)
|
144
|
+
# - UTF-8 encoding is handled consistently
|
145
|
+
# - Empty string "" vs nil produce different identifiers (intentional for security)
|
146
|
+
# - Different scope values guarantee different identifier spaces
|
147
|
+
#
|
148
|
+
# Examples of scope usage:
|
149
|
+
# - Customer isolation: scope: "tenant:#{tenant_id}"
|
150
|
+
# - Environment separation: scope: "production" vs scope: "staging"
|
151
|
+
# - Domain scoping: scope: "example.com"
|
152
|
+
# - Application scoping: scope: "#{app_name}:#{version}"
|
153
|
+
hmac_input = scope ? "#{message}:scope:#{scope}" : message
|
154
|
+
|
155
|
+
digest = OpenSSL::Digest.new('sha256')
|
156
|
+
hmac = OpenSSL::HMAC.hexdigest(digest, SECRET_KEY, hmac_input)
|
157
|
+
# Truncate to the desired length for the tag.
|
158
|
+
hmac[0...TAG_HEX_LENGTH]
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
data/lib/familia/version.rb
CHANGED
data/lib/familia.rb
CHANGED
data/setup.cfg
CHANGED
@@ -1,12 +1,5 @@
|
|
1
1
|
[scriv]
|
2
|
-
format = md
|
3
2
|
categories = Added, Changed, Deprecated, Removed, Fixed, Security, Documentation, AI Assistance
|
4
|
-
|
5
|
-
entry_title_template = [{{ version }}] - {{ date }}
|
6
|
-
fragment_directory = changelog.d/fragments
|
7
|
-
template_file = changelog.d/template.md.j2
|
8
|
-
output_file = CHANGELOG.md
|
3
|
+
version = command: ruby -r ./lib/familia/version.rb -e "puts Familia::VERSION"
|
9
4
|
main_branches = main, develop
|
10
5
|
md_header_level = 2
|
11
|
-
end_marker = scriv-end-here
|
12
|
-
start_marker = scriv-insert-here
|
@@ -56,22 +56,6 @@ hex_trace_id = Familia.generate_hex_trace_id
|
|
56
56
|
[hex_trace_id.class, hex_trace_id.length == 16, hex_trace_id.match?(/^[a-f0-9]+$/)]
|
57
57
|
#=> [String, true, true]
|
58
58
|
|
59
|
-
## Familia.shorten_to_external_id
|
60
|
-
Familia.respond_to?(:shorten_to_external_id)
|
61
|
-
#=> true
|
62
|
-
|
63
|
-
## Can shorten hex ID to external ID (128 bits)
|
64
|
-
hex_id = Familia.generate_hex_id
|
65
|
-
external_id = Familia.shorten_to_external_id(hex_id)
|
66
|
-
[external_id.class, external_id.length < hex_id.length]
|
67
|
-
#=> [String, true]
|
68
|
-
|
69
|
-
## Can shorten hex ID to external ID with custom base (hex)
|
70
|
-
hex_id = Familia.generate_hex_id
|
71
|
-
hex_external_id = Familia.shorten_to_external_id(hex_id, base: 16)
|
72
|
-
[hex_external_id.class, hex_external_id.length == 32]
|
73
|
-
#=> [String, true]
|
74
|
-
|
75
59
|
## Familia.shorten_to_trace_id
|
76
60
|
Familia.respond_to?(:shorten_to_trace_id)
|
77
61
|
#=> true
|
@@ -88,10 +72,55 @@ hex_trace_id = Familia.shorten_to_trace_id(hex_id, base: 16)
|
|
88
72
|
[hex_trace_id.class, hex_trace_id.length == 16]
|
89
73
|
#=> [String, true]
|
90
74
|
|
75
|
+
## Familia.truncate_hex
|
76
|
+
Familia.respond_to?(:truncate_hex)
|
77
|
+
#=> true
|
78
|
+
|
79
|
+
## Can truncate hex ID to 128 bits by default
|
80
|
+
hex_id = Familia.generate_hex_id
|
81
|
+
truncated_id = Familia.truncate_hex(hex_id)
|
82
|
+
[truncated_id.class, truncated_id.length < hex_id.length]
|
83
|
+
#=> [String, true]
|
84
|
+
|
85
|
+
## Can truncate hex ID to a custom bit length (64 bits)
|
86
|
+
hex_id = Familia.generate_hex_id
|
87
|
+
truncated_64 = Familia.truncate_hex(hex_id, bits: 64)
|
88
|
+
[truncated_64.class, truncated_64.length < hex_id.length]
|
89
|
+
#=> [String, true]
|
90
|
+
|
91
|
+
## Can truncate with a custom base (hex)
|
92
|
+
hex_id = Familia.generate_hex_id
|
93
|
+
hex_truncated = Familia.truncate_hex(hex_id, bits: 128, base: 16)
|
94
|
+
[hex_truncated.class, hex_truncated.length == 32]
|
95
|
+
#=> [String, true]
|
96
|
+
|
97
|
+
## Truncated IDs are deterministic
|
98
|
+
hex_id = Familia.generate_hex_id
|
99
|
+
id1 = Familia.truncate_hex(hex_id)
|
100
|
+
id2 = Familia.truncate_hex(hex_id)
|
101
|
+
id1 == id2
|
102
|
+
#=> true
|
103
|
+
|
104
|
+
## Raises error for invalid hex
|
105
|
+
begin
|
106
|
+
Familia.truncate_hex("not-a-hex-string")
|
107
|
+
rescue ArgumentError => e
|
108
|
+
e.message
|
109
|
+
end
|
110
|
+
#=> "Invalid hexadecimal string: not-a-hex-string"
|
111
|
+
|
112
|
+
## Raises error if input bits are less than output bits
|
113
|
+
begin
|
114
|
+
Familia.truncate_hex("abc", bits: 64)
|
115
|
+
rescue ArgumentError => e
|
116
|
+
e.message
|
117
|
+
end
|
118
|
+
#=> "Input bits (12) cannot be less than desired output bits (64)."
|
119
|
+
|
91
120
|
## Shortened IDs are deterministic
|
92
121
|
hex_id = Familia.generate_hex_id
|
93
|
-
id1 = Familia.
|
94
|
-
id2 = Familia.
|
122
|
+
id1 = Familia.shorten_to_trace_id(hex_id)
|
123
|
+
id2 = Familia.shorten_to_trace_id(hex_id)
|
95
124
|
id1 == id2
|
96
125
|
#=> true
|
97
126
|
|
@@ -0,0 +1,171 @@
|
|
1
|
+
# try/core/verifiable_identifier_try.rb
|
2
|
+
|
3
|
+
require_relative '../helpers/test_helpers'
|
4
|
+
require 'familia/verifiable_identifier'
|
5
|
+
|
6
|
+
## Module is available
|
7
|
+
defined?(Familia::VerifiableIdentifier)
|
8
|
+
#=> "constant"
|
9
|
+
|
10
|
+
## Uses the development secret key by default when ENV is not set
|
11
|
+
Familia::VerifiableIdentifier::SECRET_KEY
|
12
|
+
#=> "cafef00dcafef00dcafef00dcafef00dcafef00dcafef00d"
|
13
|
+
|
14
|
+
# --- Verifiable ID Generation and Verification ---
|
15
|
+
|
16
|
+
## Generates a non-empty string ID
|
17
|
+
id = Familia::VerifiableIdentifier.generate_verifiable_id
|
18
|
+
id.is_a?(String) && !id.empty?
|
19
|
+
#=> true
|
20
|
+
|
21
|
+
## Generated ID is URL-safe (base36)
|
22
|
+
id = Familia::VerifiableIdentifier.generate_verifiable_id
|
23
|
+
id.match?(/[a-z0-9]+/ix)
|
24
|
+
#=> true
|
25
|
+
|
26
|
+
## Generates unique identifiers on subsequent calls
|
27
|
+
Familia::VerifiableIdentifier.generate_verifiable_id
|
28
|
+
#=/> Familia::VerifiableIdentifier.generate_verifiable_id
|
29
|
+
|
30
|
+
## A genuinely generated ID successfully verifies
|
31
|
+
id = Familia::VerifiableIdentifier.generate_verifiable_id
|
32
|
+
Familia::VerifiableIdentifier.verified_identifier?(id)
|
33
|
+
#=> true
|
34
|
+
|
35
|
+
## Fails verification for a completely random garbage string
|
36
|
+
Familia::VerifiableIdentifier.verified_identifier?('this-is-not-a-valid-id-at-all')
|
37
|
+
#=> false
|
38
|
+
|
39
|
+
## Fails verification for a string with invalid characters for the base
|
40
|
+
# A plus sign is not a valid base-36 character.
|
41
|
+
Familia::VerifiableIdentifier.verified_identifier?('this+is+invalid', 36)
|
42
|
+
#=> false
|
43
|
+
|
44
|
+
## Fails verification if the random part of the ID is tampered with
|
45
|
+
id = Familia::VerifiableIdentifier.generate_verifiable_id
|
46
|
+
tampered_id = id.dup
|
47
|
+
tampered_id[0] = (tampered_id[0] == 'a' ? 'b' : 'a') # Flip the first character
|
48
|
+
Familia::VerifiableIdentifier.verified_identifier?(tampered_id)
|
49
|
+
#=> false
|
50
|
+
|
51
|
+
## Fails verification if the tag part of the ID is tampered with
|
52
|
+
id = Familia::VerifiableIdentifier.generate_verifiable_id
|
53
|
+
tampered_id = id.dup
|
54
|
+
idx = tampered_id.length - 1
|
55
|
+
tampered_id[idx] = (tampered_id[idx] == 'a' ? 'b' : 'a') # Flip the last character
|
56
|
+
Familia::VerifiableIdentifier.verified_identifier?(tampered_id)
|
57
|
+
#=> false
|
58
|
+
|
59
|
+
## Works correctly with a different base (hexadecimal)
|
60
|
+
id_hex = Familia::VerifiableIdentifier.generate_verifiable_id(16)
|
61
|
+
Familia::VerifiableIdentifier.verified_identifier?(id_hex, 16)
|
62
|
+
#=> true
|
63
|
+
|
64
|
+
## Base 16 ID has the correct hex length (64 random + 16 tag = 80 chars)
|
65
|
+
id_hex = Familia::VerifiableIdentifier.generate_verifiable_id(16)
|
66
|
+
id_hex.length
|
67
|
+
#=> 80
|
68
|
+
|
69
|
+
# --- Plausibility Checks ---
|
70
|
+
|
71
|
+
## A genuinely generated ID is plausible
|
72
|
+
id = Familia::VerifiableIdentifier.generate_verifiable_id
|
73
|
+
Familia::VerifiableIdentifier.plausible_identifier?(id)
|
74
|
+
#=> true
|
75
|
+
|
76
|
+
## A well-formed but fake ID is still plausible
|
77
|
+
# A string of the correct length (62 for base 36) and charset is plausible
|
78
|
+
total_bits = (Familia::VerifiableIdentifier::RANDOM_HEX_LENGTH + Familia::VerifiableIdentifier::TAG_HEX_LENGTH) * 4
|
79
|
+
fake_id = 'a' * Familia::SecureIdentifier.min_length_for_bits(total_bits, 36)
|
80
|
+
Familia::VerifiableIdentifier.plausible_identifier?(fake_id)
|
81
|
+
#=> true
|
82
|
+
|
83
|
+
## Fails plausibility check if too short
|
84
|
+
short_id = 'a' * 60
|
85
|
+
Familia::VerifiableIdentifier.plausible_identifier?(short_id)
|
86
|
+
#=> false
|
87
|
+
|
88
|
+
## Fails plausibility check if too long
|
89
|
+
long_id = 'a' * 66
|
90
|
+
Familia::VerifiableIdentifier.plausible_identifier?(long_id)
|
91
|
+
#=> false
|
92
|
+
|
93
|
+
## Fails plausibility check for invalid characters
|
94
|
+
invalid_char_id = 'a' * 61 + '+'
|
95
|
+
Familia::VerifiableIdentifier.plausible_identifier?(invalid_char_id)
|
96
|
+
#=> false
|
97
|
+
|
98
|
+
## Fails plausibility check for nil input
|
99
|
+
Familia::VerifiableIdentifier.plausible_identifier?(nil)
|
100
|
+
#=> false
|
101
|
+
|
102
|
+
# --- Scoped Identifier Generation and Verification ---
|
103
|
+
|
104
|
+
## Scoped identifier generation produces different results than unscoped
|
105
|
+
scoped_id = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "example.com")
|
106
|
+
unscoped_id = Familia::VerifiableIdentifier.generate_verifiable_id
|
107
|
+
scoped_id != unscoped_id
|
108
|
+
#=> true
|
109
|
+
|
110
|
+
## Scoped identifiers verify successfully with correct scope
|
111
|
+
scoped_id = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "example.com")
|
112
|
+
Familia::VerifiableIdentifier.verified_identifier?(scoped_id, scope: "example.com")
|
113
|
+
#=> true
|
114
|
+
|
115
|
+
## Scoped identifiers fail verification with wrong scope
|
116
|
+
scoped_id = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "example.com")
|
117
|
+
Familia::VerifiableIdentifier.verified_identifier?(scoped_id, scope: "different.com")
|
118
|
+
#=> false
|
119
|
+
|
120
|
+
## Scoped identifiers fail verification without scope parameter
|
121
|
+
scoped_id = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "example.com")
|
122
|
+
Familia::VerifiableIdentifier.verified_identifier?(scoped_id)
|
123
|
+
#=> false
|
124
|
+
|
125
|
+
## Unscoped identifiers fail verification with scope parameter
|
126
|
+
unscoped_id = Familia::VerifiableIdentifier.generate_verifiable_id
|
127
|
+
Familia::VerifiableIdentifier.verified_identifier?(unscoped_id, scope: "example.com")
|
128
|
+
#=> false
|
129
|
+
|
130
|
+
## Empty string scope produces different identifier than nil scope
|
131
|
+
id_nil = Familia::VerifiableIdentifier.generate_verifiable_id(scope: nil)
|
132
|
+
id_empty = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "")
|
133
|
+
id_nil != id_empty
|
134
|
+
#=> true
|
135
|
+
|
136
|
+
## Empty string scope verifies correctly
|
137
|
+
id_empty = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "")
|
138
|
+
Familia::VerifiableIdentifier.verified_identifier?(id_empty, scope: "")
|
139
|
+
#=> true
|
140
|
+
|
141
|
+
## Short scope values work correctly
|
142
|
+
id_short = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "a")
|
143
|
+
Familia::VerifiableIdentifier.verified_identifier?(id_short, scope: "a")
|
144
|
+
#=> true
|
145
|
+
|
146
|
+
## Long scope values work correctly
|
147
|
+
long_scope = "x" * 1000
|
148
|
+
id_long = Familia::VerifiableIdentifier.generate_verifiable_id(scope: long_scope)
|
149
|
+
Familia::VerifiableIdentifier.verified_identifier?(id_long, scope: long_scope)
|
150
|
+
#=> true
|
151
|
+
|
152
|
+
## Unicode scope values work correctly
|
153
|
+
unicode_scope = "测试🔒🔑"
|
154
|
+
id_unicode = Familia::VerifiableIdentifier.generate_verifiable_id(scope: unicode_scope)
|
155
|
+
Familia::VerifiableIdentifier.verified_identifier?(id_unicode, scope: unicode_scope)
|
156
|
+
#=> true
|
157
|
+
|
158
|
+
## Scoped identifiers work with different bases
|
159
|
+
id_hex = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "test", base: 16)
|
160
|
+
Familia::VerifiableIdentifier.verified_identifier?(id_hex, scope: "test", base: 16)
|
161
|
+
#=> true
|
162
|
+
|
163
|
+
## Backward compatibility: existing method signatures still work
|
164
|
+
id = Familia::VerifiableIdentifier.generate_verifiable_id(36)
|
165
|
+
Familia::VerifiableIdentifier.verified_identifier?(id, 36)
|
166
|
+
#=> true
|
167
|
+
|
168
|
+
## Mixed parameter styles work correctly
|
169
|
+
id = Familia::VerifiableIdentifier.generate_verifiable_id(scope: "test", base: 16)
|
170
|
+
Familia::VerifiableIdentifier.verified_identifier?(id, scope: "test", base: 16)
|
171
|
+
#=> true
|
@@ -1,15 +1,15 @@
|
|
1
|
-
# try/features/
|
1
|
+
# try/features/external_identifier/external_identifier_try.rb
|
2
2
|
|
3
3
|
require_relative '../../helpers/test_helpers'
|
4
4
|
|
5
5
|
Familia.debug = false
|
6
6
|
|
7
|
-
# Test
|
7
|
+
# Test ExternalIdentifier feature functionality
|
8
8
|
|
9
|
-
# Basic class using
|
9
|
+
# Basic class using external_identifier
|
10
10
|
class ExternalIdTest < Familia::Horreum
|
11
|
-
feature :
|
12
|
-
feature :
|
11
|
+
feature :object_identifier
|
12
|
+
feature :external_identifier
|
13
13
|
identifier_field :id
|
14
14
|
field :id
|
15
15
|
field :name
|
@@ -17,8 +17,8 @@ end
|
|
17
17
|
|
18
18
|
# Class with custom prefix
|
19
19
|
class CustomPrefixTest < Familia::Horreum
|
20
|
-
feature :
|
21
|
-
feature :
|
20
|
+
feature :object_identifier
|
21
|
+
feature :external_identifier, prefix: 'cust'
|
22
22
|
identifier_field :id
|
23
23
|
field :id
|
24
24
|
field :name
|
@@ -26,8 +26,8 @@ end
|
|
26
26
|
|
27
27
|
# Class testing data integrity preservation
|
28
28
|
class ExternalDataIntegrityTest < Familia::Horreum
|
29
|
-
feature :
|
30
|
-
feature :
|
29
|
+
feature :object_identifier
|
30
|
+
feature :external_identifier
|
31
31
|
identifier_field :id
|
32
32
|
field :id
|
33
33
|
field :name
|
@@ -40,12 +40,12 @@ end
|
|
40
40
|
@lazy_obj = ExternalIdTest.new
|
41
41
|
@complex_obj = ExternalIdTest.new(id: 'complex_ext', name: 'Complex External')
|
42
42
|
|
43
|
-
## Feature depends on
|
44
|
-
ExternalIdTest.features_enabled.include?(:
|
43
|
+
## Feature depends on object_identifier
|
44
|
+
ExternalIdTest.features_enabled.include?(:object_identifier)
|
45
45
|
#==> true
|
46
46
|
|
47
|
-
## External
|
48
|
-
ExternalIdTest.features_enabled.include?(:
|
47
|
+
## External identifier feature is included
|
48
|
+
ExternalIdTest.features_enabled.include?(:external_identifier)
|
49
49
|
#==> true
|
50
50
|
|
51
51
|
## Class has extid field defined
|
@@ -57,16 +57,14 @@ obj = ExternalIdTest.new
|
|
57
57
|
obj.respond_to?(:extid)
|
58
58
|
#==> true
|
59
59
|
|
60
|
-
## External ID is generated from objid deterministically
|
60
|
+
## External ID is generated from objid deterministically for same object
|
61
61
|
obj = ExternalIdTest.new
|
62
62
|
obj.id = 'test_obj'
|
63
63
|
obj.name = 'Test Object'
|
64
64
|
objid = obj.objid
|
65
65
|
extid = obj.extid
|
66
|
-
#
|
67
|
-
|
68
|
-
obj2.instance_variable_set(:@objid, objid)
|
69
|
-
obj2.extid == extid
|
66
|
+
# Multiple calls to extid on same object should return same value
|
67
|
+
obj.extid == extid
|
70
68
|
#==> true
|
71
69
|
|
72
70
|
## External ID uses default 'ext' prefix
|
@@ -123,13 +121,12 @@ result = ExternalIdTest.find_by_extid('nonexistent')
|
|
123
121
|
result.is_a?(ExternalIdTest) || result.nil?
|
124
122
|
#==> true
|
125
123
|
|
126
|
-
## External ID is deterministic
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
obj1.extid == obj2.extid
|
124
|
+
## External ID is deterministic within same object
|
125
|
+
obj = ExternalIdTest.new
|
126
|
+
obj.id = 'deterministic_test'
|
127
|
+
first_extid = obj.extid
|
128
|
+
second_extid = obj.extid
|
129
|
+
first_extid == second_extid
|
133
130
|
#==> true
|
134
131
|
|
135
132
|
## External ID is different from objid
|
@@ -155,14 +152,14 @@ obj1.extid != obj2.extid
|
|
155
152
|
|
156
153
|
## extid field type is ExternalIdentifierFieldType
|
157
154
|
ExternalIdTest.field_types[:extid]
|
158
|
-
#=:> Familia::Features::
|
155
|
+
#=:> Familia::Features::ExternalIdentifier::ExternalIdentifierFieldType
|
159
156
|
|
160
157
|
## Feature options contain correct prefix
|
161
|
-
ExternalIdTest.feature_options(:
|
158
|
+
ExternalIdTest.feature_options(:external_identifier)[:prefix]
|
162
159
|
#=> "ext"
|
163
160
|
|
164
161
|
## Custom prefix feature options
|
165
|
-
CustomPrefixTest.feature_options(:
|
162
|
+
CustomPrefixTest.feature_options(:external_identifier)[:prefix]
|
166
163
|
#=> "cust"
|
167
164
|
|
168
165
|
## External ID is shorter than UUID objid
|
@@ -0,0 +1,126 @@
|
|
1
|
+
# try/features/feature_improvements_try.rb
|
2
|
+
|
3
|
+
require_relative '../helpers/test_helpers'
|
4
|
+
|
5
|
+
# Test hierarchical feature registration
|
6
|
+
class ::TestClass
|
7
|
+
include Familia::Base
|
8
|
+
end
|
9
|
+
|
10
|
+
class TestSubClass < TestClass
|
11
|
+
end
|
12
|
+
|
13
|
+
# Create a simple test feature
|
14
|
+
module ::TestFeature
|
15
|
+
def test_method
|
16
|
+
"test feature working"
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.included(base)
|
20
|
+
base.extend(ClassMethods)
|
21
|
+
end
|
22
|
+
|
23
|
+
module ClassMethods
|
24
|
+
def class_test_method
|
25
|
+
"class method from feature"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Test SafeDump DSL improvements
|
31
|
+
class ::TestModelWithSafeDump
|
32
|
+
include Familia::Base
|
33
|
+
include Familia::Features::SafeDump
|
34
|
+
|
35
|
+
attr_accessor :id, :name, :email, :active
|
36
|
+
|
37
|
+
def initialize(attrs = {})
|
38
|
+
attrs.each { |k, v| send("#{k}=", v) }
|
39
|
+
end
|
40
|
+
|
41
|
+
def active?
|
42
|
+
@active == true
|
43
|
+
end
|
44
|
+
|
45
|
+
# Define safe dump fields using new DSL
|
46
|
+
safe_dump_field :id
|
47
|
+
safe_dump_field :name
|
48
|
+
safe_dump_field :status, ->(obj) { obj.active? ? 'active' : 'inactive' }
|
49
|
+
safe_dump_fields :email, { computed_field: ->(obj) { "#{obj.name}-computed" } }
|
50
|
+
end
|
51
|
+
|
52
|
+
# Test field definitions in feature modules
|
53
|
+
module ::TestFieldFeature
|
54
|
+
def self.included(base)
|
55
|
+
base.extend ClassMethods
|
56
|
+
end
|
57
|
+
|
58
|
+
module ClassMethods
|
59
|
+
# This should work - field calls in ClassMethods should execute in the extending class context
|
60
|
+
def define_test_fields
|
61
|
+
# Assuming we have a field method available (this would come from Horreum)
|
62
|
+
# For this test, we'll just verify the method gets called in the right context
|
63
|
+
self.name + "_with_fields"
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
class ::TestFieldClass
|
69
|
+
include Familia::Base
|
70
|
+
include TestFieldFeature
|
71
|
+
end
|
72
|
+
|
73
|
+
## Test model-specific feature registration
|
74
|
+
# Register feature on TestClass
|
75
|
+
TestClass.add_feature TestFeature, :test_feature
|
76
|
+
#=> TestFeature
|
77
|
+
|
78
|
+
## TestClass should have the feature available
|
79
|
+
TestClass.features_available[:test_feature]
|
80
|
+
#=> TestFeature
|
81
|
+
|
82
|
+
## TestSubClass should inherit the feature from TestClass via ancestry chain
|
83
|
+
TestSubClass.find_feature(:test_feature)
|
84
|
+
#=> TestFeature
|
85
|
+
|
86
|
+
## Familia::Base should also be able to find features in the chain
|
87
|
+
Familia::Base.find_feature(:test_feature, TestSubClass)
|
88
|
+
#=> TestFeature
|
89
|
+
|
90
|
+
## Check that fields were registered correctly
|
91
|
+
TestModelWithSafeDump.safe_dump_field_names.sort
|
92
|
+
#=> [:computed_field, :email, :id, :name, :status]
|
93
|
+
|
94
|
+
## Test the safe_dump functionality
|
95
|
+
@test_model = TestModelWithSafeDump.new
|
96
|
+
@test_model.id = 123
|
97
|
+
@test_model.name = "Test User"
|
98
|
+
@test_model.email = "test@example.com"
|
99
|
+
@test_model.active = true
|
100
|
+
|
101
|
+
@result = @test_model.safe_dump
|
102
|
+
#=:> Hash
|
103
|
+
|
104
|
+
## Test safe_dump returns correct values
|
105
|
+
@result[:id]
|
106
|
+
#=> 123
|
107
|
+
|
108
|
+
## Test safe_dump name field
|
109
|
+
@result[:name]
|
110
|
+
#=> "Test User"
|
111
|
+
|
112
|
+
## Test safe_dump email field
|
113
|
+
@result[:email]
|
114
|
+
#=> "test@example.com"
|
115
|
+
|
116
|
+
## Test safe_dump status field with callable
|
117
|
+
@result[:status]
|
118
|
+
#=> "active"
|
119
|
+
|
120
|
+
## Test safe_dump computed field
|
121
|
+
@result[:computed_field]
|
122
|
+
#=> "Test User-computed"
|
123
|
+
|
124
|
+
## Test that ClassMethods execute in the right context
|
125
|
+
TestFieldClass.define_test_fields
|
126
|
+
#=> "TestFieldClass_with_fields"
|