From e0769c1ca102ff9a9b548bfbcc22b90c06918c1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Fri, 16 Oct 2026 19:48:18 +0200 Subject: [PATCH 1/2] Create keys in integration tests from a two-factor sign-in hexpm refuses an API key, or a token exchanged for one, when creating API keys (hexpm/hexpm#2055). The tests that created keys with a key now sign in the way mix hex.user auth does: a user with two-factor authentication and an OAuth token, with the TOTP code that OAuth writes need. They pass against hexpm with and without that change. --- test/hex/api_test.exs | 11 ++-- .../organization_auth_integration_test.exs | 15 +---- test/mix/tasks/hex.organization_test.exs | 32 +++++------ test/support/hexpm.ex | 56 +++++++++++++++++++ 4 files changed, 77 insertions(+), 37 deletions(-) diff --git a/test/hex/api_test.exs b/test/hex/api_test.exs index 231f5f89..65c32353 100644 --- a/test/hex/api_test.exs +++ b/test/hex/api_test.exs @@ -69,10 +69,10 @@ defmodule Hex.APITest do test "keys" do permissions = [%{"domain" => "api"}] - auth = Hexpm.new_key("user", "keys_test") + sign_in = Hexpm.new_tfa_user("keys_user", "keys_user@mail.com", "hunter42") - assert {:ok, {201, _, %{"secret" => key_a}}} = Hex.API.Key.new("key_a", permissions, auth) - assert {:ok, {201, _, %{"secret" => key_b}}} = Hex.API.Key.new("key_b", permissions, auth) + assert {:ok, {201, _, %{"secret" => key_a}}} = Hex.API.Key.new("key_a", permissions, sign_in) + assert {:ok, {201, _, %{"secret" => key_b}}} = Hex.API.Key.new("key_b", permissions, sign_in) assert is_binary(key_a) and byte_size(key_a) > 0 assert is_binary(key_b) and byte_size(key_b) > 0 auth = [key: key_a] @@ -88,9 +88,8 @@ defmodule Hex.APITest do assert {:ok, {401, _, _}} = Hex.API.Key.get(auth) # Delete all keys - auth = Hexpm.new_key("user", "keys_test") - assert {:ok, {201, _, %{"secret" => key_c}}} = Hex.API.Key.new("key_c", permissions, auth) - assert {:ok, {201, _, %{"secret" => key_d}}} = Hex.API.Key.new("key_d", permissions, auth) + assert {:ok, {201, _, %{"secret" => key_c}}} = Hex.API.Key.new("key_c", permissions, sign_in) + assert {:ok, {201, _, %{"secret" => key_d}}} = Hex.API.Key.new("key_d", permissions, sign_in) assert is_binary(key_c) and byte_size(key_c) > 0 assert is_binary(key_d) and byte_size(key_d) > 0 auth_c = [key: key_c] diff --git a/test/hex/organization_auth_integration_test.exs b/test/hex/organization_auth_integration_test.exs index da83d894..cef579ed 100644 --- a/test/hex/organization_auth_integration_test.exs +++ b/test/hex/organization_auth_integration_test.exs @@ -59,7 +59,7 @@ defmodule Hex.OrganizationAuthIntegrationTest do "scope" => "api repositories" }) - auth = [key: response["access_token"], oauth: true, otp: totp(secret)] + auth = [key: response["access_token"], oauth: true, otp: Hexpm.totp(secret)] assert {:ok, {204, _, _}} = Hexpm.new_repo(organization, auth) assert {:ok, {204, _, _}} = @@ -81,17 +81,4 @@ defmodule Hex.OrganizationAuthIntegrationTest do assert decoded =~ "repository:#{organization}" end) end - - defp totp(secret) do - counter = div(System.system_time(:second), 30) + 1 - digest = :crypto.mac(:hmac, :sha, Base.decode32!(secret), <>) - offset = Bitwise.band(:binary.last(digest), 15) - <> = :binary.part(digest, offset, 4) - - code - |> Bitwise.band(0x7FFFFFFF) - |> rem(1_000_000) - |> Integer.to_string() - |> String.pad_leading(6, "0") - end end diff --git a/test/mix/tasks/hex.organization_test.exs b/test/mix/tasks/hex.organization_test.exs index f54dd5c4..d8b720da 100644 --- a/test/mix/tasks/hex.organization_test.exs +++ b/test/mix/tasks/hex.organization_test.exs @@ -4,8 +4,8 @@ defmodule Mix.Tasks.Hex.OrganizationTest do test "auth" do in_tmp(fn -> set_home_cwd() - auth = Hexpm.new_user("orgauth", "orgauth@mail.com", "password", "key") - Hex.State.put(:api_key, auth[:key]) + auth = Hexpm.new_tfa_user("orgauth", "orgauth@mail.com", "password") + Hexpm.sign_in(auth) Hexpm.new_repo("myorgauth", auth) send(self(), {:mix_shell_input, :yes?, true}) @@ -29,8 +29,8 @@ defmodule Mix.Tasks.Hex.OrganizationTest do test "auth without --key warns about deprecation" do in_tmp(fn -> set_home_cwd() - auth = Hexpm.new_user("orgauthdeprecated", "orgauthdeprecated@mail.com", "password", "key") - Hex.State.put(:api_key, auth[:key]) + auth = Hexpm.new_tfa_user("orgauthdeprecated", "orgauthdeprecated@mail.com", "password") + Hexpm.sign_in(auth) Hexpm.new_repo("myorgauthdeprecated", auth) send(self(), {:mix_shell_input, :yes?, true}) @@ -55,10 +55,8 @@ defmodule Mix.Tasks.Hex.OrganizationTest do in_tmp(fn -> set_home_cwd() - auth = - Hexpm.new_user("orgauthwithkeyname", "orgauthwithkeyname@mail.com", "password", "key") - - Hex.State.put(:api_key, auth[:key]) + auth = Hexpm.new_tfa_user("orgauthwithkeyname", "orgauthwithkeyname@mail.com", "password") + Hexpm.sign_in(auth) Hexpm.new_repo("myorgauthwithkeyname", auth) @@ -87,7 +85,7 @@ defmodule Mix.Tasks.Hex.OrganizationTest do test "auth --key" do in_tmp(fn -> set_home_cwd() - auth = Hexpm.new_user("orgauthkey", "orgauthkey@mail.com", "password", "key") + auth = Hexpm.new_tfa_user("orgauthkey", "orgauthkey@mail.com", "password") Hexpm.new_repo("myorgauthkey", auth) parameters = [%{"domain" => "repository", "resource" => "myorgauthkey"}] @@ -236,8 +234,8 @@ defmodule Mix.Tasks.Hex.OrganizationTest do in_tmp(fn -> set_home_cwd() - auth = Hexpm.new_user("orgkeygenuser", "orgkeygenuser@mail.com", "password", "key") - Hex.State.put(:api_key, auth[:key]) + auth = Hexpm.new_tfa_user("orgkeygenuser", "orgkeygenuser@mail.com", "password") + Hexpm.sign_in(auth) Hexpm.new_repo("orgkeygenrepo", auth) @@ -265,8 +263,8 @@ defmodule Mix.Tasks.Hex.OrganizationTest do in_tmp(fn -> set_home_cwd() - auth = Hexpm.new_user("orgkeylistuser", "orgkeylistuser@mail.com", "password", "key") - Hex.State.put(:api_key, auth[:key]) + auth = Hexpm.new_tfa_user("orgkeylistuser", "orgkeylistuser@mail.com", "password") + Hexpm.sign_in(auth) Hexpm.new_repo("orgkeylistrepo", auth) @@ -287,8 +285,8 @@ defmodule Mix.Tasks.Hex.OrganizationTest do in_tmp(fn -> set_home_cwd() - auth = Hexpm.new_user("orgkeyrevokeyuser", "orgkeyrevokeyuser@mail.com", "password", "key") - Hex.State.put(:api_key, auth[:key]) + auth = Hexpm.new_tfa_user("orgkeyrevokeyuser", "orgkeyrevokeyuser@mail.com", "password") + Hexpm.sign_in(auth) Hexpm.new_repo("orgkeyrevokerepo", auth) Hexpm.new_organization_key("orgkeyrevokerepo", "orgkeyrevokerepo2", auth) @@ -310,9 +308,9 @@ defmodule Mix.Tasks.Hex.OrganizationTest do set_home_cwd() auth = - Hexpm.new_user("orgkeyrevokealluser", "orgkeyrevokealluser@mail.com", "password", "key") + Hexpm.new_tfa_user("orgkeyrevokealluser", "orgkeyrevokealluser@mail.com", "password") - Hex.State.put(:api_key, auth[:key]) + Hexpm.sign_in(auth) Hexpm.new_repo("orgkeyrevokeallrepo", auth) Hexpm.new_organization_key("orgkeyrevokeallrepo", "orgkeyrevokeallrepo2", auth) diff --git a/test/support/hexpm.ex b/test/support/hexpm.ex index daffac91..ca538983 100644 --- a/test/support/hexpm.ex +++ b/test/support/hexpm.ex @@ -238,6 +238,62 @@ defmodule HexTest.Hexpm do [key: secret] end + @doc """ + Creates a user with two-factor authentication and an OAuth token for it, the + sign-in `mix hex.user auth` gives. The auth carries the user's current TOTP + code, which writes made with an OAuth token need. + """ + def new_tfa_user(username, email, password) do + secret = Base.encode32(:crypto.strong_rand_bytes(10)) + config = Hex.API.Client.config() + + {:ok, {201, _, _}} = + :mix_hex_api.post(config, ["user"], %{ + "username" => to_string(username), + "email" => to_string(email), + "password" => to_string(password), + "tfa" => %{"secret" => secret} + }) + + {:ok, {200, _, response}} = + :mix_hex_api.post(config, ["oauth_token"], %{ + "username" => to_string(username), + "scope" => "api repositories" + }) + + [ + key: response["access_token"], + oauth: true, + otp: totp(secret), + "$oauth_token": %{ + access_token: response["access_token"], + refresh_token: response["refresh_token"], + expires_at: System.system_time(:second) + response["expires_in"] + } + ] + end + + @doc """ + Signs Hex in with an auth from `new_tfa_user/3`, as `mix hex.user auth` does. + """ + def sign_in(auth) do + Hex.OAuth.store_token(auth[:"$oauth_token"]) + Hex.State.put(:api_otp, auth[:otp]) + end + + def totp(secret) do + counter = div(System.system_time(:second), 30) + 1 + digest = :crypto.mac(:hmac, :sha, Base.decode32!(secret), <>) + offset = Bitwise.band(:binary.last(digest), 15) + <> = :binary.part(digest, offset, 4) + + code + |> Bitwise.band(0x7FFFFFFF) + |> rem(1_000_000) + |> Integer.to_string() + |> String.pad_leading(6, "0") + end + def new_organization_key(organization, key, auth) do permissions = [%{"domain" => "api"}] From fb80fb301c796a431b8d0a7c7c35c44afeb32cc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Fri, 16 Oct 2026 19:50:03 +0200 Subject: [PATCH 2/2] Sign in before organization auth in the deauth test The test authorized the organization with a stored key, which hexpm#2055 refuses for key creation, and then passed because deauth leaves no repo either way. It now signs in with two-factor and checks that auth added the repo before deauth removes it. --- test/mix/tasks/hex.organization_test.exs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/mix/tasks/hex.organization_test.exs b/test/mix/tasks/hex.organization_test.exs index d8b720da..8dff8c7c 100644 --- a/test/mix/tasks/hex.organization_test.exs +++ b/test/mix/tasks/hex.organization_test.exs @@ -217,13 +217,14 @@ defmodule Mix.Tasks.Hex.OrganizationTest do test "deauth" do in_tmp(fn -> set_home_cwd() - auth = Hexpm.new_user("orgdeauth", "orgdeauth@mail.com", "password", "key") - Hex.State.put(:api_key, auth[:key]) + auth = Hexpm.new_tfa_user("orgdeauth", "orgdeauth@mail.com", "password") + Hexpm.sign_in(auth) Hexpm.new_repo("myorgdeauth", auth) send(self(), {:mix_shell_input, :yes?, true}) send(self(), {:mix_shell_input, :prompt, "password"}) Mix.Tasks.Hex.Organization.run(["auth", "myorgdeauth"]) + assert Hex.Config.read_repos(Hex.Config.read())["hexpm:myorgdeauth"] Mix.Tasks.Hex.Organization.run(["deauth", "myorgdeauth"]) refute Hex.Config.read_repos(Hex.Config.read())["hexpm:myorgdeauth"]