diff --git a/.github/workflows/dart-tests.yml b/.github/workflows/dart-tests.yml new file mode 100644 index 0000000..7962f02 --- /dev/null +++ b/.github/workflows/dart-tests.yml @@ -0,0 +1,26 @@ +name: Dart Tests +on: + push: + branches: + - main + pull_request: +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: dart-lang/setup-dart@v1 + with: + sdk: 3.4.0 + - name: Cache Dart dependencies + uses: actions/cache@v2 + with: + path: ~/.pub-cache + key: ${{ runner.os }}-dart-${{ hashFiles('**/pubspec.lock') }} + restore-keys: | + ${{ runner.os }}-dart- + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true # Runs 'bundle install' and caches installed gems automatically + - name: Generate Dart Code & Run Tests + run: bundle exec rake test_dart diff --git a/Gemfile.lock b/Gemfile.lock index f8b2c64..f5226a0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -33,7 +33,7 @@ GEM rspec-mocks (~> 3.13.0) rspec-core (3.13.0) rspec-support (~> 3.13.0) - rspec-expectations (3.13.0) + rspec-expectations (3.13.1) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-mocks (3.13.1) diff --git a/Rakefile b/Rakefile index 5f46fd7..da1677c 100644 --- a/Rakefile +++ b/Rakefile @@ -39,6 +39,20 @@ task :test_kotlin do end end +desc "Generates dart source code and run its unit tests." +task :test_dart do + config_file = File.absolute_path("spec/fixtures/dart-tests.yml") + dotenv_file = File.absolute_path("spec/fixtures/.env.fruitloops") + directory_to_copy = File.absolute_path("spec/fixtures/dart") + with_temp_dir do |temp_dir| + puts "Current working directory: #{temp_dir}" + FileUtils.copy_entry(directory_to_copy, "tests") + Dir.chdir("tests") + sh("ARKANA_RUNNING_CI_INTEGRATION_TESTS=true && arkana --lang dart --config-filepath #{config_file} --dotenv-filepath #{dotenv_file} --include-environments dev,staging") + sh("dart test") + end +end + desc "Sets lib version to the semantic version given, and push it to remote." task :bump, [:v] do |_t, args| version = args[:v] || raise("A version is required. Pass it like `rake bump[1.2.3]`") diff --git a/lib/arkana.rb b/lib/arkana.rb index 589754a..10cb315 100644 --- a/lib/arkana.rb +++ b/lib/arkana.rb @@ -8,6 +8,7 @@ require_relative "arkana/salt_generator" require_relative "arkana/swift_code_generator" require_relative "arkana/kotlin_code_generator" +require_relative "arkana/dart_code_generator" require_relative "arkana/version" # Top-level namespace for Arkana's execution entry point. When ran from CLI, `Arkana.run` is what is invoked. @@ -48,6 +49,7 @@ def self.run(arguments) generator = case config.current_lang.downcase when "swift" then SwiftCodeGenerator when "kotlin" then KotlinCodeGenerator + when "dart" then DartCodeGenerator else UI.crash("Unknown output lang selected: #{config.current_lang}") end diff --git a/lib/arkana/dart_code_generator.rb b/lib/arkana/dart_code_generator.rb new file mode 100644 index 0000000..51b5dc7 --- /dev/null +++ b/lib/arkana/dart_code_generator.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "erb" unless defined?(Erb) +require "fileutils" unless defined?(FileUtils) +require_relative "helpers/string" + +# Responsible for generating Dart source and test files. +module DartCodeGenerator + # Generates Dart code and test files for the given template arguments. + def self.generate(template_arguments:, config:) + dart_sources_dir = File.join("lib", config.result_path.downcase) + dart_tests_dir = File.join("test", config.result_path.downcase) + set_up_dart_interfaces(dart_sources_dir, template_arguments, config) + set_up_dart_classes(dart_sources_dir, dart_tests_dir, template_arguments, config) + end + + def self.set_up_dart_interfaces(path, template_arguments, config) + dirname = File.dirname(__FILE__) + sources_dir = path + source_template = File.read("#{dirname}/templates/dart/arkana_protocol.dart.erb") + FileUtils.mkdir_p(path) + render(source_template, template_arguments, File.join(sources_dir, "#{config.namespace.downcase}_environment.dart")) + end + + def self.set_up_dart_classes(sources_dir, tests_dir, template_arguments, config) + dirname = File.dirname(__FILE__) + source_template = File.read("#{dirname}/templates/dart/arkana.dart.erb") + tests_template = File.read("#{dirname}/templates/dart/arkana_tests.dart.erb") + FileUtils.mkdir_p(sources_dir) + if config.should_generate_unit_tests + FileUtils.mkdir_p(tests_dir) + end + render(source_template, template_arguments, File.join(sources_dir, "#{config.namespace.downcase}.dart")) + return unless config.should_generate_unit_tests + render(tests_template, template_arguments, File.join(tests_dir, "#{config.namespace.downcase}_test.dart")) + end + + def self.render(template, template_arguments, destination_file) + renderer = ERB.new(template, trim_mode: ">") # Don't automatically add newlines at the end of each template tag + result = renderer.result(template_arguments.get_binding) + File.write(destination_file, result) + end +end diff --git a/lib/arkana/helpers/dart_template_helper.rb b/lib/arkana/helpers/dart_template_helper.rb new file mode 100644 index 0000000..c1efca8 --- /dev/null +++ b/lib/arkana/helpers/dart_template_helper.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Utilities to reduce the amount of boilerplate code in `.dart.erb` template files. +module DartTemplateHelper + def self.dart_type(type) + case type + when :string then "String" + when :boolean then "bool" + when :integer then "int" + else raise "Unknown variable type '#{type}' received." + end + end + + def self.dart_decode_function(type) + case type + when :string then "decode" + when :boolean then "decodeBoolean" + when :integer then "decodeInt" + else raise "Unknown variable type '#{type}' received." + end + end + + def self.relative_path_to_source(src) + slash_count = src.count("/") + padding = src.empty? ? 1 : 2 + "../" * (slash_count + padding) + end +end diff --git a/lib/arkana/models/template_arguments.rb b/lib/arkana/models/template_arguments.rb index 514c330..9bbd439 100644 --- a/lib/arkana/models/template_arguments.rb +++ b/lib/arkana/models/template_arguments.rb @@ -20,6 +20,8 @@ def initialize(environment_secrets:, global_secrets:, config:, salt:) @pod_name = config.pod_name # The top level namespace in which the keys will be generated. Often an enum. @namespace = config.namespace + # Dart sources Path + @result_path = config.result_path # Name of the kotlin package to be used for the generated code. @kotlin_package_name = config.kotlin_package_name # The kotlin JVM toolchain JDK version to be used in the generated build.gradle file. diff --git a/lib/arkana/templates/dart/arkana.dart.erb b/lib/arkana/templates/dart/arkana.dart.erb new file mode 100644 index 0000000..6aac9ba --- /dev/null +++ b/lib/arkana/templates/dart/arkana.dart.erb @@ -0,0 +1,67 @@ +<% require "arkana/helpers/string" %> +<% require "arkana/helpers/dart_template_helper" %> +import '<%=(@namespace).downcase%>_environment.dart'; + +// DO NOT MODIFY +// Automatically generated by Arkana (https://github.com/rogerluan/arkana) +const _salt = [<%= @salt.formatted %>]; + +class <%= @namespace %> { + + static final Global global = Global(); + +<% for environment in @environments %> + static final <%= @namespace %>Environment <%=environment.camel_case()%> = _<%= environment %>(); + +<% end %> + static String decode({required List encoded, required List cipher}) { + var index = -1; + final decoded = encoded.map((item) => + (item ^ cipher[++index % cipher.length]) & 0xff + ).toList(); + return String.fromCharCodes(decoded); + } + + static int decodeInt({required List encoded, required List cipher}) { + return int.parse(decode(encoded: encoded, cipher: cipher)); + } + + static bool decodeBoolean({required List encoded, required List cipher}) { + return decode(encoded: encoded, cipher: cipher) == 'true'; + } + +} + +class Global { + +<% @global_secrets.each_with_index do |secret, index| %> + <%= DartTemplateHelper.dart_type(secret.type) %> get <%= secret.key.camel_case %> { + final encoded = [<%= secret.encoded_value %>]; + return <%= @namespace %>.<%= DartTemplateHelper.dart_decode_function(secret.type) %>(encoded: encoded, cipher: _salt); + } +<% unless index == @global_secrets.length - 1 %> + +<% end %> +<% end %> + +} + +<% @environments.each_with_index do |environment, env_index| %> +class _<%= environment %> implements <%= @namespace %>Environment { + +<% environment_protocol_secrets(environment).each_with_index do |secret, secret_index| %> + @override + <%= DartTemplateHelper.dart_type(secret.type) %> get <%= secret.protocol_key.camel_case %> { + final encoded = [<%= secret.encoded_value %>]; + return <%= @namespace %>.<%= DartTemplateHelper.dart_decode_function(secret.type) %>(encoded: encoded, cipher: _salt); + } +<% unless secret_index == environment_protocol_secrets(environment).length - 1 %> + +<% end %> +<% end %> + +} +<% unless env_index == @environments.length - 1 %> + +<% end %> +<% end %> diff --git a/lib/arkana/templates/dart/arkana_protocol.dart.erb b/lib/arkana/templates/dart/arkana_protocol.dart.erb new file mode 100644 index 0000000..a29ff45 --- /dev/null +++ b/lib/arkana/templates/dart/arkana_protocol.dart.erb @@ -0,0 +1,12 @@ +<% require "arkana/helpers/string" %> +<% require "arkana/helpers/dart_template_helper" %> +// DO NOT MODIFY +// Automatically generated by Arkana (https://github.com/rogerluan/arkana) + +abstract class <%= @namespace %>Environment { + + <% for secret in @environment_secrets.uniq(&:protocol_key) %> + <%=DartTemplateHelper.dart_type(secret.type)%> get <%= secret.protocol_key.camel_case %>; + <% end %> + +} \ No newline at end of file diff --git a/lib/arkana/templates/dart/arkana_tests.dart.erb b/lib/arkana/templates/dart/arkana_tests.dart.erb new file mode 100644 index 0000000..f092fff --- /dev/null +++ b/lib/arkana/templates/dart/arkana_tests.dart.erb @@ -0,0 +1,120 @@ +<% require "arkana/helpers/string" %> +<% require "arkana/helpers/dart_template_helper" %> +// DO NOT MODIFY +// Automatically generated by Arkana (https://github.com/rogerluan/arkana) + + +import 'package:test/test.dart'; +import '<%=DartTemplateHelper.relative_path_to_source(@result_path.downcase)%>lib/<%= @result_path.downcase%>/<%= @namespace.downcase %>.dart'; + +void main(){ + const List salt = [<%= @salt.formatted %>]; + + test("decodeRandomHexKey_shouldDecode", () { + <% hex_key = SecureRandom.hex(64) %> + <% secret = generate_test_secret(key: hex_key) %> + const encoded = [<%= secret.encoded_value %>]; + expect(<%= @namespace %>.decode(encoded: encoded, cipher: salt), "<%= hex_key %>"); + }); + + test("decodeRandomBase64Key_shouldDecode", () { + <% base64_key = SecureRandom.base64(64) %> + <% secret = generate_test_secret(key: base64_key) %> + const encoded = [<%= secret.encoded_value %>]; + expect(<%= @namespace %>.decode(encoded: encoded, cipher: salt), "<%= base64_key %>"); + }); + + test("decodeUUIDKey_shouldDecode", () { + <% uuid_key = SecureRandom.uuid %> + <% secret = generate_test_secret(key: uuid_key) %> + const encoded = [<%= secret.encoded_value %>]; + expect(<%= @namespace %>.decode(encoded: encoded, cipher: salt), "<%= uuid_key %>"); + }); + + test("decodeTrueBoolValue_shouldDecode", () { + <% bool_key = "true" %> + <% secret = generate_test_secret(key: bool_key) %> + const encoded = [<%= secret.encoded_value %>]; + assert(<%= @namespace %>.decodeBoolean(encoded: encoded, cipher: salt)); + }); + + test("decodeFalseBoolValue_shouldDecode", () { + <% bool_key = "false" %> + <% secret = generate_test_secret(key: bool_key) %> + const encoded = [<%= secret.encoded_value %>]; + assert(!<%= @namespace %>.decodeBoolean(encoded: encoded, cipher: salt)); + }); + + test("decodeIntValue_shouldDecode", () { + <% int_key = "42" %> + <% secret = generate_test_secret(key: int_key) %> + const encoded = [<%= secret.encoded_value %>]; + expect(<%= @namespace %>.decodeInt(encoded: encoded, cipher: salt), 42); + }); + + test("decodeIntValueWithLeadingZeroes_shouldDecodeAsString", () { + <% int_with_leading_zeroes_key = "0001" %> + <% secret = generate_test_secret(key: int_with_leading_zeroes_key) %> + const encoded = [<%= secret.encoded_value %>]; + expect(<%= @namespace %>.decode(encoded: encoded, cipher: salt), "0001"); + }); + + test("decodeMassiveIntValue_shouldDecodeAsString", () { + <% int_with_massive_number_key = "92233720368547758079223372036854775807" %> + <% secret = generate_test_secret(key: int_with_massive_number_key) %> + const encoded = [<%= secret.encoded_value %>]; + expect(<%= @namespace %>.decode(encoded: encoded, cipher: salt), "92233720368547758079223372036854775807"); + }); + + test("decodeNegativeIntValue_shouldDecodeAsString", () { + <% negative_int_key = "-42" %> + <% secret = generate_test_secret(key: negative_int_key) %> + const encoded = [<%= secret.encoded_value %>]; + expect(<%= @namespace %>.decode(encoded: encoded, cipher: salt), "-42"); + }); + + test("decodeFloatingPointValue_shouldDecodeAsString", () { + <% float_key = "3.14" %> + <% secret = generate_test_secret(key: float_key) %> + const encoded = [<%= secret.encoded_value %>]; + expect(<%= @namespace %>.decode(encoded: encoded, cipher: salt), "3.14"); + }); + + test("encodeAndDecodeValueWithDollarSign_shouldDecode", () { + <% dollar_sign_key = "real_$lim_shady" %> + <% secret = generate_test_secret(key: dollar_sign_key) %> + const encoded = [<%= secret.encoded_value %>]; + expect(<%= @namespace %>.decode(encoded: encoded, cipher: salt), "real_\$lim_shady"); + }); + + +<% if ENV["ARKANA_RUNNING_CI_INTEGRATION_TESTS"] %> + const globalSecrets = <%= @namespace %>.Global; + + test("decodeEnvVarFromDotfile_withDollarSign__andEscaped_andNoQuotes_shouldDecode", () { + expect(globalSecrets.secretWithDollarSignEscapedAndAndNoQuotesKey, "real_\$lim_shady"); + }); + + test("decodeEnvVarFromDotfile_withDollarSign__andEscaped_andDoubleQuotes_shouldDecode", () { + expect(globalSecrets.secretWithDollarSignEscapedAndDoubleQuoteKey, "real_\$lim_shady"); + }); + + test("decodeEnvVarFromDotfile_withDollarSign__andNotEscaped_andSingleQuotes_shouldDecode", () { + expect(globalSecrets.secretWithDollarSignNotEscapedAndSingleQuoteKey, "real_\$lim_shady"); + }); + + test("decodeEnvVarFromDotfile_withDollarSign__andNotEscaped_andDoubleQuotes_shouldDecodeWithUnexpectedValue", () { + expect(globalSecrets.secretWithDollarSignNotEscapedAndDoubleQuotesKey, "real_\$lim_shady"); + }); + + test("test_decodeEnvVarFromDotfile_withDollarSign__andNotEscaped_andNoQuotes_shouldDecodeWithUnexpectedValue", () { + expect(globalSecrets.secretWithDollarSignNotEscapedAndNoQuotesKey, "real_\$lim_shady"); + }); + + test("test_decodeEnvVarFromDotfile_withWeirdCharacters_shouldDecode", () { + expect(globalSecrets.secretWithWeirdCharactersKey, "` ~ ! @ # % ^ & * ( ) _ - + = { [ } } | : ; ' < , > . ? /"); + }); + +<% end %> +} + diff --git a/spec/dart_code_generator_spec.rb b/spec/dart_code_generator_spec.rb new file mode 100644 index 0000000..50a18b3 --- /dev/null +++ b/spec/dart_code_generator_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +RSpec.describe DartCodeGenerator do + let(:config) { Config.new(YAML.load_file("spec/fixtures/arkana-fixture.yml")) } + let(:salt) { SaltGenerator.generate } + let(:environment_secrets) do + Encoder.encode!( + keys: config.environment_keys, + salt: salt, + current_flavor: config.current_flavor, + environments: config.environments, + ) + end + + let(:global_secrets) do + Encoder.encode!( + keys: config.global_secrets, + salt: salt, + current_flavor: config.current_flavor, + environments: config.environments, + ) + end + + let(:template_arguments) do + TemplateArguments.new( + environment_secrets: environment_secrets, + global_secrets: global_secrets, + config: config, + salt: salt, + ) + end + + before do + config.all_keys.each do |key| + allow(ENV).to receive(:[]).with(key).and_return("value") + end + allow(ENV).to receive(:[]).with("ARKANA_RUNNING_CI_INTEGRATION_TESTS").and_return(true) + end + + after do + FileUtils.rm_rf(File.join("lib", config.result_path)) + FileUtils.rm_rf(File.join("test")) + end + + describe ".generate" do + let(:dart_module_dir) { config.result_path.downcase } + let(:dart_sources_dir) { File.join("lib", dart_module_dir) } + let(:dart_tests_dir) { File.join("test", dart_module_dir) } + + def path(...) + Pathname.new(File.join(...)) + end + + it "generates all necessary directories and files" do + described_class.generate(template_arguments: template_arguments, config: config) + expect(path(dart_sources_dir, "#{config.namespace.downcase}_environment.dart")).to be_file + expect(path(dart_sources_dir, "#{config.namespace.downcase}.dart")).to be_file + end + + context "when 'config.should_generate_unit_tests' is true" do + before do + allow(config).to receive(:should_generate_unit_tests).and_return(true) + described_class.generate(template_arguments: template_arguments, config: config) + end + + it "generates test folder and files" do + expect(path(dart_tests_dir, "#{config.namespace.downcase}_test.dart")).to be_file + end + end + + context "when 'config.should_generate_unit_tests' is false" do + before do + allow(config).to receive(:should_generate_unit_tests).and_return(false) + described_class.generate(template_arguments: template_arguments, config: config) + end + + it "does not generate test folder or files" do + expect(path(dart_tests_dir, "#{config.namespace.downcase}_test.dart")).not_to be_file + expect(Pathname.new(dart_tests_dir)).not_to be_directory + end + end + end +end diff --git a/spec/fixtures/dart-tests.yml b/spec/fixtures/dart-tests.yml new file mode 100644 index 0000000..52d5574 --- /dev/null +++ b/spec/fixtures/dart-tests.yml @@ -0,0 +1,32 @@ +import_name: 'MySecrets' +namespace: 'MySecrets' +result_path: 'tests' +should_generate_unit_tests: true +global_secrets: + - BoolAsStringTrueKey + - BoolAsStringFalseKey + - BoolAsBoolTrueKey + - BoolAsBoolFalseKey + - IntAsStringKey + - IntAsNumberKey + - IntWithLeadingZeroesAsStringKey + - IntWithLeadingZeroesAsNumberKey + - MassiveIntAsStringKey + - MassiveIntAsNumberKey + - NegativeIntAsStringKey + - NegativeIntAsNumberKey + - FloatAsStringKey + - FloatAsNumberKey + - SecretWithDollarSignEscapedAndAndNoQuotesKey + - SecretWithDollarSignEscapedAndDoubleQuoteKey + - SecretWithDollarSignNotEscapedAndSingleQuoteKey + - SecretWithDollarSignNotEscapedAndDoubleQuotesKey + - SecretWithDollarSignNotEscapedAndNoQuotesKey + - SecretWithWeirdCharactersKey +environments: + - dev + - staging + - prod +environment_secrets: + - ServiceKey + - Server diff --git a/spec/fixtures/dart/pubspec.yaml b/spec/fixtures/dart/pubspec.yaml new file mode 100644 index 0000000..c96adaf --- /dev/null +++ b/spec/fixtures/dart/pubspec.yaml @@ -0,0 +1,9 @@ +name: sample +description: A minimal sample +version: 1.0.0 + +environment: + sdk: ^3.4.0 + +dev_dependencies: + test: ^1.25.7 \ No newline at end of file