agentcode 0.9.0
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 +7 -0
- data/README.md +59 -0
- data/lib/agentcode/blueprint/blueprint_parser.rb +198 -0
- data/lib/agentcode/blueprint/blueprint_validator.rb +209 -0
- data/lib/agentcode/blueprint/generators/factory_generator.rb +74 -0
- data/lib/agentcode/blueprint/generators/policy_generator.rb +154 -0
- data/lib/agentcode/blueprint/generators/seeder_generator.rb +160 -0
- data/lib/agentcode/blueprint/generators/test_generator.rb +291 -0
- data/lib/agentcode/blueprint/manifest_manager.rb +81 -0
- data/lib/agentcode/commands/base_command.rb +57 -0
- data/lib/agentcode/commands/blueprint_command.rb +549 -0
- data/lib/agentcode/commands/export_postman_command.rb +328 -0
- data/lib/agentcode/commands/generate_command.rb +563 -0
- data/lib/agentcode/commands/install_command.rb +441 -0
- data/lib/agentcode/commands/invitation_link_command.rb +107 -0
- data/lib/agentcode/concerns/belongs_to_organization.rb +49 -0
- data/lib/agentcode/concerns/has_agentcode.rb +93 -0
- data/lib/agentcode/concerns/has_audit_trail.rb +125 -0
- data/lib/agentcode/concerns/has_auto_scope.rb +91 -0
- data/lib/agentcode/concerns/has_permissions.rb +117 -0
- data/lib/agentcode/concerns/has_uuid.rb +26 -0
- data/lib/agentcode/concerns/has_validation.rb +250 -0
- data/lib/agentcode/concerns/hidable_columns.rb +180 -0
- data/lib/agentcode/configuration.rb +98 -0
- data/lib/agentcode/controllers/auth_controller.rb +242 -0
- data/lib/agentcode/controllers/invitations_controller.rb +231 -0
- data/lib/agentcode/controllers/resources_controller.rb +813 -0
- data/lib/agentcode/engine.rb +65 -0
- data/lib/agentcode/mailers/invitation_mailer.rb +22 -0
- data/lib/agentcode/middleware/resolve_organization_from_route.rb +72 -0
- data/lib/agentcode/models/agentcode_model.rb +387 -0
- data/lib/agentcode/models/audit_log.rb +17 -0
- data/lib/agentcode/models/organization_invitation.rb +57 -0
- data/lib/agentcode/policies/invitation_policy.rb +54 -0
- data/lib/agentcode/policies/resource_policy.rb +197 -0
- data/lib/agentcode/query_builder.rb +278 -0
- data/lib/agentcode/railtie.rb +11 -0
- data/lib/agentcode/resource_scope.rb +59 -0
- data/lib/agentcode/routes.rb +124 -0
- data/lib/agentcode/tasks/agentcode.rake +39 -0
- data/lib/agentcode/templates/agentcode.rb +71 -0
- data/lib/agentcode/templates/agentcode_model.rb +104 -0
- data/lib/agentcode/templates/audit_trail/create_audit_logs.rb.erb +26 -0
- data/lib/agentcode/templates/generate/factory.rb.erb +43 -0
- data/lib/agentcode/templates/generate/migration.rb.erb +26 -0
- data/lib/agentcode/templates/generate/model.rb.erb +55 -0
- data/lib/agentcode/templates/generate/policy.rb.erb +52 -0
- data/lib/agentcode/templates/generate/scope.rb.erb +31 -0
- data/lib/agentcode/templates/multi_tenant/factories/organizations.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/factories/roles.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/factories/user_roles.rb.erb +10 -0
- data/lib/agentcode/templates/multi_tenant/factories/users.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/migrations/create_organizations.rb.erb +15 -0
- data/lib/agentcode/templates/multi_tenant/migrations/create_roles.rb.erb +15 -0
- data/lib/agentcode/templates/multi_tenant/migrations/create_user_roles.rb.erb +16 -0
- data/lib/agentcode/templates/multi_tenant/migrations/create_users.rb.erb +15 -0
- data/lib/agentcode/templates/multi_tenant/models/organization.rb.erb +18 -0
- data/lib/agentcode/templates/multi_tenant/models/role.rb.erb +11 -0
- data/lib/agentcode/templates/multi_tenant/models/user.rb.erb +14 -0
- data/lib/agentcode/templates/multi_tenant/models/user_role.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/policies/organization_policy.rb.erb +6 -0
- data/lib/agentcode/templates/multi_tenant/policies/role_policy.rb.erb +6 -0
- data/lib/agentcode/templates/multi_tenant/seeders/organization_seeder.rb.erb +9 -0
- data/lib/agentcode/templates/multi_tenant/seeders/role_seeder.rb.erb +19 -0
- data/lib/agentcode/templates/routes.rb +13 -0
- data/lib/agentcode/version.rb +5 -0
- data/lib/agentcode/views/lumina/invitation_mailer/invite.html.erb +29 -0
- data/lib/agentcode-rails.rb +3 -0
- data/lib/agentcode.rb +26 -0
- metadata +281 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentCode
|
|
4
|
+
module Blueprint
|
|
5
|
+
module Generators
|
|
6
|
+
# Generates fully working Ruby policy classes with role-based attribute permissions.
|
|
7
|
+
# Port of agentcode-adonis-server policy_generator.ts.
|
|
8
|
+
class PolicyGenerator
|
|
9
|
+
# Generate a complete policy class.
|
|
10
|
+
#
|
|
11
|
+
# @param blueprint [Hash] ParsedBlueprint
|
|
12
|
+
# @return [String] Ruby source code
|
|
13
|
+
def generate(blueprint)
|
|
14
|
+
model_name = blueprint[:model]
|
|
15
|
+
slug = blueprint[:slug]
|
|
16
|
+
permissions = blueprint[:permissions]
|
|
17
|
+
|
|
18
|
+
show_method = build_permitted_attributes_method("permitted_attributes_for_show", permissions, :show_fields)
|
|
19
|
+
hidden_method = build_hidden_attributes_method(permissions)
|
|
20
|
+
create_method = build_permitted_attributes_method("permitted_attributes_for_create", permissions, :create_fields)
|
|
21
|
+
update_method = build_permitted_attributes_method("permitted_attributes_for_update", permissions, :update_fields)
|
|
22
|
+
|
|
23
|
+
<<~RUBY
|
|
24
|
+
# frozen_string_literal: true
|
|
25
|
+
|
|
26
|
+
class #{model_name}Policy < AgentCode::ResourcePolicy
|
|
27
|
+
self.resource_slug = '#{slug}'
|
|
28
|
+
|
|
29
|
+
#{show_method}
|
|
30
|
+
#{hidden_method}
|
|
31
|
+
#{create_method}
|
|
32
|
+
#{update_method}
|
|
33
|
+
end
|
|
34
|
+
RUBY
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Group roles with identical field sets into combined conditions.
|
|
38
|
+
#
|
|
39
|
+
# @param permissions [Hash<String, Hash>]
|
|
40
|
+
# @param field_key [Symbol] :show_fields, :create_fields, etc.
|
|
41
|
+
# @return [Array<Hash>] [{ fields: [...], roles: [...] }, ...]
|
|
42
|
+
def group_roles_by_fields(permissions, field_key)
|
|
43
|
+
groups = {}
|
|
44
|
+
|
|
45
|
+
permissions.each do |role, perm|
|
|
46
|
+
fields = perm[field_key] || []
|
|
47
|
+
next if fields.empty?
|
|
48
|
+
|
|
49
|
+
key = fields.sort.join(",")
|
|
50
|
+
groups[key] ||= { fields: fields, roles: [] }
|
|
51
|
+
groups[key][:roles] << role
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
groups.values
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Build a role condition string.
|
|
58
|
+
#
|
|
59
|
+
# @param roles [Array<String>]
|
|
60
|
+
# @return [String] e.g. "has_role?(user, 'admin') || has_role?(user, 'editor')"
|
|
61
|
+
def build_role_condition(roles)
|
|
62
|
+
roles.map { |r| "has_role?(user, '#{r}')" }.join(" || ")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Convert field array to Ruby array literal.
|
|
66
|
+
#
|
|
67
|
+
# @param fields [Array<String>]
|
|
68
|
+
# @return [String]
|
|
69
|
+
def fields_to_ruby_array(fields)
|
|
70
|
+
return "[]" if fields.empty?
|
|
71
|
+
return "['*']" if fields == ["*"]
|
|
72
|
+
|
|
73
|
+
items = fields.map { |f| "'#{f}'" }
|
|
74
|
+
inline = "[#{items.join(', ')}]"
|
|
75
|
+
|
|
76
|
+
if inline.length <= 80
|
|
77
|
+
inline
|
|
78
|
+
else
|
|
79
|
+
lines = items.join(",\n ")
|
|
80
|
+
"[\n #{lines},\n ]"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Build a permitted_attributes method body.
|
|
85
|
+
#
|
|
86
|
+
# @param method_name [String]
|
|
87
|
+
# @param permissions [Hash<String, Hash>]
|
|
88
|
+
# @param field_key [Symbol]
|
|
89
|
+
# @return [String]
|
|
90
|
+
def build_permitted_attributes_method(method_name, permissions, field_key)
|
|
91
|
+
groups = group_roles_by_fields(permissions, field_key)
|
|
92
|
+
|
|
93
|
+
if groups.empty?
|
|
94
|
+
return <<~RUBY.chomp
|
|
95
|
+
def #{method_name}(user)
|
|
96
|
+
['*']
|
|
97
|
+
end
|
|
98
|
+
RUBY
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
lines = []
|
|
102
|
+
lines << " def #{method_name}(user)"
|
|
103
|
+
|
|
104
|
+
groups.each do |group|
|
|
105
|
+
condition = build_role_condition(group[:roles])
|
|
106
|
+
array_str = fields_to_ruby_array(group[:fields])
|
|
107
|
+
lines << " return #{array_str} if #{condition}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
lines << " []"
|
|
111
|
+
lines << " end"
|
|
112
|
+
|
|
113
|
+
lines.join("\n")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Build the hidden_attributes_for_show method.
|
|
117
|
+
#
|
|
118
|
+
# @param permissions [Hash<String, Hash>]
|
|
119
|
+
# @return [String]
|
|
120
|
+
def build_hidden_attributes_method(permissions)
|
|
121
|
+
# Filter to only roles with hidden_fields
|
|
122
|
+
hidden_perms = permissions.select { |_, perm| perm[:hidden_fields]&.any? }
|
|
123
|
+
|
|
124
|
+
if hidden_perms.empty?
|
|
125
|
+
return <<~RUBY.chomp
|
|
126
|
+
def hidden_attributes_for_show(user)
|
|
127
|
+
[]
|
|
128
|
+
end
|
|
129
|
+
RUBY
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
groups = group_roles_by_fields(
|
|
133
|
+
hidden_perms.transform_values { |p| { show_fields: p[:hidden_fields] } },
|
|
134
|
+
:show_fields
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
lines = []
|
|
138
|
+
lines << " def hidden_attributes_for_show(user)"
|
|
139
|
+
|
|
140
|
+
groups.each do |group|
|
|
141
|
+
condition = build_role_condition(group[:roles])
|
|
142
|
+
array_str = fields_to_ruby_array(group[:fields])
|
|
143
|
+
lines << " return #{array_str} if #{condition}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
lines << " []"
|
|
147
|
+
lines << " end"
|
|
148
|
+
|
|
149
|
+
lines.join("\n")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentCode
|
|
4
|
+
module Blueprint
|
|
5
|
+
module Generators
|
|
6
|
+
# Generates seeder files for roles and users with aggregated permissions.
|
|
7
|
+
# Port of agentcode-adonis-server seeder_generator.ts.
|
|
8
|
+
class SeederGenerator
|
|
9
|
+
ALL_ACTIONS = %w[index show store update destroy trashed restore forceDelete].freeze
|
|
10
|
+
|
|
11
|
+
# Generate a RoleSeeder file for multi-tenant apps.
|
|
12
|
+
#
|
|
13
|
+
# @param roles [Hash<String, Hash>]
|
|
14
|
+
# @return [String] Ruby source code
|
|
15
|
+
def generate_role_seeder(roles)
|
|
16
|
+
role_entries = roles.map do |slug, role|
|
|
17
|
+
desc = role[:description].gsub("'", "\\\\'")
|
|
18
|
+
<<~RUBY.chomp
|
|
19
|
+
Role.find_or_create_by!(slug: '#{slug}') do |r|
|
|
20
|
+
r.name = '#{role[:name]}'
|
|
21
|
+
r.description = '#{desc}'
|
|
22
|
+
end
|
|
23
|
+
RUBY
|
|
24
|
+
end.join("\n\n")
|
|
25
|
+
|
|
26
|
+
<<~RUBY
|
|
27
|
+
# frozen_string_literal: true
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
#{role_entries}
|
|
31
|
+
RUBY
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Generate a UserRoleSeeder file for multi-tenant apps.
|
|
35
|
+
#
|
|
36
|
+
# @param roles [Hash<String, Hash>]
|
|
37
|
+
# @param aggregated_permissions [Hash<String, Array<String>>]
|
|
38
|
+
# @return [String] Ruby source code
|
|
39
|
+
def generate_user_role_seeder(roles, aggregated_permissions)
|
|
40
|
+
user_entries = roles.map do |slug, role|
|
|
41
|
+
permissions = aggregated_permissions[slug] || []
|
|
42
|
+
perm_str = permissions_to_ruby_array(permissions)
|
|
43
|
+
|
|
44
|
+
<<~RUBY.chomp
|
|
45
|
+
# #{role[:name]}
|
|
46
|
+
#{slug}_user = User.find_or_create_by!(email: '#{slug}@demo.com') do |u|
|
|
47
|
+
u.password = 'password'
|
|
48
|
+
end
|
|
49
|
+
#{slug}_role = Role.find_by!(slug: '#{slug}')
|
|
50
|
+
UserRole.find_or_create_by!(
|
|
51
|
+
user: #{slug}_user,
|
|
52
|
+
organization: org,
|
|
53
|
+
role: #{slug}_role
|
|
54
|
+
) do |ur|
|
|
55
|
+
ur.permissions = #{perm_str}
|
|
56
|
+
end
|
|
57
|
+
RUBY
|
|
58
|
+
end.join("\n\n")
|
|
59
|
+
|
|
60
|
+
<<~RUBY
|
|
61
|
+
# frozen_string_literal: true
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
org = Organization.find_or_create_by!(slug: 'demo-org') do |o|
|
|
65
|
+
o.name = 'Demo Organization'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
#{user_entries}
|
|
69
|
+
RUBY
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Generate a UserPermissionSeeder file for non-tenant apps.
|
|
73
|
+
#
|
|
74
|
+
# @param roles [Hash<String, Hash>]
|
|
75
|
+
# @param aggregated_permissions [Hash<String, Array<String>>]
|
|
76
|
+
# @return [String] Ruby source code
|
|
77
|
+
def generate_user_permission_seeder(roles, aggregated_permissions)
|
|
78
|
+
user_entries = roles.map do |slug, role|
|
|
79
|
+
permissions = aggregated_permissions[slug] || []
|
|
80
|
+
perm_str = permissions_to_ruby_array(permissions)
|
|
81
|
+
|
|
82
|
+
<<~RUBY.chomp
|
|
83
|
+
# #{role[:name]}
|
|
84
|
+
User.find_or_create_by!(email: '#{slug}@demo.com') do |u|
|
|
85
|
+
u.password = 'password'
|
|
86
|
+
u.permissions = #{perm_str}
|
|
87
|
+
end
|
|
88
|
+
RUBY
|
|
89
|
+
end.join("\n\n")
|
|
90
|
+
|
|
91
|
+
<<~RUBY
|
|
92
|
+
# frozen_string_literal: true
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
#{user_entries}
|
|
96
|
+
RUBY
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Aggregate permissions from multiple blueprints.
|
|
100
|
+
# Returns { role_slug => ['model.action', ...] } with wildcard simplification.
|
|
101
|
+
#
|
|
102
|
+
# @param blueprints [Array<Hash>]
|
|
103
|
+
# @return [Hash<String, Array<String>>]
|
|
104
|
+
def aggregate_permissions(blueprints)
|
|
105
|
+
result = {}
|
|
106
|
+
|
|
107
|
+
blueprints.each do |blueprint|
|
|
108
|
+
slug = blueprint[:slug]
|
|
109
|
+
|
|
110
|
+
blueprint[:permissions].each do |role, perm|
|
|
111
|
+
result[role] ||= Set.new
|
|
112
|
+
|
|
113
|
+
has_all = ALL_ACTIONS.all? { |a| perm[:actions].include?(a) }
|
|
114
|
+
|
|
115
|
+
if has_all
|
|
116
|
+
result[role].add("#{slug}.*")
|
|
117
|
+
else
|
|
118
|
+
perm[:actions].each { |action| result[role].add("#{slug}.#{action}") }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Simplify: if a role has wildcard on ALL models → ['*']
|
|
124
|
+
model_slugs = blueprints.map { |b| b[:slug] }
|
|
125
|
+
final_result = {}
|
|
126
|
+
|
|
127
|
+
result.each do |role, perms|
|
|
128
|
+
perm_array = perms.to_a.sort
|
|
129
|
+
|
|
130
|
+
has_all_wildcards = model_slugs.all? { |s| perms.include?("#{s}.*") }
|
|
131
|
+
|
|
132
|
+
if has_all_wildcards && model_slugs.any?
|
|
133
|
+
final_result[role] = ["*"]
|
|
134
|
+
else
|
|
135
|
+
final_result[role] = perm_array
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
final_result
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Convert permission array to Ruby array literal.
|
|
143
|
+
def permissions_to_ruby_array(permissions)
|
|
144
|
+
return "[]" if permissions.empty?
|
|
145
|
+
return "['*']" if permissions.length == 1 && permissions[0] == "*"
|
|
146
|
+
|
|
147
|
+
items = permissions.map { |p| "'#{p}'" }
|
|
148
|
+
inline = "[#{items.join(', ')}]"
|
|
149
|
+
|
|
150
|
+
if inline.length <= 80
|
|
151
|
+
inline
|
|
152
|
+
else
|
|
153
|
+
lines = items.join(",\n ")
|
|
154
|
+
"[\n #{lines},\n ]"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgentCode
|
|
4
|
+
module Blueprint
|
|
5
|
+
module Generators
|
|
6
|
+
# Generates RSpec request spec files with per-role contexts and individual action tests.
|
|
7
|
+
class TestGenerator
|
|
8
|
+
ALL_ACTIONS = %w[index show store update destroy trashed restore forceDelete].freeze
|
|
9
|
+
|
|
10
|
+
ACTION_LABELS = {
|
|
11
|
+
"index" => "list",
|
|
12
|
+
"show" => "show",
|
|
13
|
+
"store" => "create",
|
|
14
|
+
"update" => "update",
|
|
15
|
+
"destroy" => "delete",
|
|
16
|
+
"trashed" => "view trashed",
|
|
17
|
+
"restore" => "restore",
|
|
18
|
+
"forceDelete" => "force delete"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def generate(blueprint, is_multi_tenant, org_identifier = "slug")
|
|
22
|
+
model = blueprint[:model]
|
|
23
|
+
slug = blueprint[:slug]
|
|
24
|
+
permissions = blueprint[:permissions]
|
|
25
|
+
columns = blueprint[:columns]
|
|
26
|
+
factory_name = model_to_factory(model)
|
|
27
|
+
|
|
28
|
+
role_contexts = build_role_contexts(slug, factory_name, permissions, columns, is_multi_tenant, org_identifier)
|
|
29
|
+
|
|
30
|
+
if is_multi_tenant
|
|
31
|
+
wrap_multi_tenant(model, slug, role_contexts, org_identifier)
|
|
32
|
+
else
|
|
33
|
+
wrap_non_tenant(model, slug, role_contexts)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def actions_to_permissions(actions, slug)
|
|
38
|
+
if (ALL_ACTIONS - actions).empty?
|
|
39
|
+
"['#{slug}.*']"
|
|
40
|
+
else
|
|
41
|
+
items = actions.map { |a| "'#{slug}.#{a}'" }
|
|
42
|
+
"[#{items.join(', ')}]"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def model_to_factory(model)
|
|
49
|
+
model.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def build_role_contexts(slug, factory_name, permissions, columns, is_multi_tenant, org_identifier)
|
|
53
|
+
return "" if permissions.empty?
|
|
54
|
+
|
|
55
|
+
all_defined_actions = permissions.values.flat_map { |p| p[:actions] }.uniq & ALL_ACTIONS
|
|
56
|
+
lines = []
|
|
57
|
+
|
|
58
|
+
permissions.each do |role, perm|
|
|
59
|
+
allowed = perm[:actions] & all_defined_actions
|
|
60
|
+
blocked = all_defined_actions - perm[:actions]
|
|
61
|
+
|
|
62
|
+
lines << " context 'as #{role}' do"
|
|
63
|
+
lines << build_let_user(role, perm[:actions], slug, is_multi_tenant, org_identifier)
|
|
64
|
+
lines << build_let_record(factory_name, is_multi_tenant)
|
|
65
|
+
lines << ""
|
|
66
|
+
|
|
67
|
+
# Individual allowed action tests
|
|
68
|
+
allowed.each do |action|
|
|
69
|
+
lines << build_single_action_test(slug, action, is_multi_tenant, org_identifier, true)
|
|
70
|
+
lines << ""
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Individual blocked action tests
|
|
74
|
+
blocked.each do |action|
|
|
75
|
+
lines << build_single_action_test(slug, action, is_multi_tenant, org_identifier, false)
|
|
76
|
+
lines << ""
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Field visibility tests
|
|
80
|
+
field_test = build_field_visibility_test(slug, role, perm, columns, is_multi_tenant, org_identifier)
|
|
81
|
+
if field_test
|
|
82
|
+
lines << field_test
|
|
83
|
+
lines << ""
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Forbidden field tests
|
|
87
|
+
forbidden_test = build_forbidden_field_test(slug, role, perm, columns, is_multi_tenant, org_identifier)
|
|
88
|
+
if forbidden_test
|
|
89
|
+
lines << forbidden_test
|
|
90
|
+
lines << ""
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
lines << " end"
|
|
94
|
+
lines << ""
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
lines.join("\n")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def build_let_user(role, actions, slug, is_multi_tenant, org_identifier)
|
|
101
|
+
perms = actions_to_permissions(actions, slug)
|
|
102
|
+
if is_multi_tenant
|
|
103
|
+
" let(:user) { create_user_with_role('#{role}', org, #{perms}) }"
|
|
104
|
+
else
|
|
105
|
+
" let(:user) { create_user_with_permissions(#{perms}) }"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def build_let_record(factory_name, is_multi_tenant)
|
|
110
|
+
if is_multi_tenant
|
|
111
|
+
" let(:record) { create(:#{factory_name}, organization: org) }"
|
|
112
|
+
else
|
|
113
|
+
" let(:record) { create(:#{factory_name}) }"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def build_single_action_test(slug, action, is_multi_tenant, org_identifier, expect_success)
|
|
118
|
+
id_actions = %w[show update destroy restore forceDelete]
|
|
119
|
+
needs_id = id_actions.include?(action)
|
|
120
|
+
needs_discard = %w[restore forceDelete].include?(action)
|
|
121
|
+
|
|
122
|
+
action_methods = {
|
|
123
|
+
"index" => "get", "show" => "get", "store" => "post",
|
|
124
|
+
"update" => "put", "destroy" => "delete", "trashed" => "get",
|
|
125
|
+
"restore" => "post", "forceDelete" => "delete"
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
action_path_suffix = {
|
|
129
|
+
"index" => "", "show" => "/\#{record.id}", "store" => "",
|
|
130
|
+
"update" => "/\#{record.id}", "destroy" => "/\#{record.id}",
|
|
131
|
+
"trashed" => "/trashed", "restore" => "/\#{record.id}/restore",
|
|
132
|
+
"forceDelete" => "/\#{record.id}/force-delete"
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
success_codes = { "store" => ":created", "destroy" => ":no_content", "forceDelete" => ":no_content" }
|
|
136
|
+
default_success = ":ok"
|
|
137
|
+
|
|
138
|
+
http_method = action_methods[action]
|
|
139
|
+
suffix = action_path_suffix[action]
|
|
140
|
+
return "" unless http_method
|
|
141
|
+
|
|
142
|
+
label = ACTION_LABELS[action] || action
|
|
143
|
+
|
|
144
|
+
if is_multi_tenant
|
|
145
|
+
url = "\"/api/\#{org.#{org_identifier}}/#{slug}#{suffix}\""
|
|
146
|
+
else
|
|
147
|
+
url = "\"/api/#{slug}#{suffix}\""
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
if expect_success
|
|
151
|
+
code = success_codes[action] || default_success
|
|
152
|
+
verb = "can"
|
|
153
|
+
else
|
|
154
|
+
code = ":forbidden"
|
|
155
|
+
verb = "cannot"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
lines = []
|
|
159
|
+
lines << " it '#{verb} #{label} #{slug}' do"
|
|
160
|
+
lines << " record.discard" if needs_discard
|
|
161
|
+
lines << " #{http_method} #{url}, headers: auth_headers(user)"
|
|
162
|
+
if expect_success && action == "store"
|
|
163
|
+
lines << " expect(response.status).not_to eq(403)"
|
|
164
|
+
else
|
|
165
|
+
lines << " expect(response).to have_http_status(#{code})"
|
|
166
|
+
end
|
|
167
|
+
lines << " end"
|
|
168
|
+
lines.join("\n")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def build_field_visibility_test(slug, role, perm, columns, is_multi_tenant, org_identifier)
|
|
172
|
+
return nil unless perm[:actions].include?("show")
|
|
173
|
+
return nil if perm[:show_fields] == ["*"]
|
|
174
|
+
return nil if perm[:show_fields].empty?
|
|
175
|
+
|
|
176
|
+
all_fields = columns.map { |c| c[:name] }
|
|
177
|
+
visible = perm[:show_fields].include?("*") ? all_fields : perm[:show_fields]
|
|
178
|
+
hidden = all_fields - visible + (perm[:hidden_fields] || [])
|
|
179
|
+
hidden = hidden.uniq - visible
|
|
180
|
+
|
|
181
|
+
return nil if hidden.empty? && visible.empty?
|
|
182
|
+
|
|
183
|
+
lines = []
|
|
184
|
+
lines << " it 'shows only permitted fields' do"
|
|
185
|
+
|
|
186
|
+
if is_multi_tenant
|
|
187
|
+
lines << " get \"/api/\#{org.#{org_identifier}}/#{slug}/\#{record.id}\", headers: auth_headers(user)"
|
|
188
|
+
else
|
|
189
|
+
lines << " get \"/api/#{slug}/\#{record.id}\", headers: auth_headers(user)"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
lines << " expect(response).to have_http_status(:ok)"
|
|
193
|
+
lines << " data = JSON.parse(response.body)"
|
|
194
|
+
lines << ""
|
|
195
|
+
|
|
196
|
+
visible.each do |field|
|
|
197
|
+
lines << " expect(data).to have_key('#{field}')"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
if hidden.any?
|
|
201
|
+
lines << ""
|
|
202
|
+
hidden.each do |field|
|
|
203
|
+
lines << " expect(data).not_to have_key('#{field}')"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
lines << " end"
|
|
208
|
+
lines.join("\n")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def build_forbidden_field_test(slug, role, perm, columns, is_multi_tenant, org_identifier)
|
|
212
|
+
return nil unless perm[:actions].include?("store")
|
|
213
|
+
return nil if perm[:create_fields] == ["*"]
|
|
214
|
+
return nil if perm[:create_fields].empty?
|
|
215
|
+
|
|
216
|
+
all_fields = columns.map { |c| c[:name] }
|
|
217
|
+
forbidden = all_fields - perm[:create_fields]
|
|
218
|
+
|
|
219
|
+
return nil if forbidden.empty?
|
|
220
|
+
|
|
221
|
+
lines = []
|
|
222
|
+
lines << " it 'returns 403 when setting restricted fields' do"
|
|
223
|
+
|
|
224
|
+
if is_multi_tenant
|
|
225
|
+
lines << " post \"/api/\#{org.#{org_identifier}}/#{slug}\","
|
|
226
|
+
else
|
|
227
|
+
lines << " post \"/api/#{slug}\","
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
lines << " params: { #{forbidden.first}: 'forbidden_value' },"
|
|
231
|
+
lines << " headers: auth_headers(user)"
|
|
232
|
+
lines << ""
|
|
233
|
+
lines << " expect(response).to have_http_status(:forbidden)"
|
|
234
|
+
lines << " end"
|
|
235
|
+
lines.join("\n")
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def wrap_multi_tenant(model, slug, role_contexts, org_identifier)
|
|
239
|
+
<<~RUBY
|
|
240
|
+
# frozen_string_literal: true
|
|
241
|
+
|
|
242
|
+
require 'rails_helper'
|
|
243
|
+
|
|
244
|
+
RSpec.describe '#{model} — CRUD & Permissions', type: :request do
|
|
245
|
+
let(:org) { create(:organization) }
|
|
246
|
+
|
|
247
|
+
def create_user_with_role(role_slug, organization, permissions)
|
|
248
|
+
user = create(:user)
|
|
249
|
+
role = Role.find_or_create_by!(slug: role_slug, name: role_slug.capitalize)
|
|
250
|
+
UserRole.find_or_create_by!(
|
|
251
|
+
user: user,
|
|
252
|
+
organization: organization,
|
|
253
|
+
role: role
|
|
254
|
+
) do |ur|
|
|
255
|
+
ur.permissions = permissions
|
|
256
|
+
end
|
|
257
|
+
user
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def auth_headers(user)
|
|
261
|
+
{ 'Authorization' => "Bearer \#{user.api_token}" }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
#{role_contexts}
|
|
265
|
+
end
|
|
266
|
+
RUBY
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def wrap_non_tenant(model, slug, role_contexts)
|
|
270
|
+
<<~RUBY
|
|
271
|
+
# frozen_string_literal: true
|
|
272
|
+
|
|
273
|
+
require 'rails_helper'
|
|
274
|
+
|
|
275
|
+
RSpec.describe '#{model} — CRUD & Permissions', type: :request do
|
|
276
|
+
def create_user_with_permissions(permissions)
|
|
277
|
+
create(:user, permissions: permissions)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def auth_headers(user)
|
|
281
|
+
{ 'Authorization' => "Bearer \#{user.api_token}" }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
#{role_contexts}
|
|
285
|
+
end
|
|
286
|
+
RUBY
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module AgentCode
|
|
6
|
+
module Blueprint
|
|
7
|
+
# Tracks file hashes and generated files for change detection.
|
|
8
|
+
# Port of agentcode-server ManifestManager.php / agentcode-adonis-server manifest_manager.ts.
|
|
9
|
+
class ManifestManager
|
|
10
|
+
def initialize(blueprints_dir)
|
|
11
|
+
@manifest_path = File.join(blueprints_dir, ".blueprint-manifest.json")
|
|
12
|
+
@manifest = load_manifest
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Check if a blueprint file has changed since last generation.
|
|
16
|
+
def has_changed?(filename, current_hash)
|
|
17
|
+
entry = @manifest.dig("files", filename)
|
|
18
|
+
return true unless entry
|
|
19
|
+
|
|
20
|
+
entry["content_hash"] != current_hash
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Record a successful generation.
|
|
24
|
+
def record_generation(filename, content_hash, generated_files)
|
|
25
|
+
@manifest["files"][filename] = {
|
|
26
|
+
"content_hash" => content_hash,
|
|
27
|
+
"generated_files" => generated_files,
|
|
28
|
+
"generated_at" => Time.now.iso8601
|
|
29
|
+
}
|
|
30
|
+
@manifest["generated_at"] = Time.now.iso8601
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Get the list of generated files for a blueprint.
|
|
34
|
+
def get_generated_files(filename)
|
|
35
|
+
@manifest.dig("files", filename, "generated_files") || []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Get all tracked blueprint filenames.
|
|
39
|
+
def get_tracked_files
|
|
40
|
+
@manifest["files"].keys
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Remove tracking for a blueprint file.
|
|
44
|
+
def remove_tracking(filename)
|
|
45
|
+
@manifest["files"].delete(filename)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Save manifest to disk.
|
|
49
|
+
def save
|
|
50
|
+
File.write(@manifest_path, JSON.pretty_generate(@manifest))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Load manifest from disk, returning empty structure if missing or corrupted.
|
|
54
|
+
def load_manifest
|
|
55
|
+
return empty_manifest unless File.exist?(@manifest_path)
|
|
56
|
+
|
|
57
|
+
begin
|
|
58
|
+
parsed = JSON.parse(File.read(@manifest_path))
|
|
59
|
+
|
|
60
|
+
if parsed.is_a?(Hash) && parsed["version"] && parsed["files"]
|
|
61
|
+
parsed
|
|
62
|
+
else
|
|
63
|
+
empty_manifest
|
|
64
|
+
end
|
|
65
|
+
rescue JSON::ParserError
|
|
66
|
+
empty_manifest
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def empty_manifest
|
|
73
|
+
{
|
|
74
|
+
"version" => 1,
|
|
75
|
+
"generated_at" => Time.now.iso8601,
|
|
76
|
+
"files" => {}
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|