diff --git a/Gemfile.sqlite-vec.lock b/Gemfile.sqlite-vec.lock index bf196de..ba73fa6 100644 --- a/Gemfile.sqlite-vec.lock +++ b/Gemfile.sqlite-vec.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - mxrb (0.1.1) + mxrb (0.1.2) base64 (~> 0.2) bigdecimal (~> 3.1) bson (~> 5.2) @@ -126,7 +126,7 @@ CHECKSUMS json (2.21.1) sha256=13a43df75d95641443f5702dff350f237164a9d811ff0f2c2800d4d980220583 language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 - mxrb (0.1.1) + mxrb (0.1.2) onnxruntime (0.11.5-arm64-darwin) sha256=9f2f45b3ac16999c466ac352562195e03a011cc930b8750c0b1e43a49c1ca5d9 onnxruntime (0.11.5-x86_64-darwin) sha256=19c18e90eb101f69a3232dfdd1742e77d69e2df623b106b670221629d4b4cc15 onnxruntime (0.11.5-x86_64-linux) sha256=13f20de606a8fb32ba404bca9e1fc47f0eab4605c909252dbcf6db4fb106c9e6 diff --git a/README.de-DE.md b/README.de-DE.md index c57b391..79d6cdf 100644 --- a/README.de-DE.md +++ b/README.de-DE.md @@ -19,6 +19,7 @@ und Testen von Mendix-Projekten (`.mpr`) ohne MDL oder `mxcli`. - Lint und ausführbare Modellbewertungen; - Erkennung von nativem OQL und logische SQL-Ansicht nur bei vorhandenem OQL; - isolierte, durch das Runtime synchronisierte PostgreSQL für direkte SQL-Abfragen; +- Demo-Benutzer mit lokalen `.env`-Geheimnissen und sichere Runtime-Zugangsdaten; - Analyse von PostgreSQL-/SQL-Server-Plänen und kumulativem PostgreSQL-Workload; - funktionale Microflow-Tests lokal oder in Docker. - Suche und Installation wiederverwendbarer Ruby-Module mit SHA-256-Lock. @@ -35,6 +36,7 @@ wird kompatibles Java benötigt; Docker stellt JDK und Runtime bereit. ```sh bundle install +bundle exec mxrb --version bundle exec mxrb validate App.mpr bundle exec mxrb export App.mpr app-ruby bundle exec mxrb compare original.mpr rebuilt.mpr @@ -45,7 +47,9 @@ bundle exec mxrb cache warm App.mpr bundle exec mxrb cache clear App.mpr bundle exec mxrb oql App.mpr --dialect postgresql bundle exec mxrb db up App.mpr +bundle exec mxrb db credentials App.mpr --copy bundle exec mxrb db sql App.mpr 'SELECT * FROM "sales$order" LIMIT 20' +bundle exec mxrb demo-user new manager --entity System.User --role User ``` Die MPR speichert das Modell, nicht die Anwendungsdaten. `db up` baut das diff --git a/README.md b/README.md index ebc6ec2..283f97e 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ remain optional external gates for compatibility and functional execution. - inventory design tokens, check contrast and preview atomic literal migration; - discover native OQL and render safe logical SQL views only when OQL exists; - materialize an isolated, Runtime-synchronized PostgreSQL for direct SQL inspection; +- scaffold demo users with local `.env` secrets and safely inspect Runtime credentials; - analyze PostgreSQL/SQL Server plans and rank cumulative PostgreSQL workload pressure; - move standalone units between folders in the same module with a preview; - remove standalone units only after reference and child-unit safety checks; @@ -57,6 +58,7 @@ Install dependencies: ```sh bundle install +bundle exec mxrb --version ``` ## Ruby DSL @@ -92,7 +94,9 @@ bundle exec mxrb cache warm Shop.mpr bundle exec mxrb cache clear Shop.mpr bundle exec mxrb oql Shop.mpr --dialect postgresql bundle exec mxrb db up Shop.mpr +bundle exec mxrb db credentials Shop.mpr --copy bundle exec mxrb db sql Shop.mpr 'SELECT * FROM "sales$order" LIMIT 20' +bundle exec mxrb demo-user new manager --entity System.User --role User bundle exec mxrb pack Shop.mpr --output build/Shop.mda bundle exec mxrb portable Shop.mpr --output build/runtime.zip bundle exec mxrb team-server clone APP_ID ./shop --pat-file /secure/team-server.env diff --git a/README.pt-BR.md b/README.pt-BR.md index f83c4e8..d651f93 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -19,6 +19,7 @@ projetos Mendix (`.mpr`) sem MDL ou `mxcli`. - lint e avaliações executáveis de modelo; - descoberta de OQL nativo e visão SQL lógica somente quando o projeto contém OQL; - PostgreSQL isolado e sincronizado pelo Runtime para consultas SQL diretas; +- scaffold de demo users com segredos locais no `.env` e consulta segura de credenciais; - análise de planos PostgreSQL/SQL Server e workload cumulativo PostgreSQL; - testes funcionais de microflows localmente ou em Docker. - busca e instalação de módulos Ruby reutilizáveis com SHA-256 travado. @@ -34,6 +35,7 @@ local precisa do Java compatível; o modo Docker fornece JDK e Runtime. ```sh bundle install +bundle exec mxrb --version bundle exec mxrb init vet_clinic bundle exec mxrb validate App.mpr bundle exec mxrb export App.mpr app-ruby @@ -45,7 +47,9 @@ bundle exec mxrb cache warm App.mpr bundle exec mxrb cache clear App.mpr bundle exec mxrb oql App.mpr --dialect postgresql bundle exec mxrb db up App.mpr +bundle exec mxrb db credentials App.mpr --copy bundle exec mxrb db sql App.mpr 'SELECT * FROM "sales$order" LIMIT 20' +bundle exec mxrb demo-user new manager --entity System.User --role User ``` O MPR armazena o modelo, não os dados da aplicação. `db up` compila o Runtime diff --git a/bin/mxrb b/bin/mxrb index 63eb2b8..71098cb 100755 --- a/bin/mxrb +++ b/bin/mxrb @@ -67,6 +67,9 @@ SCAFFOLD_COMMANDS = (Mxrb::Scaffold::Help::COMMANDS.keys - ["design"]).freeze command = ARGV.shift case command +when '--version', '-v' + puts "mxrb #{Mxrb::VERSION}" + when 'doctor' json = ARGV.delete('--json') root = ARGV.shift || Dir.pwd @@ -742,7 +745,7 @@ when "design" when "db" action = ARGV.shift or - abort "Usage: mxrb db " + abort "Usage: mxrb db " path = ARGV.shift or abort "Usage: mxrb db #{action} " option = lambda do |name, fallback = nil| index = ARGV.index(name) @@ -795,6 +798,30 @@ when "db" puts "Database : #{info.database_container}" puts "Runtime : #{info.runtime_container}" puts "State : #{info.state_dir}" + when "credentials" + copy = !ARGV.delete("--copy").nil? + show_password = !ARGV.delete("--show-password").nil? + json = !ARGV.delete("--json").nil? + abort '--copy and --show-password are mutually exclusive' if copy && show_password + abort "Unknown arguments: #{ARGV.join(' ')}" unless ARGV.empty? + + credentials = workspace.admin_credentials + visible_password = nil + visible_password = credentials.password if show_password + if copy + command = Mxrb::Runtime::Clipboard.new.copy(credentials.password) + puts "[mxrb] Password for #{credentials.username} copied with #{command}" + elsif json + puts JSON.pretty_generate( + username: credentials.username, password: visible_password, + state: credentials.state_dir, credentials_file: credentials.path + ) + else + visible_password ||= '' + puts "User name : #{credentials.username}" + puts "Password : #{visible_password}" + puts "State : #{credentials.state_dir}" + end when "url" puts workspace.connection_url when "sql" @@ -833,7 +860,7 @@ when "db" exec(*workspace.shell_command(write:)) else abort "Unknown db action #{action.inspect}; " \ - "use up, sync, down, destroy, status, url, sql, explain, workload, indexes or shell" + "use up, sync, down, destroy, status, credentials, url, sql, explain, workload, indexes or shell" end when "serve" @@ -1479,6 +1506,7 @@ else mxrb — Pure-Ruby Mendix .mpr toolkit Commands: + --version, -v Show the installed MXRB version doctor [DIR] [--json] Check project files and local toolchain benchmark Measure open, index and validation time --iterations N --json Configure repetitions or emit JSON @@ -1537,6 +1565,8 @@ else down Stop containers while preserving all data destroy --yes Remove isolated containers, data, and state status Show isolated database/runtime status + credentials [--copy] Show the admin user or copy its password + --show-password Explicitly print the Runtime admin password url [--port PORT] Print the read-only PostgreSQL connection URL sql "SELECT …" Execute SQL as the read-only analyst role --write Explicitly use the Runtime owner role @@ -1595,6 +1625,9 @@ else nanoflow new Module.Name Scaffold a client nanoflow repository new Module.Name Scaffold a repository port and adapter security init Module Scaffold module roles + demo-user [new] NAME Scaffold a local Mendix demo user + [--entity ENTITY] Default: System.User + [--role ROLE] Repeatable; default: User scheduled-event new Module.Name Scaffold an event and handler constant new Module.Name Scaffold a module constant integration new Module.Name Scaffold an integration adapter diff --git a/docs/de-DE/oql-sql.md b/docs/de-DE/oql-sql.md index b670bb0..c8fb738 100644 --- a/docs/de-DE/oql-sql.md +++ b/docs/de-DE/oql-sql.md @@ -142,6 +142,24 @@ bundle exec mxrb db sql Shop.mpr \ bundle exec mxrb db shell Shop.mpr ``` +Die Zugangsdaten des vorhandenen Runtime-Administrators lassen sich prüfen, +ohne das Passwort standardmäßig auszugeben: + +```sh +bundle exec mxrb db credentials Shop.mpr +bundle exec mxrb db credentials Shop.mpr --copy +bundle exec mxrb db credentials Shop.mpr --json +bundle exec mxrb db credentials Shop.mpr --show-password +``` + +`--copy` übergibt das Passwort per stdin an die Zwischenablage und nie als +Prozessargument. Nur `--show-password` gibt es aus; im JSON bleibt `password` +ohne diese Option `null`. `--copy` und `--show-password` schließen einander +aus. Der Befehl liest nur einen vorhandenen Workspace: Er erzeugt oder rotiert +keine Zugangsdaten und verweist auf `db up`, falls noch kein Zustand existiert. +Dies sind die Zugangsdaten des Mendix-Anwendungsadministrators, getrennt von +den PostgreSQL-Rollen `mxrb_reader` und Runtime-Eigentümer. + Nach einer Änderung der MPR wird unter Beibehaltung der Daten neu gebaut und synchronisiert: diff --git a/docs/de-DE/scaffolds.md b/docs/de-DE/scaffolds.md index 4b7ce90..f08ae6b 100644 --- a/docs/de-DE/scaffolds.md +++ b/docs/de-DE/scaffolds.md @@ -30,6 +30,29 @@ Data Source. Ketten, die mit einem Microflow enden, erzeugen zusätzlich Jede erzeugte Kette wird als gültiges MPR materialisiert und durch den Compiler-Preflight geprüft. +## Demo-Benutzer + +Nach der Initialisierung der Sicherheit wird jeder Demo-Benutzer als eigene +Ruby-Datei erzeugt: + +```sh +mxrb security init App +mxrb demo-user new manager --entity System.User --role User +mxrb demo-user operator --entity App.Account --role User --role Administrator +``` + +`new` ist optional. `--entity` verwendet standardmäßig `System.User`, und +`--role` kann wiederholt werden; ohne Angabe wird die Rolle `User` gewählt. Das +Scaffold prüft sofort, ob Entität und Anwendungsrollen vorhanden sind, +verbindet `app/security/demo_users` mit dem `security`-Block und aktiviert +Demo-Benutzer in der MPR. + +Das zufällige Passwort liegt nur in der ignorierten `.env`, die beim Erstellen +den Modus `0600` erhält. Der erzeugte Ruby-Code nutzt `ENV.fetch`, während +`.env.example` nur eine leere Variable enthält. Danach wird die MPR wie gewohnt +generiert und validiert. Demo-Benutzer sind für Entwicklung und Vorführungen +gedacht, nicht für Produktionsbenutzer. + ## Seitenvorlagen In Mendix sind Page Templates Ausgangspunkte, deren Struktur zu einer normalen, diff --git a/docs/en-US/oql-sql.md b/docs/en-US/oql-sql.md index 7a74e04..d78acaa 100644 --- a/docs/en-US/oql-sql.md +++ b/docs/en-US/oql-sql.md @@ -137,6 +137,24 @@ bundle exec mxrb db sql Shop.mpr \ bundle exec mxrb db shell Shop.mpr ``` +You can inspect the existing Runtime administrator credentials without +printing the password by default: + +```sh +bundle exec mxrb db credentials Shop.mpr +bundle exec mxrb db credentials Shop.mpr --copy +bundle exec mxrb db credentials Shop.mpr --json +bundle exec mxrb db credentials Shop.mpr --show-password +``` + +`--copy` sends the password to the clipboard through stdin, never in process +arguments. `--show-password` is the only mode that prints it; JSON keeps +`password` as `null` without that option. `--copy` and `--show-password` are +mutually exclusive. The command only reads an existing workspace: it neither +creates nor rotates credentials and tells you to run `db up` when no state +exists. These are Mendix application-administrator credentials, separate from +the PostgreSQL `mxrb_reader` and Runtime-owner roles. + After changing the MPR, rebuild and synchronize while retaining the data: ```sh diff --git a/docs/en-US/scaffolds.md b/docs/en-US/scaffolds.md index b4941de..96b8be9 100644 --- a/docs/en-US/scaffolds.md +++ b/docs/en-US/scaffolds.md @@ -29,6 +29,28 @@ end in a microflow also create `ACT_RefreshOrder`. `page generate` and `page g` are aliases of `page new`. Every generated chain is materialized as a valid MPR and checked by compiler preflight. +## Demo users + +After initializing security, scaffold each demo user as an independent Ruby +file: + +```sh +mxrb security init App +mxrb demo-user new manager --entity System.User --role User +mxrb demo-user operator --entity App.Account --role User --role Administrator +``` + +`new` is optional. `--entity` defaults to `System.User`, and `--role` is +repeatable; without it, the `User` role is selected. The scaffold immediately +validates that the entity and application roles exist, connects +`app/security/demo_users` to the `security` block, and enables demo users in +the MPR. + +The random password exists only in the ignored `.env`, created with mode +`0600`. Generated Ruby uses `ENV.fetch`, while `.env.example` receives only an +empty variable. Generate and validate the MPR normally after scaffolding. Demo +users are intended for development and demonstrations, not production users. + ## Page templates In Mendix, page templates are starting points whose structure becomes a normal, diff --git a/docs/pt-BR/oql-sql.md b/docs/pt-BR/oql-sql.md index ad798f8..71caf2b 100644 --- a/docs/pt-BR/oql-sql.md +++ b/docs/pt-BR/oql-sql.md @@ -144,6 +144,24 @@ bundle exec mxrb db sql Shop.mpr \ bundle exec mxrb db shell Shop.mpr ``` +As credenciais do administrador do Runtime já criado podem ser consultadas sem +imprimir a senha por padrão: + +```sh +bundle exec mxrb db credentials Shop.mpr +bundle exec mxrb db credentials Shop.mpr --copy +bundle exec mxrb db credentials Shop.mpr --json +bundle exec mxrb db credentials Shop.mpr --show-password +``` + +`--copy` envia a senha ao clipboard por stdin, sem colocá-la nos argumentos do +processo. `--show-password` é a única forma de imprimi-la; no JSON, `password` +permanece `null` sem essa opção. `--copy` e `--show-password` são mutuamente +exclusivos. O comando apenas lê o workspace existente: não cria nem gira +credenciais e orienta executar `db up` quando o estado ainda não existe. Essas +são as credenciais do administrador da aplicação Mendix, distintas dos papéis +PostgreSQL `mxrb_reader` e proprietário do Runtime. + Depois de alterar o MPR, recompile e sincronize preservando os dados: ```sh diff --git a/docs/pt-BR/scaffolds.md b/docs/pt-BR/scaffolds.md index c516a9b..60b9ac7 100644 --- a/docs/pt-BR/scaffolds.md +++ b/docs/pt-BR/scaffolds.md @@ -30,6 +30,7 @@ o hash criado pelo gerador, protegendo edições posteriores. | `mxrb page new App.Order --chain page:nanoflow:microflow` | Fatia vertical executável | | `mxrb nanoflow new App.NAN_OpenCustomer` | Nanoflow cliente | | `mxrb security init App` | Papéis, `CheckEverything` no projeto e orientação de acesso | +| `mxrb demo-user new manager --role User` | Demo user com segredo local em `.env` | | `mxrb integration new App.PetApi` | Adaptador de integração | | `mxrb published-rest new App.CustomersApi` | Handler editável para REST publicado | | `mxrb consumed-rest new App.ExternalPets` | Adaptador REST consumido | @@ -71,6 +72,27 @@ são aliases de `page new`. Cada cadeia é materializada em um MPR válido, serv como ponto de partida removível e exercita o preflight do compilador; arquivos existentes nunca são sobrescritos. +## Demo users + +Depois de inicializar a segurança, um demo user pode ser declarado como um +arquivo Ruby independente: + +```sh +mxrb security init App +mxrb demo-user new manager --entity System.User --role User +mxrb demo-user operator --entity App.Account --role User --role Administrator +``` + +`new` é opcional. `--entity` usa `System.User` por padrão e `--role` pode ser +repetido; sem ele, o papel `User` é usado. O scaffold valida imediatamente se +a entidade e os papéis existem, conecta `app/security/demo_users` ao bloco +`security` e habilita demo users no MPR. + +A senha aleatória fica apenas no `.env` ignorado, com modo `0600` quando o +arquivo é criado. O Ruby gerado usa `ENV.fetch` e `.env.example` recebe somente +a variável vazia. Gere e valide o MPR normalmente depois do scaffold. Demo +users destinam-se a desenvolvimento e demonstração, não a usuários de produção. + ## Modelos de página No Mendix, page templates são pontos de partida: sua estrutura é copiada para diff --git a/lib/mxrb.rb b/lib/mxrb.rb index 283f0fd..f1fe11e 100644 --- a/lib/mxrb.rb +++ b/lib/mxrb.rb @@ -126,6 +126,7 @@ require_relative "mxrb/runtime/executor" require_relative "mxrb/runtime/native" require_relative "mxrb/runtime/docker_executor" +require_relative "mxrb/runtime/clipboard" require_relative "mxrb/runtime/database_workspace" require_relative "mxrb/runtime/sql_server_database" require_relative "mxrb/oql/server" diff --git a/lib/mxrb/compare.rb b/lib/mxrb/compare.rb index 7d2fa73..aa173e4 100644 --- a/lib/mxrb/compare.rb +++ b/lib/mxrb/compare.rb @@ -72,6 +72,13 @@ def security_summary(project) admin_user_name: doc["AdminUserName"], admin_user_role: doc["AdminUserRole"], demo_users_enabled: doc["EnableDemoUsers"], + demo_users: IO::BsonCodec.parse_array(doc["DemoUsers"]).fetch(:items).map do |user| + { + name: user["UserName"], entity: user["Entity"], + roles: IO::BsonCodec.parse_array(user["UserRoles"]).fetch(:items).sort, + password_sha256: Digest::SHA256.hexdigest(user["Password"].to_s) + } + end.sort_by { _1[:name].to_s }, guest_access_enabled: doc["EnableGuestAccess"], guest_user_role: doc["GuestUserRole"], sign_in_microflow: doc["SignInMicroflow"], diff --git a/lib/mxrb/dsl/builder.rb b/lib/mxrb/dsl/builder.rb index 47b11ad..8f31696 100644 --- a/lib/mxrb/dsl/builder.rb +++ b/lib/mxrb/dsl/builder.rb @@ -6,6 +6,49 @@ module Mxrb module Dsl + # Validates project-security references that cross DSL builders. + class SecurityValidator + def initialize(modules, security) + @modules = modules + @security = security + end + + def validate! + return unless @security + + errors = demo_users.flat_map { reference_errors(_1) } + duplicates = demo_users.group_by { _1.fetch(:name) }.select { |_name, users| users.size > 1 } + errors << "duplicate demo user name(s): #{duplicates.keys.join(', ')}" unless duplicates.empty? + return if errors.empty? + + raise ValidationError, "security validation failed:\n- #{errors.join("\n- ")}" + end + + private + + def demo_users = Array(@security[:demo_users]) + + def reference_errors(user) + errors = [] + missing_roles = user.fetch(:roles) - user_roles + errors << "demo user #{user[:name]} references missing user role(s): #{missing_roles.join(', ')}" unless + missing_roles.empty? + entity = user.fetch(:entity) + errors << "demo user #{user[:name]} references missing user entity #{entity}" unless + entity == "System.User" || entities.include?(entity) + errors + end + + def user_roles = @security.fetch(:user_roles, []).map { _1.fetch(:name) } + + def entities + @modules.flat_map do |mod| + definition = mod.to_h + definition.fetch(:entities, []).map { "#{definition.fetch(:name)}.#{_1.fetch(:name)}" } + end + end + end + # Shared widget-building methods for PageBuilder and ContainerBuilder. # Includers must implement private `_widget_list` returning the target array. module WidgetDsl @@ -361,9 +404,14 @@ def graph end def validate! + validate_security! Architecture::Validator.new(graph).validate! end + def validate_security! + SecurityValidator.new(@modules, @security).validate! + end + def definition { version: @mendix_version, @@ -382,6 +430,8 @@ def definition class SecurityBuilder def initialize @user_roles = [] + @demo_users = [] + @demo_users_declared = false @security_level = nil @admin_user_role = nil @demo_users_enabled = nil @@ -414,6 +464,36 @@ def demo_users(enabled = true) @demo_users_enabled = enabled == true end + def demo_user(name, entity:, roles:, password:) + user_name = name.to_s + entity_name = entity.to_s + role_names = Array(roles).map(&:to_s).uniq + secret = password.to_s + raise ArgumentError, 'demo user name must not be empty' if user_name.empty? + raise ArgumentError, 'demo user name must not contain whitespace' unless user_name.match?(/\A[^\s]+\z/) + unless entity_name.match?(/\A[A-Za-z][A-Za-z0-9_]*\.[A-Za-z][A-Za-z0-9_]*\z/) + raise ArgumentError, 'demo user entity must be qualified as Module.Entity' + end + raise ArgumentError, 'demo user requires at least one role' if role_names.empty? + raise ArgumentError, 'demo user password must not be empty' if secret.empty? + + @demo_users << { + name: user_name, entity: entity_name, roles: role_names, password: secret + } + @demo_users_declared = true + @demo_users_enabled = true + end + + def evaluate(path) + instance_eval(File.read(path), path, 1) + end + + def evaluate_dir(dir) + return unless File.directory?(dir) + + Dir[File.join(dir, '*.rb')].sort.each { |path| evaluate(path) } + end + def guest_access(enabled = true, role: nil) @guest_access_enabled = enabled == true @guest_user_role = role&.to_s @@ -430,6 +510,7 @@ def password_policy(**options) def to_h { user_roles: @user_roles, + demo_users: @demo_users_declared ? @demo_users : nil, security_level: @security_level, admin_user_role: @admin_user_role, demo_users_enabled: @demo_users_enabled, diff --git a/lib/mxrb/exporter.rb b/lib/mxrb/exporter.rb index 76e855f..0c989c8 100644 --- a/lib/mxrb/exporter.rb +++ b/lib/mxrb/exporter.rb @@ -415,6 +415,12 @@ def security_source(doc) options = [] options << " admin_user_role #{ruby(doc["AdminUserRole"])}" unless doc["AdminUserRole"].to_s.empty? options << " demo_users #{doc["EnableDemoUsers"] == true}" + demo_users = doc["DemoUsers"] || IO::BsonCodec.build_array([]) + IO::BsonCodec.parse_array(demo_users).fetch(:items).each do |user| + assigned_roles = IO::BsonCodec.parse_array(user["UserRoles"]).fetch(:items) + options << " demo_user #{ruby(user["UserName"])}, entity: #{ruby(user["Entity"])}, " \ + "roles: #{ruby(assigned_roles)}, password: #{ruby(user["Password"])}" + end guest = " guest_access #{doc["EnableGuestAccess"] == true}" guest += ", role: #{ruby(doc["GuestUserRole"])}" unless doc["GuestUserRole"].to_s.empty? options << guest diff --git a/lib/mxrb/initializer.rb b/lib/mxrb/initializer.rb index 9dfefb9..2ef672d 100644 --- a/lib/mxrb/initializer.rb +++ b/lib/mxrb/initializer.rb @@ -152,6 +152,7 @@ def project_rb # frozen_string_literal: true require "dotenv/load" + Dotenv.load(File.join(__dir__, ".env")) require "mxrb" output = ENV.fetch("MXRB_OUTPUT_PATH", File.join(__dir__, "#{@mpr_name}")) diff --git a/lib/mxrb/runtime/clipboard.rb b/lib/mxrb/runtime/clipboard.rb new file mode 100644 index 0000000..42182c8 --- /dev/null +++ b/lib/mxrb/runtime/clipboard.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'tempfile' + +module Mxrb + module Runtime + # Copies secrets through stdin so they never appear in the process list. + class Clipboard + COMMANDS = [ + ['wl-copy'], ['pbcopy'], ['xclip', '-selection', 'clipboard'], + ['xsel', '--clipboard', '--input'], ['clip.exe'] + ].freeze + + def initialize(path: ENV.fetch('PATH', ''), runner: nil) + @path = path + @runner = runner || method(:capture) + end + + def copy(text) + command = available_command + unless command + raise ToolchainError, + 'no clipboard command found; install wl-copy, pbcopy, xclip, xsel, or clip.exe' + end + + _stdout, stderr, status = @runner.call(command, text.to_s) + raise ToolchainError, "clipboard command failed: #{stderr}" unless status.success? + + command.first + end + + private + + def available_command + COMMANDS.find { executable?(_1.first) } + end + + def executable?(name) + @path.split(File::PATH_SEPARATOR).any? do |directory| + file = File.join(directory, name) + File.file?(file) && File.executable?(file) + end + end + + def capture(command, input) + Tempfile.create('mxrb-clipboard') { capture_process(command, input, _1) } + end + + def capture_process(command, input, stderr) + ::IO.pipe do |reader, writer| + pid = Process.spawn({ 'PATH' => @path }, *command, in: reader, out: File::NULL, err: stderr) + reader.close + writer.write(input) + writer.close + _child, status = Process.wait2(pid) + stderr.rewind + ['', stderr.read, status] + end + end + end + end +end diff --git a/lib/mxrb/runtime/database_workspace.rb b/lib/mxrb/runtime/database_workspace.rb index 77d1f10..60bc8d1 100644 --- a/lib/mxrb/runtime/database_workspace.rb +++ b/lib/mxrb/runtime/database_workspace.rb @@ -14,6 +14,7 @@ module Runtime :host, :port, :database, :reader_user, :running, :model_fingerprint, :runtime_url ) + AdminCredentials = Data.define(:username, :password, :state_dir, :path) # Materializes a private PostgreSQL database for one MPR. The Mendix Runtime # owns schema synchronization; analyst access uses a separate read-only role. @@ -148,8 +149,35 @@ def connection_url "postgresql://#{READER}:#{secret}@127.0.0.1:#{@port}/#{DATABASE}" end + def admin_credentials + unless File.file?(credentials_path) + raise ToolchainError, + "Runtime credentials not found; run `mxrb db up #{@project_path}` first" + end + + values = JSON.parse(File.read(credentials_path)) + password = values.fetch('admin_password') + AdminCredentials.new(admin_user_name, password, @state_dir, credentials_path) + rescue JSON::ParserError, KeyError => e + raise ToolchainError, "invalid Runtime credentials file #{credentials_path}: #{e.message}" + end + private + def admin_user_name + mpr = IO::MprFile.open(@project_path, readonly: true) + begin + security = mpr.all_units.lazy.map { mpr.parse_contents(_1) } + .find { _1['$Type'] == 'Security$ProjectSecurity' } + name = security.to_h.fetch('AdminUserName', '').to_s + raise ToolchainError, 'MPR has no configured administrator user' if name.empty? + + name + ensure + mpr.close + end + end + def bind_parameters(statement, params) values = params.to_h.transform_keys(&:to_s) return [statement, []] if values.empty? diff --git a/lib/mxrb/scaffold/cli.rb b/lib/mxrb/scaffold/cli.rb index 1cecd87..c99ccdb 100644 --- a/lib/mxrb/scaffold/cli.rb +++ b/lib/mxrb/scaffold/cli.rb @@ -31,9 +31,12 @@ def run def generator chain = extract_option('--chain') if @command == 'page' template = extract_option('--template') if @command == 'page' + entity = extract_option('--entity') if @command == 'demo-user' + roles = extract_options('--role') if @command == 'demo-user' Generator.new( kind, scaffold_name, target: target, dry_run: @dry_run, - page_chain: chain, page_template: template + page_chain: chain, page_template: template, + demo_entity: entity, demo_roles: roles ) end @@ -56,6 +59,12 @@ def show_help end def validate_action! + if @command == 'demo-user' + @argv.shift if @argv.first == 'new' + abort Help.text(@command) if @argv.empty? + return + end + action = @argv.shift abort Help.text(@command) unless accepted_actions.include?(action) end @@ -90,6 +99,16 @@ def extract_option(name) @argv.slice!(index, 2).last end + def extract_options(name) + values = [] + while (index = @argv.index(name)) + abort "#{name} requires a value" unless @argv[index + 1] + + values << @argv.slice!(index, 2).last + end + values + end + def kind = @command.tr('-', '_').to_sym def render(result) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength diff --git a/lib/mxrb/scaffold/generator.rb b/lib/mxrb/scaffold/generator.rb index 21c96a4..d5778de 100644 --- a/lib/mxrb/scaffold/generator.rb +++ b/lib/mxrb/scaffold/generator.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'securerandom' + module Mxrb module Scaffold # Creates conventional Ruby-first artifacts and connects missing aggregators. @@ -24,8 +26,7 @@ def initialize(kind, name = nil, target: Dir.pwd, dry_run: false, **page_options @root = File.expand_path(target) @transaction = Transaction.new @dry_run = dry_run - @page_chain = page_options.delete(:page_chain) - @page_template = page_options.delete(:page_template) + assign_options(page_options) raise ArgumentError, "unknown generator options: #{page_options.keys.join(', ')}" unless page_options.empty? end @@ -42,6 +43,13 @@ def scaffold private + def assign_options(options) + @page_chain = options.delete(:page_chain) + @page_template = options.delete(:page_template) + @demo_entity = options.delete(:demo_entity) + @demo_roles = options.delete(:demo_roles) + end + def page_chain return unless @page_chain return @page_chain if PAGE_CHAINS.include?(@page_chain) @@ -55,8 +63,13 @@ def selected_page_template end def stage_registry + files = @transaction.created.dup + if @kind == :demo_user + shared = [project_path('.env'), project_path('.env.example')] + files -= shared + end Registry.stage( - @transaction, root: @root, key: "#{@kind}:#{@name}", files: @transaction.created.dup + @transaction, root: @root, key: "#{@kind}:#{@name}", files: ) end @@ -125,6 +138,115 @@ def connect_project_security(module_name) @transaction.write(path, lines.join) end + def connect_project_demo_users + path = project_path('project.rb') + source = connect_project_dotenv(@transaction.content(path)) + opening = source.lines.index { _1.match?(/^\s*security do\s*$/) } + raise ArgumentError, security_initialization_error unless opening + + insert_demo_user_loader(path, source, opening) + end + + def insert_demo_user_loader(path, source, opening) + line = 'evaluate_dir File.join(__dir__, "app", "security", "demo_users")' + return if source.include?(line) + + lines = source.lines + indentation = "#{lines.fetch(opening)[/^\s*/]} " + lines.insert(opening + 1, "#{indentation}#{line}\n") + @transaction.write(path, lines.join) + end + + def connect_project_dotenv(source) + dotenv_line = 'Dotenv.load(File.join(__dir__, ".env"))' + return source if source.include?(dotenv_line) + + lines = source.lines + require_line = lines.index { _1.match?(%r{^require ["']dotenv/load["']\s*$}) } + raise ArgumentError, 'project.rb must require dotenv/load before scaffolding demo users' unless require_line + + lines.insert(require_line + 1, "#{dotenv_line}\n").join + end + + def security_initialization_error + 'project security is not initialized; run `mxrb security init Module` first' + end + + def create_demo_user_file(name, entity, roles, password_env) + path = project_path('app', 'security', 'demo_users', "#{Templates.snake_case(name)}.rb") + @transaction.create( + path, + Templates.render( + :demo_user, name:, entity:, roles:, password_env: + ) + ) + end + + def ensure_demo_user_secret(password_env) + path = project_path('.env') + source = @transaction.content(path) + if source + @transaction.write(path, append_env(source, password_env, generated_demo_password)) unless + env_key?(source, password_env) + else + create_demo_user_secret(path, password_env) + end + ensure_demo_user_env_example(password_env) + end + + def create_demo_user_secret(path, password_env) + source = "# Local secrets; never commit this file.\n" + content = append_env(source, password_env, generated_demo_password) + @transaction.create(path, content, mode: 0o600) + end + + def ensure_demo_user_env_example(password_env) + path = project_path('.env.example') + source = @transaction.content(path) || "# Copy to .env and keep real values local.\n" + @transaction.write(path, append_env(source, password_env, '')) unless env_key?(source, password_env) + end + + def append_env(source, key, value) + "#{source.rstrip}\n#{key}=#{value}\n" + end + + def env_key?(source, key) + source.lines.any? { _1.match?(/\A#{Regexp.escape(key)}=/) } + end + + def generated_demo_password + "Mxrb#{SecureRandom.alphanumeric(16)}7" + end + + def validate_demo_user_references!(name, entity, roles) + sources = project_ruby_sources + errors = [] + missing_roles = roles - known_user_roles(sources) + errors << "missing user role(s): #{missing_roles.join(', ')}" unless missing_roles.empty? + errors << "missing user entity: #{entity}" unless + entity == 'System.User' || known_entities(sources).include?(entity) + return if errors.empty? + + raise ArgumentError, "demo user #{name}: #{errors.join('; ')}" + end + + def project_ruby_sources + Dir.glob(project_path('**', '*.rb')).to_h { [_1, File.read(_1)] } + end + + def known_user_roles(sources) + pattern = /\buser_role\s+(?::([A-Za-z][A-Za-z0-9_]*)|["']([^"']+)["'])/ + sources.values.flat_map { _1.scan(pattern).map { |match| match.compact.first } }.uniq + end + + def known_entities(sources) + pattern = /\bentity\s+(?::([A-Za-z][A-Za-z0-9_]*)|["']([^"']+)["'])/ + sources.flat_map do |path, source| + mod = path.match(%r{/modules/([^/]+)/})&.[](1) + mod ? source.scan(pattern).map { "#{mod}.#{_1.compact.first}" } : [] + end.uniq + end + def insert_before_closing(source, line, path) lines = source.lines closing = lines.rindex { _1.strip == 'end' } diff --git a/lib/mxrb/scaffold/help.rb b/lib/mxrb/scaffold/help.rb index f76874f..04f96aa 100644 --- a/lib/mxrb/scaffold/help.rb +++ b/lib/mxrb/scaffold/help.rb @@ -23,6 +23,11 @@ module Help 'nanoflow' => ['new Module.Flow', 'Create a client nanoflow', 'presentation/client_actions'], 'repository' => ['new Module.Repository', 'Create repository port and adapter', 'application/infrastructure'], 'security' => ['init Module', 'Create module roles template', 'security'], + 'demo-user' => [ + 'new NAME [--entity Module.Entity] [--role ROLE]', + 'Create a local Mendix demo user backed by an ignored .env secret', + 'app/security/demo_users' + ], 'scheduled-event' => ['new Module.Event', 'Create scheduled event and handler', 'application/jobs'], 'constant' => ['new Module.Constant', 'Create a string constant', 'domain/constants'], 'integration' => ['new Module.Adapter', 'Create integration adapter flow', 'infrastructure/integrations'], @@ -54,6 +59,14 @@ module Help mxrb page templates [--json] --template starter|blank|dashboard|form-vertical HELP + DEMO_USER_OPTIONS = <<~HELP + Demo user options: + --entity Module.Entity User entity (default: System.User) + --role ROLE App user role (repeatable; default: User) + + `new` is optional, so `mxrb demo-user manager ...` is also accepted. + The generated password is stored in the ignored .env file. + HELP def text(command) usage, description, destination = COMMANDS.fetch(command) @@ -71,9 +84,10 @@ def text(command) end def page_options(command) - return '' unless command == 'page' + return PAGE_OPTIONS if command == 'page' + return DEMO_USER_OPTIONS if command == 'demo-user' - PAGE_OPTIONS + '' end end end diff --git a/lib/mxrb/scaffold/recipes.rb b/lib/mxrb/scaffold/recipes.rb index 58d1b14..55f1253 100644 --- a/lib/mxrb/scaffold/recipes.rb +++ b/lib/mxrb/scaffold/recipes.rb @@ -129,6 +129,33 @@ def scaffold_security connect_project_security(module_name) end + def scaffold_demo_user + ensure_project + name = identifier(@name, label: 'demo user') + entity = demo_user_entity + roles = demo_user_roles + validate_demo_user_references!(name, entity, roles) + password_env = "MXRB_DEMO_USER_#{name.gsub(/[^A-Za-z0-9]/, '_').upcase}_PASSWORD" + + connect_project_demo_users + create_demo_user_file(name, entity, roles, password_env) + ensure_demo_user_secret(password_env) unless @dry_run + end + + def demo_user_entity + entity = (@demo_entity || 'System.User').to_s + pattern = /\A[A-Za-z][A-Za-z0-9_]*\.[A-Za-z][A-Za-z0-9_]*\z/ + raise ArgumentError, 'demo user entity must be qualified as Module.Entity' unless entity.match?(pattern) + + entity + end + + def demo_user_roles + roles = Array(@demo_roles) + roles = ['User'] if roles.empty? + roles.map { identifier(_1.to_s, label: 'user role') }.uniq + end + def scaffold_functional_test module_name, artifact_name = qualified_name ensure_project diff --git a/lib/mxrb/scaffold/templates.rb b/lib/mxrb/scaffold/templates.rb index 44e0180..cff26d8 100644 --- a/lib/mxrb/scaffold/templates.rb +++ b/lib/mxrb/scaffold/templates.rb @@ -293,6 +293,17 @@ def project_security(module_name, _name) RUBY end + def demo_user(_module_name, name, entity:, roles:, password_env:) + <<~RUBY + # frozen_string_literal: true + + demo_user #{name.inspect}, + entity: #{entity.inspect}, + roles: #{roles.inspect}, + password: ENV.fetch(#{password_env.inspect}) + RUBY + end + def scheduled_event(module_name, name) <<~RUBY # frozen_string_literal: true diff --git a/lib/mxrb/scaffold/transaction.rb b/lib/mxrb/scaffold/transaction.rb index c803eba..42d45d1 100644 --- a/lib/mxrb/scaffold/transaction.rb +++ b/lib/mxrb/scaffold/transaction.rb @@ -21,11 +21,11 @@ def content(path) nil end - def create(path, content) + def create(path, content, mode: 0o644) abort "#{path}: file already exists" if File.exist?(path) || @changes.key?(path) @created << path - @changes[path] = Change.new(path, content, nil, 0o644) + @changes[path] = Change.new(path, content, nil, mode) end def write(path, new_content) diff --git a/lib/mxrb/version.rb b/lib/mxrb/version.rb index 6223bba..c9f3b72 100644 --- a/lib/mxrb/version.rb +++ b/lib/mxrb/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Mxrb - VERSION = "0.1.1" + VERSION = "0.1.2" end diff --git a/lib/mxrb/writer.rb b/lib/mxrb/writer.rb index 1c3daf6..abd6b0c 100644 --- a/lib/mxrb/writer.rb +++ b/lib/mxrb/writer.rb @@ -441,6 +441,7 @@ def write_project_security(mpr, root_id, security) end { demo_users_enabled: "EnableDemoUsers", + demo_users: "DemoUsers", guest_access_enabled: "EnableGuestAccess", guest_user_role: "GuestUserRole", sign_in_microflow: "SignInMicroflow", @@ -1923,7 +1924,9 @@ def project_security_doc(security) "StrictMode" => false, "StrictPageUrlCheck" => true, "UserRoles" => IO::BsonCodec.build_array(roles, marker: 2), - "DemoUsers" => IO::BsonCodec.build_array([], marker: 2), + "DemoUsers" => IO::BsonCodec.build_array( + Array(security[:demo_users]).map { demo_user_doc(_1) }, marker: 2 + ), "FileDocumentAccess" => access_container("Security$FileDocumentAccessRuleContainer"), "ImageAccess" => access_container("Security$ImageAccessRuleContainer"), "PasswordPolicySettings" => password_policy @@ -1945,6 +1948,17 @@ def user_role_doc(role) } end + def demo_user_doc(user) + { + "$ID" => SecureRandom.uuid, + "$Type" => "Security$DemoUserImpl", + "UserName" => user.fetch(:name), + "Password" => user.fetch(:password), + "Entity" => user.fetch(:entity), + "UserRoles" => IO::BsonCodec.build_array(user.fetch(:roles), marker: 1) + } + end + def module_security_doc(roles, legacy: false) { "$ID" => SecureRandom.uuid, diff --git a/mxrb.gemspec b/mxrb.gemspec index 38cf24a..c613cbf 100644 --- a/mxrb.gemspec +++ b/mxrb.gemspec @@ -2,7 +2,7 @@ Gem::Specification.new do |s| s.name = "mxrb" - s.version = "0.1.1" + s.version = "0.1.2" s.summary = "Pure-Ruby read/write engine for Mendix .mpr projects — no mxcli required" s.description = "mxrb reads and writes Mendix .mpr files (SQLite3) directly, providing a Ruby DSL to define entities, pages, microflows and modules without any dependency on the official mxcli tooling." s.authors = ["Lucas Moura"] diff --git a/spec/contracts_spec.rb b/spec/contracts_spec.rb index b7e5a88..b9a7e94 100644 --- a/spec/contracts_spec.rb +++ b/spec/contracts_spec.rb @@ -77,6 +77,87 @@ def define_contract_project(path) end end + it "writes, validates, and exports individual demo users" do + Dir.mktmpdir do |dir| + source = File.join(dir, 'demo.mpr') + rebuilt = File.join(dir, 'rebuilt.mpr') + exported = File.join(dir, 'exported') + Mxrb.define(source) do + mendix_version '11.12.1' + self.module(:App) { module_role :User } + security do + user_role :User, module_roles: ['App.User'] + demo_user 'manager', entity: 'System.User', roles: ['User'], password: 'MxrbDemo123' + end + end + + mpr = Mxrb::IO::MprFile.open(source, readonly: true) + security = mpr.all_units.map { mpr.parse_contents(_1) } + .find { _1['$Type'] == 'Security$ProjectSecurity' } + users = Mxrb::IO::BsonCodec.parse_array(security['DemoUsers']).fetch(:items) + expect(security['EnableDemoUsers']).to be true + expect(users.fetch(0)).to include( + '$Type' => 'Security$DemoUserImpl', 'UserName' => 'manager', + 'Entity' => 'System.User', 'Password' => 'MxrbDemo123' + ) + expect(Mxrb::IO::BsonCodec.parse_array(users.fetch(0)['UserRoles']).fetch(:items)) + .to eq(['User']) + mpr.close + + Mxrb::Exporter.new(source, exported).export! + security_source = File.read(File.join(exported, 'app', 'security', 'security.rb')) + expect(security_source).to include( + 'demo_user "manager"', 'entity: "System.User"', 'roles: ["User"]' + ) + begin + ENV['MXRB_OUTPUT_PATH'] = rebuilt + load File.join(exported, 'project.rb') + ensure + ENV.delete('MXRB_OUTPUT_PATH') + end + expect(Mxrb.compare(source, rebuilt)).to be_identical + end + end + + it "rejects invalid and duplicate demo-user references" do + invalid_arguments = [ + ['', 'System.User', ['User'], 'secret', /name must not be empty/], + ['bad name', 'System.User', ['User'], 'secret', /must not contain whitespace/], + ['manager', 'SystemUser', ['User'], 'secret', /qualified as Module.Entity/], + ['manager', 'System.User', [], 'secret', /at least one role/], + ['manager', 'System.User', ['User'], '', /password must not be empty/] + ] + invalid_arguments.each do |name, entity, roles, password, message| + expect do + Mxrb::Dsl::SecurityBuilder.new.demo_user(name, entity:, roles:, password:) + end.to raise_error(ArgumentError, message) + end + expect { Mxrb::Dsl::SecurityBuilder.new.evaluate_dir('/missing/demo-users') } + .not_to raise_error + + expect do + Mxrb::Dsl::Builder.new('/tmp/invalid-demo.mpr').tap do |builder| + builder.module(:App) { entity :Account } + builder.security do + user_role :User + demo_user 'manager', entity: 'App.Missing', roles: ['Manager'], password: 'secret' + end + builder.validate! + end + end.to raise_error(Mxrb::ValidationError, /missing user role.*Manager.*missing user entity/m) + + expect do + Mxrb::Dsl::Builder.new('/tmp/duplicate-demo.mpr').tap do |builder| + builder.module(:App) + builder.security do + user_role :User + 2.times { demo_user 'manager', entity: 'System.User', roles: ['User'], password: 'secret' } + end + builder.validate! + end + end.to raise_error(Mxrb::ValidationError, /duplicate demo user/) + end + it "round-trips navigation and design-system contracts through exported Ruby" do Dir.mktmpdir do |dir| source_dir = File.join(dir, "source") diff --git a/spec/database_workspace_spec.rb b/spec/database_workspace_spec.rb index 188370c..0f6a399 100644 --- a/spec/database_workspace_spec.rb +++ b/spec/database_workspace_spec.rb @@ -143,6 +143,72 @@ def workspace(path, state, &runner) credentials = File.join(info.state_dir, 'credentials.json') expect(File.stat(credentials).mode & 0o777).to eq(0o600) expect(File).to exist(File.join(info.state_dir, 'runtime.json')) + + credentials = workspace(path, File.join(dir, 'second-state'), &runner) + expect { credentials.admin_credentials }.to raise_error(Mxrb::ToolchainError, /db up/) + end + end + + it 'reads the configured Runtime administrator without exposing database passwords' do + Dir.mktmpdir do |dir| + path = make_project(dir) + state = File.join(dir, 'state') + FileUtils.mkdir_p(state) + credentials_path = File.join(state, 'credentials.json') + File.write( + credentials_path, + JSON.generate( + 'owner_password' => 'owner', 'reader_password' => 'reader', + 'admin_password' => 'runtime-admin-secret' + ) + ) + result = workspace(path, state) { |_command| ['', '', status(true)] }.admin_credentials + + expect(result.username).to eq('MxAdmin') + expect(result.password).to eq('runtime-admin-secret') + expect(result.path).to eq(credentials_path) + + File.write(credentials_path, '{}') + expect { workspace(path, state) { ['', '', status(true)] }.admin_credentials } + .to raise_error(Mxrb::ToolchainError, /invalid Runtime credentials file/) + + File.write(credentials_path, JSON.generate('admin_password' => 'secret')) + mpr = Mxrb::IO::MprFile.open(path) + raw = mpr.all_units.find { mpr.parse_contents(_1)['$Type'] == 'Security$ProjectSecurity' } + security = mpr.parse_contents(raw) + security['AdminUserName'] = '' + mpr.update_unit(raw.fetch('UnitID'), security) + mpr.close + expect { workspace(path, state) { ['', '', status(true)] }.admin_credentials } + .to raise_error(Mxrb::ToolchainError, /no configured administrator/) + end + end + + it 'copies a secret through clipboard stdin instead of command arguments' do + Dir.mktmpdir do |dir| + executable = File.join(dir, 'wl-copy') + File.write(executable, '') + File.chmod(0o700, executable) + calls = [] + runner = lambda do |command, input| + calls << [command, input] + ['', '', status(true)] + end + + command = Mxrb::Runtime::Clipboard.new(path: dir, runner:).copy('secret') + expect(command).to eq('wl-copy') + expect(calls).to eq([[['wl-copy'], 'secret']]) + + failing = ->(_command, _input) { ['', 'clipboard unavailable', status(false)] } + expect { Mxrb::Runtime::Clipboard.new(path: dir, runner: failing).copy('secret') } + .to raise_error(Mxrb::ToolchainError, /clipboard command failed/) + + File.write(executable, "#!/bin/sh\nexit 0\n") + expect(Mxrb::Runtime::Clipboard.new(path: dir).copy('captured')).to eq('wl-copy') + + File.chmod(0o600, executable) + expect { Mxrb::Runtime::Clipboard.new(path: dir).copy('secret') } + .to raise_error(Mxrb::ToolchainError, /no clipboard command found/) end end diff --git a/spec/scaffold_spec.rb b/spec/scaffold_spec.rb index 853628a..dd0c441 100644 --- a/spec/scaffold_spec.rb +++ b/spec/scaffold_spec.rb @@ -85,6 +85,97 @@ def scaffold(root, kind, name = nil) end end + it 'scaffolds a demo user with a local secret and validates model references' do + Dir.mktmpdir do |dir| + root = project_in(dir) + scaffold(root, :security, 'ScaffoldApp') + result = described_class.new( + :demo_user, 'manager', target: root, + demo_entity: 'System.User', demo_roles: ['User'] + ).scaffold + user_file = File.join(root, 'app', 'security', 'demo_users', 'manager.rb') + env_file = File.join(root, '.env') + + expect(result.files).to include(user_file, env_file) + expect(File.read(user_file)).to include( + 'demo_user "manager"', 'entity: "System.User"', + 'roles: ["User"]', 'ENV.fetch("MXRB_DEMO_USER_MANAGER_PASSWORD")' + ) + expect(File.read(user_file)).not_to include(File.read(env_file).split('=').last.strip) + expect(File.stat(env_file).mode & 0o777).to eq(0o600) + expect(File.read(File.join(root, '.env.example'))) + .to include('MXRB_DEMO_USER_MANAGER_PASSWORD=') + expect(File.read(File.join(root, 'project.rb'))) + .to include('evaluate_dir File.join(__dir__, "app", "security", "demo_users")') + + Dir.chdir(root) { load File.join(root, 'project.rb') } + mpr = Mxrb::IO::MprFile.open(File.join(root, 'ScaffoldApp.mpr'), readonly: true) + security = mpr.all_units.map { mpr.parse_contents(_1) } + .find { _1['$Type'] == 'Security$ProjectSecurity' } + users = Mxrb::IO::BsonCodec.parse_array(security['DemoUsers']).fetch(:items) + expect(users.map { _1['UserName'] }).to eq(['manager']) + mpr.close + + expect do + described_class.new( + :demo_user, 'broken', target: root, + demo_entity: 'Administration.Account', demo_roles: ['Manager'] + ).scaffold + end.to raise_error(ArgumentError, /missing user role.*missing user entity/) + + File.open(File.join(root, '.env.example'), 'a') do |file| + file.puts 'MXRB_DEMO_USER_OPERATOR_PASSWORD=' + end + File.open(env_file, 'a') do |file| + file.puts 'MXRB_DEMO_USER_OPERATOR_PASSWORD=ExistingSecret7' + end + operator = described_class.new(:demo_user, 'operator', target: root).scaffold + expect(operator.files).to include( + File.join(root, 'app', 'security', 'demo_users', 'operator.rb') + ) + + expect do + described_class.new( + :demo_user, 'invalid', target: root, + demo_entity: 'SystemUser', demo_roles: ['User'] + ).scaffold + end.to raise_error(ArgumentError, /qualified as Module.Entity/) + end + end + + it 'repairs legacy dotenv loading and diagnoses incomplete demo-user projects' do + Dir.mktmpdir do |dir| + root = project_in(dir) + scaffold(root, :security, 'ScaffoldApp') + project = File.join(root, 'project.rb') + File.write( + project, + File.read(project).sub(%{Dotenv.load(File.join(__dir__, ".env"))\n}, '') + ) + File.write(File.join(root, '.env'), "EXISTING=value\n") + described_class.new(:demo_user, 'legacy', target: root).scaffold + expect(File.read(project)).to include('Dotenv.load(File.join(__dir__, ".env"))') + + incomplete = File.join(dir, 'incomplete') + FileUtils.mkdir_p(incomplete) + no_security = project_in(incomplete) + scaffold(no_security, :security, 'ScaffoldApp') + incomplete_project = File.join(no_security, 'project.rb') + File.write(incomplete_project, File.read(incomplete_project).sub(' security do', ' if false')) + expect do + described_class.new(:demo_user, 'missing', target: no_security).scaffold + end.to raise_error(ArgumentError, /security is not initialized/) + + source = File.read(project) + source = source.sub(%r{^require ["']dotenv/load["']\n}, '') + .sub(%{Dotenv.load(File.join(__dir__, ".env"))\n}, '') + File.write(project, source) + expect do + described_class.new(:demo_user, 'dotenv', target: root).scaffold + end.to raise_error(ArgumentError, %r{must require dotenv/load}) + end + end + it 'scaffolds an executable page to nanoflow to microflow chain' do Dir.mktmpdir do |dir| root = project_in(dir) @@ -573,6 +664,33 @@ def scaffold(root, kind, name = nil) end end + it 'accepts demo-user with or without the new action and repeatable roles' do + Dir.mktmpdir do |dir| + root = Mxrb::Initializer.new('cli_app').scaffold(into: dir).root + Mxrb::Scaffold::Generator.new(:security, 'CliApp', target: root).scaffold + output = StringIO.new + described_class.new( + 'demo-user', ['manager', '--entity', 'System.User', '--role', 'User', + '--target', root], output: + ).run + expect(output.string).to include('manager.rb', '.env') + + preview = StringIO.new + described_class.new( + 'demo-user', ['new', 'operator', '--role', 'User', '--target', root, '--dry-run'], + output: preview + ).run + expect(preview.string).to include('would create', 'operator.rb') + + expect do + described_class.new('demo-user', [], output: StringIO.new).run + end.to raise_error(SystemExit, /Usage: mxrb demo-user/) + expect do + described_class.new('demo-user', ['broken', '--role'], output: StringIO.new).run + end.to raise_error(SystemExit, /--role requires a value/) + end + end + it 'accepts new, generate, and g plus every chain for the page scaffold' do Dir.mktmpdir do |dir| root = Mxrb::Initializer.new('cli_app').scaffold(into: dir).root @@ -633,6 +751,17 @@ def scaffold(root, kind, name = nil) end end + it 'reports the public CLI version with both standard flags' do + executable = [RbConfig.ruby, File.expand_path('../bin/mxrb', __dir__)] + %w[--version -v].each do |flag| + stdout, stderr, status = Open3.capture3(*executable, flag) + + expect(status).to be_success + expect(stderr).to be_empty + expect(stdout).to eq("mxrb #{Mxrb::VERSION}\n") + end + end + it 'covers help placement and reports invalid command arguments' do output = StringIO.new described_class.new('entity', ['new', '--help'], output:).run