diff --git a/lib/kamal/cli/secrets.rb b/lib/kamal/cli/secrets.rb index 76394184..e5c3b7d5 100644 --- a/lib/kamal/cli/secrets.rb +++ b/lib/kamal/cli/secrets.rb @@ -2,21 +2,39 @@ class Kamal::Cli::Secrets < Kamal::Cli::Base desc "fetch [SECRETS...]", "Fetch secrets from a vault" option :adapter, type: :string, aliases: "-a", required: true, desc: "Which vault adapter to use" option :account, type: :string, required: true, desc: "The account identifier or username" - option :location, type: :string, required: false, desc: "A vault or folder to fetch the secrets from" + option :from, type: :string, required: false, desc: "A vault or folder to fetch the secrets from" def fetch(*secrets) - ENV["KAMAL_SECRETS_KILL_PARENT"] = "1" - - results = adapter(options[:adapter]).fetch(secrets, **options.slice(:account, :location).symbolize_keys) + results = adapter(options[:adapter]).fetch(secrets, **options.slice(:account, :from).symbolize_keys) puts JSON.dump(results).shellescape + rescue => e + handle_error(e) end desc "extract", "Extract a single secret from the results of a fetch call" def extract(name, secrets) + parsed_secrets = JSON.parse(secrets) + + if (value = parsed_secrets[name]).nil? + value = parsed_secrets.find { |k, v| k.end_with?("/#{name}") }&.last + end + + raise "Could not find secret #{name}" if value.nil? + puts JSON.parse(secrets).fetch(name) + rescue => e + handle_error(e) end private def adapter(adapter) Kamal::Secrets::Adapters.lookup(adapter) end + + def handle_error(e) + $stderr.puts " \e[31mERROR (#{e.class}): #{e.message}\e[0m" + $stderr.puts e.backtrace if ENV["VERBOSE"] + + Process.kill("INT", Process.ppid) if ENV["KAMAL_SECRETS_INT_PARENT"] + exit 1 + end end diff --git a/lib/kamal/secrets.rb b/lib/kamal/secrets.rb index e195cc37..5c15bc9b 100644 --- a/lib/kamal/secrets.rb +++ b/lib/kamal/secrets.rb @@ -6,7 +6,10 @@ class Kamal::Secrets end def [](key) - @secrets ||= secrets_file ? Dotenv.parse(*secrets_file) : {} + # If dot env interpolates any `kamal secrets` calls, this tells it to interrupt this process if there are errors + ENV["KAMAL_SECRETS_INT_PARENT"] = "1" + + @secrets ||= secrets_file ? Dotenv.parse(secrets_file) : {} @secrets.fetch(key) rescue KeyError if secrets_file @@ -15,4 +18,20 @@ class Kamal::Secrets raise Kamal::ConfigurationError, "Secret '#{key}' not found, no secret files provided" end end + + private + def parse_secrets + if secrets_file + interrupting_parent_on_error { Dotenv.parse(secrets_file) } + else + {} + end + end + + def interrupting_parent_on_error + ENV["KAMAL_SECRETS_INT_PARENT"] = "1" + yield + ensure + ENV.delete("KAMAL_SECRETS_INT_PARENT") + end end diff --git a/lib/kamal/secrets/adapters/base.rb b/lib/kamal/secrets/adapters/base.rb index 9432913d..93ddca47 100644 --- a/lib/kamal/secrets/adapters/base.rb +++ b/lib/kamal/secrets/adapters/base.rb @@ -1,15 +1,15 @@ class Kamal::Secrets::Adapters::Base delegate :optionize, to: Kamal::Utils - def fetch(secrets, account:, location: nil) + def fetch(secrets, account:, from: nil) session = login(account) - full_secrets = secrets.map { |secret| [ location, secret ].compact.join("/") } + full_secrets = secrets.map { |secret| [ from, secret ].compact.join("/") } fetch_from_vault(full_secrets, account: account, session: session) rescue => e $stderr.puts " \e[31mERROR (#{e.class}): #{e.message}\e[0m" $stderr.puts e.backtrace if ENV["VERBOSE"] - Process.kill("INT", Process.ppid) if ENV["KAMAL_SECRETS_KILL_PARENT"] + Process.kill("INT", Process.ppid) if ENV["KAMAL_SECRETS_INT_PARENT"] exit 1 end diff --git a/lib/kamal/secrets/adapters/bitwarden.rb b/lib/kamal/secrets/adapters/bitwarden.rb index e48ce82b..42fad2e8 100644 --- a/lib/kamal/secrets/adapters/bitwarden.rb +++ b/lib/kamal/secrets/adapters/bitwarden.rb @@ -9,13 +9,13 @@ class Kamal::Secrets::Adapters::Bitwarden < Kamal::Secrets::Adapters::Base end if status["status"] == "locked" - session = run_command("unlock --raw", raw: true) + session = run_command("unlock --raw", raw: true).presence status = run_command("status", session: session) end raise RuntimeError, "Failed to login to and unlock Bitwarden" unless status["status"] == "unlocked" - run_command("sync", raw: true) + run_command("sync", session: session, raw: true) raise RuntimeError, "Failed to sync Bitwarden" unless $?.success? session @@ -23,25 +23,37 @@ class Kamal::Secrets::Adapters::Bitwarden < Kamal::Secrets::Adapters::Base def fetch_from_vault(secrets, account:, session:) {}.tap do |results| - secrets.each do |secret| - item, field = secret.split("/") - item = run_command("get item #{item}", session: session) - raise RuntimeError, "Could not read #{item} from Bitwarden" unless $?.success? - if field - item_field = item["fields"].find { |f| f["name"] == field } - raise RuntimeError, "Could not find field #{field} in item #{item} in Bitwarden" unless item_field - value = item_field["value"] - results[secret] = value - results[field] = value + items_fields(secrets).each do |item, fields| + item_json = run_command("get item #{item}", session: session, raw: true) + raise RuntimeError, "Could not read #{secret} from Bitwarden" unless $?.success? + item_json = JSON.parse(item_json) + + if fields.any? + fields.each do |field| + item_field = item_json["fields"].find { |f| f["name"] == field } + raise RuntimeError, "Could not find field #{field} in item #{item} in Bitwarden" unless item_field + value = item_field["value"] + results["#{item}/#{field}"] = value + end else - results[secret] = item["login"]["password"] + results[item] = item_json["login"]["password"] end end end end + def items_fields(secrets) + {}.tap do |items| + secrets.each do |secret| + item, field = secret.split("/") + items[item] ||= [] + items[item] << field + end + end + end + def signedin?(account) - JSON.parse(`bw status`.strip)["status"] != "unauthenticated" + run_command("status")["status"] != "unauthenticated" end def run_command(command, session: nil, raw: false) diff --git a/lib/kamal/secrets/adapters/last_pass.rb b/lib/kamal/secrets/adapters/last_pass.rb index 984a684a..ab46e2cd 100644 --- a/lib/kamal/secrets/adapters/last_pass.rb +++ b/lib/kamal/secrets/adapters/last_pass.rb @@ -12,12 +12,13 @@ class Kamal::Secrets::Adapters::LastPass < Kamal::Secrets::Adapters::Base end def fetch_from_vault(secrets, account:, session:) - items = JSON.parse(`lpass show #{secrets.join(" ")} --json` - raise RuntimeError, "Could not read #{fields} from 1Password" unless $?.success? + items = `lpass show #{secrets.join(" ")} --json` + raise RuntimeError, "Could not read #{secrets} from 1Password" unless $?.success? + + items = JSON.parse(items) {}.tap do |results| items.each do |item| - results[item["name"]] = item["password"] results[item["fullname"]] = item["password"] end diff --git a/lib/kamal/secrets/adapters/one_password.rb b/lib/kamal/secrets/adapters/one_password.rb index ee9c9ce8..9b68ca68 100644 --- a/lib/kamal/secrets/adapters/one_password.rb +++ b/lib/kamal/secrets/adapters/one_password.rb @@ -24,9 +24,8 @@ class Kamal::Secrets::Adapters::OnePassword < Kamal::Secrets::Adapters::Base fields_json.each do |field_json| # The reference is in the form `op://vault/item/field[/field]` - field = field_json["reference"].delete_suffix("/password") + field = field_json["reference"].delete_prefix("op://").delete_suffix("/password") results[field] = field_json["value"] - results[field.split("/").last] = field_json["value"] end end end @@ -40,6 +39,7 @@ class Kamal::Secrets::Adapters::OnePassword < Kamal::Secrets::Adapters::Base def vaults_items_fields(secrets) {}.tap do |vaults| secrets.each do |secret| + secret = secret.delete_prefix("op://") vault, item, *fields = secret.split("/") fields << "password" if fields.empty? diff --git a/test/cli/secrets_test.rb b/test/cli/secrets_test.rb index 733ac0b4..35d20500 100644 --- a/test/cli/secrets_test.rb +++ b/test/cli/secrets_test.rb @@ -11,6 +11,10 @@ class CliSecretsTest < CliTestCase assert_equal "oof", run_command("extract", "foo", "{\"foo\":\"oof\", \"bar\":\"rab\", \"baz\":\"zab\"}") end + test "extract match from end" do + assert_equal "oof", run_command("extract", "foo", "{\"abc/foo\":\"oof\", \"bar\":\"rab\", \"baz\":\"zab\"}") + end + private def run_command(*command) stdouted { Kamal::Cli::Secrets.start([ *command, "-c", "test/fixtures/deploy_with_accessories.yml" ]) } diff --git a/test/secrets/bitwarden_adapter_test.rb b/test/secrets/bitwarden_adapter_test.rb new file mode 100644 index 00000000..ff3f2a1c --- /dev/null +++ b/test/secrets/bitwarden_adapter_test.rb @@ -0,0 +1,211 @@ +require "test_helper" + +class BitwardenAdapterTest < SecretAdapterTestCase + test "fetch" do + stub_unlocked + stub_ticks.with("bw sync").returns("") + stub_mypassword + + json = JSON.parse(shellunescape(run_command("fetch", "mypassword"))) + + expected_json = { "mypassword"=>"secret123" } + + assert_equal expected_json, json + end + + test "fetch with from" do + stub_unlocked + stub_ticks.with("bw sync").returns("") + stub_myitem + + json = JSON.parse(shellunescape(run_command("fetch", "--from", "myitem", "field1", "field2", "field3"))) + + expected_json = { + "myitem/field1"=>"secret1", "myitem/field2"=>"blam", "myitem/field3"=>"fewgrwjgk" + } + + assert_equal expected_json, json + end + + test "fetch with multiple items" do + stub_unlocked + + stub_ticks.with("bw sync").returns("") + stub_mypassword + stub_myitem + + stub_ticks + .with("bw get item myitem2") + .returns(<<~JSON) + { + "passwordHistory":null, + "revisionDate":"2024-08-29T13:46:53.343Z", + "creationDate":"2024-08-29T12:02:31.156Z", + "deletedDate":null, + "object":"item", + "id":"aaaaaaaa-cccc-eeee-0000-222222222222", + "organizationId":null, + "folderId":null, + "type":1, + "reprompt":0, + "name":"myitem2", + "notes":null, + "favorite":false, + "fields":[ + {"name":"field3","value":"fewgrwjgk","type":1,"linkedId":null} + ], + "login":{"fido2Credentials":[],"uris":[],"username":null,"password":null,"totp":null,"passwordRevisionDate":null},"collectionIds":[] + } + JSON + + + json = JSON.parse(shellunescape(run_command("fetch", "mypassword", "myitem/field1", "myitem/field2", "myitem2/field3"))) + + expected_json = { + "mypassword"=>"secret123", "myitem/field1"=>"secret1", "myitem/field2"=>"blam", "myitem2/field3"=>"fewgrwjgk" + } + + assert_equal expected_json, json + end + + test "fetch unauthenticated" do + stub_ticks + .with("bw status") + .returns( + '{"serverUrl":null,"lastSync":null,"status":"unauthenticated"}', + '{"serverUrl":null,"lastSync":"2024-09-04T10:11:12.433Z","userEmail":"email@example.com","userId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","status":"locked"}', + '{"serverUrl":null,"lastSync":"2024-09-04T10:11:12.433Z","userEmail":"email@example.com","userId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","status":"unlocked"}' + ) + + stub_ticks.with("bw login email@example.com").returns("1234567890") + stub_ticks.with("bw unlock --raw").returns("") + stub_ticks.with("bw sync").returns("") + stub_mypassword + + json = JSON.parse(shellunescape(run_command("fetch", "mypassword"))) + + expected_json = { "mypassword"=>"secret123" } + + assert_equal expected_json, json + end + + test "fetch locked" do + stub_ticks + .with("bw status") + .returns( + '{"serverUrl":null,"lastSync":"2024-09-04T10:11:12.433Z","userEmail":"email@example.com","userId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","status":"locked"}' + ) + + stub_ticks + .with("bw status") + .returns( + '{"serverUrl":null,"lastSync":"2024-09-04T10:11:12.433Z","userEmail":"email@example.com","userId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","status":"unlocked"}' + ) + + stub_ticks.with("bw login email@example.com").returns("1234567890") + stub_ticks.with("bw unlock --raw").returns("") + stub_ticks.with("bw sync").returns("") + stub_mypassword + + json = JSON.parse(shellunescape(run_command("fetch", "mypassword"))) + + expected_json = { "mypassword"=>"secret123" } + + assert_equal expected_json, json + end + + test "fetch locked with session" do + stub_ticks + .with("bw status") + .returns( + '{"serverUrl":null,"lastSync":"2024-09-04T10:11:12.433Z","userEmail":"email@example.com","userId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","status":"locked"}' + ) + + stub_ticks + .with("BW_SESSION=0987654321 bw status") + .returns( + '{"serverUrl":null,"lastSync":"2024-09-04T10:11:12.433Z","userEmail":"email@example.com","userId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","status":"unlocked"}' + ) + + stub_ticks.with("bw login email@example.com").returns("1234567890") + stub_ticks.with("bw unlock --raw").returns("0987654321") + stub_ticks.with("BW_SESSION=0987654321 bw sync").returns("") + stub_mypassword(session: "0987654321") + + json = JSON.parse(shellunescape(run_command("fetch", "mypassword"))) + + expected_json = { "mypassword"=>"secret123" } + + assert_equal expected_json, json + end + + private + def run_command(*command) + stdouted do + Kamal::Cli::Secrets.start \ + [ *command, + "-c", "test/fixtures/deploy_with_accessories.yml", + "--adapter", "bitwarden", + "--account", "email@example.com" ] + end + end + + def stub_unlocked + stub_ticks + .with("bw status") + .returns(<<~JSON) + {"serverUrl":null,"lastSync":"2024-09-04T10:11:12.433Z","userEmail":"email@example.com","userId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","status":"unlocked"} + JSON + end + + def stub_mypassword(session: nil) + stub_ticks + .with("#{"BW_SESSION=#{session} " if session}bw get item mypassword") + .returns(<<~JSON) + { + "passwordHistory":null, + "revisionDate":"2024-08-29T13:46:53.343Z", + "creationDate":"2024-08-29T12:02:31.156Z", + "deletedDate":null, + "object":"item", + "id":"aaaaaaaa-cccc-eeee-0000-222222222222", + "organizationId":null, + "folderId":null, + "type":1, + "reprompt":0, + "name":"mypassword", + "notes":null, + "favorite":false, + "login":{"fido2Credentials":[],"uris":[],"username":null,"password":"secret123","totp":null,"passwordRevisionDate":null},"collectionIds":[] + } + JSON + end + + def stub_myitem + stub_ticks + .with("bw get item myitem") + .returns(<<~JSON) + { + "passwordHistory":null, + "revisionDate":"2024-08-29T13:46:53.343Z", + "creationDate":"2024-08-29T12:02:31.156Z", + "deletedDate":null, + "object":"item", + "id":"aaaaaaaa-cccc-eeee-0000-222222222222", + "organizationId":null, + "folderId":null, + "type":1, + "reprompt":0, + "name":"myitem", + "notes":null, + "favorite":false, + "fields":[ + {"name":"field1","value":"secret1","type":1,"linkedId":null}, + {"name":"field2","value":"blam","type":1,"linkedId":null}, + {"name":"field3","value":"fewgrwjgk","type":1,"linkedId":null} + ], + "login":{"fido2Credentials":[],"uris":[],"username":null,"password":null,"totp":null,"passwordRevisionDate":null},"collectionIds":[] + } + JSON + end +end diff --git a/test/secrets/last_pass_adapter_test.rb b/test/secrets/last_pass_adapter_test.rb new file mode 100644 index 00000000..3801d486 --- /dev/null +++ b/test/secrets/last_pass_adapter_test.rb @@ -0,0 +1,152 @@ +require "test_helper" + +class LastPassAdapterTest < SecretAdapterTestCase + setup do + `true` # Ensure $? is 0 + end + + test "fetch" do + stub_ticks.with("lpass status --color never").returns("Logged in as email@example.com.") + + stub_ticks + .with("lpass show SECRET1 FOLDER1/FSECRET1 FOLDER1/FSECRET2 --json") + .returns(<<~JSON) + [ + { + "id": "1234567891234567891", + "name": "SECRET1", + "fullname": "SECRET1", + "username": "", + "password": "secret1", + "last_modified_gmt": "1724926054", + "last_touch": "1724926639", + "group": "", + "url": "", + "note": "" + }, + { + "id": "1234567891234567892", + "name": "FSECRET1", + "fullname": "FOLDER1/FSECRET1", + "username": "", + "password": "fsecret1", + "last_modified_gmt": "1724926084", + "last_touch": "1724926635", + "group": "Folder", + "url": "", + "note": "" + }, + { + "id": "1234567891234567893", + "name": "FSECRET2", + "fullname": "FOLDER1/FSECRET2", + "username": "", + "password": "fsecret2", + "last_modified_gmt": "1724926084", + "last_touch": "1724926635", + "group": "Folder", + "url": "", + "note": "" + } + ] + JSON + + json = JSON.parse(shellunescape(run_command("fetch", "SECRET1", "FOLDER1/FSECRET1", "FOLDER1/FSECRET2"))) + + expected_json = { + "SECRET1"=>"secret1", + "FOLDER1/FSECRET1"=>"fsecret1", + "FOLDER1/FSECRET2"=>"fsecret2" + } + + assert_equal expected_json, json + end + + test "fetch with from" do + stub_ticks.with("lpass status --color never").returns("Logged in as email@example.com.") + + stub_ticks + .with("lpass show FOLDER1/FSECRET1 FOLDER1/FSECRET2 --json") + .returns(<<~JSON) + [ + { + "id": "1234567891234567892", + "name": "FSECRET1", + "fullname": "FOLDER1/FSECRET1", + "username": "", + "password": "fsecret1", + "last_modified_gmt": "1724926084", + "last_touch": "1724926635", + "group": "Folder", + "url": "", + "note": "" + }, + { + "id": "1234567891234567893", + "name": "FSECRET2", + "fullname": "FOLDER1/FSECRET2", + "username": "", + "password": "fsecret2", + "last_modified_gmt": "1724926084", + "last_touch": "1724926635", + "group": "Folder", + "url": "", + "note": "" + } + ] + JSON + + json = JSON.parse(shellunescape(run_command("fetch", "--from", "FOLDER1", "FSECRET1", "FSECRET2"))) + + expected_json = { + "FOLDER1/FSECRET1"=>"fsecret1", + "FOLDER1/FSECRET2"=>"fsecret2" + } + + assert_equal expected_json, json + end + + test "fetch with signin" do + stub_ticks_with("lpass status --color never", succeed: false).returns("Not logged in.") + stub_ticks_with("lpass login email@example.com", succeed: true).returns("") + stub_ticks.with("lpass show SECRET1 --json").returns(single_item_json) + + json = JSON.parse(shellunescape(run_command("fetch", "SECRET1"))) + + expected_json = { + "SECRET1"=>"secret1" + } + + assert_equal expected_json, json + end + + private + def run_command(*command) + stdouted do + Kamal::Cli::Secrets.start \ + [ *command, + "-c", "test/fixtures/deploy_with_accessories.yml", + "--adapter", "lastpass", + "--account", "email@example.com" ] + end + end + + def single_item_json + <<~JSON + [ + { + "id": "1234567891234567891", + "name": "SECRET1", + "fullname": "SECRET1", + "username": "", + "password": "secret1", + "last_modified_gmt": "1724926054", + "last_touch": "1724926639", + "group": "", + "url": "", + "note": "" + } + ] + JSON + end +end diff --git a/test/secrets/one_password_adapter_test.rb b/test/secrets/one_password_adapter_test.rb index 41b6fe17..e36cf8a2 100644 --- a/test/secrets/one_password_adapter_test.rb +++ b/test/secrets/one_password_adapter_test.rb @@ -1,24 +1,11 @@ require "test_helper" -class SecretsOnePasswordAdapterTest < ActiveSupport::TestCase - test "login" do - `true` # Ensure $? is 0 - Object.any_instance.stubs(:`).with("op signin --account \"myaccount\" --force --raw").returns("Logged in") - - assert_equal "Logged in", run_command("login") - end - +class SecretsOnePasswordAdapterTest < SecretAdapterTestCase test "fetch" do - `true` # Ensure $? is 0 - Object.any_instance.stubs(:`).with("op read op://vault/item/section/foo --account \"myaccount\"").returns("bar") + stub_ticks.with("op account get --account myaccount") - assert_equal "bar", run_command("fetch", "op://vault/item/section/foo") - end - - test "fetch_all" do - `true` # Ensure $? is 0 - Object.any_instance.stubs(:`) - .with("op item get item --vault \"vault\" --fields \"label=section.SECRET1,label=section.SECRET2\" --format \"json\" --account \"myaccount\"") + stub_ticks + .with("op item get myitem --vault \"myvault\" --fields \"label=section.SECRET1,label=section.SECRET2,label=section2.SECRET3\" --format \"json\" --account \"myaccount\"") .returns(<<~JSON) [ { @@ -30,7 +17,7 @@ class SecretsOnePasswordAdapterTest < ActiveSupport::TestCase "type": "CONCEALED", "label": "SECRET1", "value": "VALUE1", - "reference": "op://vault/item/section/SECRET1" + "reference": "op://myvault/myitem/section/SECRET1" }, { "id": "bbbbbbbbbbbbbbbbbbbbbbbbbb", @@ -41,12 +28,124 @@ class SecretsOnePasswordAdapterTest < ActiveSupport::TestCase "type": "CONCEALED", "label": "SECRET2", "value": "VALUE2", - "reference": "op://vault/item/section/SECRET2" + "reference": "op://myvault/myitem/section/SECRET2" + }, + { + "id": "bbbbbbbbbbbbbbbbbbbbbbbbbb", + "section": { + "id": "dddddddddddddddddddddddddd", + "label": "section2" + }, + "type": "CONCEALED", + "label": "SECRET3", + "value": "VALUE3", + "reference": "op://myvault/myitem/section2/SECRET3" } ] JSON - assert_equal "bar", run_command("fetch_all", "op://vault/item/section/SECRET1", "op://vault/item/section/SECRET2") + json = JSON.parse(shellunescape(run_command("fetch", "--from", "op://myvault/myitem", "section/SECRET1", "section/SECRET2", "section2/SECRET3"))) + + expected_json = { + "myvault/myitem/section/SECRET1"=>"VALUE1", + "myvault/myitem/section/SECRET2"=>"VALUE2", + "myvault/myitem/section2/SECRET3"=>"VALUE3" + } + + assert_equal expected_json, json + end + + test "fetch with multiple items" do + stub_ticks.with("op account get --account myaccount") + + stub_ticks + .with("op item get myitem --vault \"myvault\" --fields \"label=section.SECRET1,label=section.SECRET2\" --format \"json\" --account \"myaccount\"") + .returns(<<~JSON) + [ + { + "id": "aaaaaaaaaaaaaaaaaaaaaaaaaa", + "section": { + "id": "cccccccccccccccccccccccccc", + "label": "section" + }, + "type": "CONCEALED", + "label": "SECRET1", + "value": "VALUE1", + "reference": "op://myvault/myitem/section/SECRET1" + }, + { + "id": "bbbbbbbbbbbbbbbbbbbbbbbbbb", + "section": { + "id": "dddddddddddddddddddddddddd", + "label": "section" + }, + "type": "CONCEALED", + "label": "SECRET2", + "value": "VALUE2", + "reference": "op://myvault/myitem/section/SECRET2" + } + ] + JSON + + stub_ticks + .with("op item get myitem2 --vault \"myvault\" --fields \"label=section2.SECRET3\" --format \"json\" --account \"myaccount\"") + .returns(<<~JSON) + { + "id": "aaaaaaaaaaaaaaaaaaaaaaaaaa", + "section": { + "id": "cccccccccccccccccccccccccc", + "label": "section" + }, + "type": "CONCEALED", + "label": "SECRET3", + "value": "VALUE3", + "reference": "op://myvault/myitem2/section/SECRET3" + } + JSON + + json = JSON.parse(shellunescape(run_command("fetch", "--from", "op://myvault", "myitem/section/SECRET1", "myitem/section/SECRET2", "myitem2/section2/SECRET3"))) + + expected_json = { + "myvault/myitem/section/SECRET1"=>"VALUE1", + "myvault/myitem/section/SECRET2"=>"VALUE2", + "myvault/myitem2/section/SECRET3"=>"VALUE3" + } + + assert_equal expected_json, json + end + + test "fetch with signin, no session" do + stub_ticks_with("op account get --account myaccount", succeed: false) + stub_ticks_with("op signin --account \"myaccount\" --force --raw", succeed: true).returns("") + + stub_ticks + .with("op item get myitem --vault \"myvault\" --fields \"label=section.SECRET1\" --format \"json\" --account \"myaccount\"") + .returns(single_item_json) + + json = JSON.parse(shellunescape(run_command("fetch", "--from", "op://myvault/myitem", "section/SECRET1"))) + + expected_json = { + "myvault/myitem/section/SECRET1"=>"VALUE1" + } + + assert_equal expected_json, json + end + + test "fetch with signin and session" do + stub_ticks_with("op account get --account myaccount", succeed: false) + stub_ticks_with("op signin --account \"myaccount\" --force --raw", succeed: true).returns("1234567890") + + stub_ticks + .with("op item get myitem --vault \"myvault\" --fields \"label=section.SECRET1\" --format \"json\" --account \"myaccount\" --session \"1234567890\"") + .returns(single_item_json) + + json = JSON.parse(shellunescape(run_command("fetch", "--from", "op://myvault/myitem", "section/SECRET1"))) + + expected_json = { + "myvault/myitem/section/SECRET1"=>"VALUE1" + } + + assert_equal expected_json, json end private @@ -56,7 +155,23 @@ class SecretsOnePasswordAdapterTest < ActiveSupport::TestCase [ *command, "-c", "test/fixtures/deploy_with_accessories.yml", "--adapter", "1password", - "--adapter-options", "account:myaccount" ] + "--account", "myaccount" ] end end + + def single_item_json + <<~JSON + { + "id": "aaaaaaaaaaaaaaaaaaaaaaaaaa", + "section": { + "id": "cccccccccccccccccccccccccc", + "label": "section" + }, + "type": "CONCEALED", + "label": "SECRET1", + "value": "VALUE1", + "reference": "op://myvault/myitem/section/SECRET1" + } + JSON + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 94bb767e..bb585280 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -75,3 +75,24 @@ class Kamal::Secrets::Adapters::Test < Kamal::Secrets::Adapters::Base secrets.to_h { |name| [ name, name.reverse ] } end end + +class SecretAdapterTestCase < ActiveSupport::TestCase + setup do + `true` # Ensure $? is 0 + end + + private + def stub_ticks + Kamal::Secrets::Adapters::Base.any_instance.stubs(:`) + end + + def stub_ticks_with(command, succeed: true) + # Sneakily run `false`/`true` after a match to set $? to 1/0 + stub_ticks.with { |c| c == command && (succeed ? `true` : `false`) } + Kamal::Secrets::Adapters::Base.any_instance.stubs(:`) + end + + def shellunescape(string) + "\"#{string}\"".undump.gsub(/\\([{}])/, "\\1") + end +end