neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Runners: a registration token registers one runner and expires
8417f8f · Gabriel Morell · 2026-10-20 18:18
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud/organizations/organization.ex
+1
−0
@@ -16,6 +16,7 @@ defmodule GitGud.Organizations.Organization do
| 16 | 16 | field :visibility, :string, default: "public" |
| 17 | 17 | # See `GitGud.Repositories.Repository.runner_reg_token_hash`. |
| 18 | 18 | field :runner_reg_token_hash, :binary |
| 19 | + field :runner_reg_token_expires_at, :utc_datetime | |
| 19 | 20 | |
| 20 | 21 | field :display_name, :string |
| 21 | 22 | field :bio, :string |
modified
lib/git_gud/repositories/repository.ex
+3
−1
@@ -36,8 +36,10 @@ defmodule GitGud.Repositories.Repository do
| 36 | 36 | field :block_self_approval, :boolean, default: true |
| 37 | 37 | # SHA-256 of the current runner registration token, if any. |
| 38 | 38 | # Regenerating the token replaces the hash, invalidating the old |
| 39 | − # token immediately. | |
| 39 | + # token immediately; registering a runner with it clears it, and it | |
| 40 | + # stops working at `runner_reg_token_expires_at` either way. | |
| 40 | 41 | field :runner_reg_token_hash, :binary |
| 42 | + field :runner_reg_token_expires_at, :utc_datetime | |
| 41 | 43 | # Set when the repo was imported from a remote git URL. Kept so the |
| 42 | 44 | # repo can be re-fetched from upstream on demand; never cast from |
| 43 | 45 | # user input. |
modified
lib/git_gud/workflows/runners.ex
+74
−31
@@ -8,7 +8,8 @@ defmodule GitGud.Workflows.Runners do
| 8 | 8 | settings UI; the resulting runner is scoped to that repo or org. |
| 9 | 9 | Stored as a SHA-256 hash on `repositories.runner_reg_token_hash` |
| 10 | 10 | or `organizations.runner_reg_token_hash`. Regenerating shifts the |
| 11 | − hash, invalidating the old token in one step. | |
| 11 | + hash, invalidating the old token in one step. A token registers | |
| 12 | + one runner and expires a day after it's minted. | |
| 12 | 13 | |
| 13 | 14 | 2. **Global env token** (`GITGUD_RUNNER_REG_TOKEN`) — legacy path |
| 14 | 15 | for operator-managed pool runners. Resulting runner has neither |
@@ -45,22 +46,34 @@ defmodule GitGud.Workflows.Runners do
| 45 | 46 | defp global_token_valid?(_), do: false |
| 46 | 47 | |
| 47 | 48 | @doc """ |
| 48 | − Resolve a registration token to its scope. Returns: | |
| 49 | + Redeem a registration token: resolve it to its scope and, for a | |
| 50 | + repository's or organization's token, use it up. Returns: | |
| 49 | 51 | |
| 50 | 52 | * `{:ok, :global}` — legacy global token from env / config |
| 51 | 53 | * `{:ok, %Repository{}}` — repo-scoped token |
| 52 | 54 | * `{:ok, %Organization{}}` — org-scoped token |
| 53 | − * `:error` — unknown / invalid token | |
| 55 | + * `:error` — unknown, expired or already used | |
| 56 | + | |
| 57 | + A scoped token is good for one runner, within | |
| 58 | + `registration_token_ttl_seconds/0` of being minted. Redeeming it is a | |
| 59 | + single `UPDATE … WHERE hash = … AND expires_at > now()` that clears | |
| 60 | + the hash, so two registrations racing on one token can't both win. | |
| 61 | + Run it inside the transaction that creates the runner (as | |
| 62 | + `register_with_token/2` does) so a registration that fails afterwards | |
| 63 | + doesn't burn the token. | |
| 64 | + | |
| 65 | + The global token is the operator's, for a pool of runners: it neither | |
| 66 | + expires nor wears out. | |
| 54 | 67 | """ |
| 55 | 68 | def consume_registration_token(token) when is_binary(token) and token != "" do |
| 56 | 69 | cond do |
| 57 | 70 | global_token_valid?(token) -> |
| 58 | 71 | {:ok, :global} |
| 59 | 72 | |
| 60 | − repo = lookup_repo_token(token) -> | |
| 73 | + repo = redeem(Repository, token) -> | |
| 61 | 74 | {:ok, repo} |
| 62 | 75 | |
| 63 | − org = lookup_org_token(token) -> | |
| 76 | + org = redeem(Organization, token) -> | |
| 64 | 77 | {:ok, org} |
| 65 | 78 | |
| 66 | 79 | true -> |
@@ -70,45 +83,73 @@ defmodule GitGud.Workflows.Runners do
| 70 | 83 | |
| 71 | 84 | def consume_registration_token(_), do: :error |
| 72 | 85 | |
| 73 | − defp lookup_repo_token(token) do | |
| 74 | − hash = token_hash(token) | |
| 75 | − Repo.get_by(Repository, runner_reg_token_hash: hash) | |
| 86 | + @doc """ | |
| 87 | + Redeem `token` and register a runner with `attrs` in its scope, all or | |
| 88 | + nothing. `{:ok, runner, bearer}`, `:error` for a bad token, or | |
| 89 | + `{:error, changeset}`. | |
| 90 | + """ | |
| 91 | + def register_with_token(token, attrs) do | |
| 92 | + Repo.transact(fn -> | |
| 93 | + with {:ok, scope} <- consume_registration_token(token), | |
| 94 | + {:ok, runner, bearer} <- register(attrs, scope) do | |
| 95 | + {:ok, {runner, bearer}} | |
| 96 | + else | |
| 97 | + :error -> {:error, :bad_token} | |
| 98 | + {:error, _} = err -> err | |
| 99 | + end | |
| 100 | + end) | |
| 101 | + |> case do | |
| 102 | + {:ok, {runner, bearer}} -> {:ok, runner, bearer} | |
| 103 | + {:error, :bad_token} -> :error | |
| 104 | + {:error, _} = err -> err | |
| 105 | + end | |
| 76 | 106 | end |
| 77 | 107 | |
| 78 | − defp lookup_org_token(token) do | |
| 108 | + defp redeem(schema, token) do | |
| 79 | 109 | hash = token_hash(token) |
| 80 | − Repo.get_by(Organization, runner_reg_token_hash: hash) | |
| 110 | + now = DateTime.utc_now(:second) | |
| 111 | + | |
| 112 | + from(s in schema, | |
| 113 | + where: s.runner_reg_token_hash == ^hash and s.runner_reg_token_expires_at > ^now, | |
| 114 | + select: s | |
| 115 | + ) | |
| 116 | + |> Repo.update_all(set: [runner_reg_token_hash: nil, runner_reg_token_expires_at: nil]) | |
| 117 | + |> case do | |
| 118 | + {1, [row]} -> row | |
| 119 | + _ -> nil | |
| 120 | + end | |
| 81 | 121 | end |
| 82 | 122 | |
| 83 | 123 | defp token_hash(token), do: :crypto.hash(:sha256, token) |
| 84 | 124 | |
| 125 | + @doc """ | |
| 126 | + How long a minted registration token stays good, in seconds: | |
| 127 | + | |
| 128 | + config :git_gud, GitGud.Workflows.Runners, registration_token_ttl_seconds: 86_400 | |
| 129 | + """ | |
| 130 | + def registration_token_ttl_seconds do | |
| 131 | + Application.get_env(:git_gud, __MODULE__, []) | |
| 132 | + |> Keyword.get(:registration_token_ttl_seconds, 86_400) | |
| 133 | + end | |
| 134 | + | |
| 85 | 135 | @doc """ |
| 86 | 136 | Generate (or regenerate) a registration token for a scope. Returns |
| 87 | 137 | `{:ok, bare_token}` — the bare token is **shown once**, only the |
| 88 | − hash is persisted. | |
| 138 | + hash is persisted. It registers one runner, within | |
| 139 | + `registration_token_ttl_seconds/0`. | |
| 89 | 140 | |
| 90 | 141 | Regenerating is destructive — any previously-distributed token for |
| 91 | 142 | that scope stops working immediately. |
| 92 | 143 | """ |
| 93 | − def mint_registration_token(%Repository{} = repo) do | |
| 94 | − token = mint_token() | |
| 95 | − hash = token_hash(token) | |
| 96 | − | |
| 97 | − repo | |
| 98 | − |> Ecto.Changeset.change(runner_reg_token_hash: hash) | |
| 99 | − |> Repo.update() | |
| 100 | − |> case do | |
| 101 | − {:ok, _} -> {:ok, token} | |
| 102 | − err -> err | |
| 103 | − end | |
| 104 | − end | |
| 105 | − | |
| 106 | − def mint_registration_token(%Organization{} = org) do | |
| 144 | + def mint_registration_token(%scope{} = record) when scope in [Repository, Organization] do | |
| 107 | 145 | token = mint_token() |
| 108 | − hash = token_hash(token) | |
| 146 | + expires_at = DateTime.add(DateTime.utc_now(:second), registration_token_ttl_seconds()) | |
| 109 | 147 | |
| 110 | − org | |
| 111 | − |> Ecto.Changeset.change(runner_reg_token_hash: hash) | |
| 148 | + record | |
| 149 | + |> Ecto.Changeset.change( | |
| 150 | + runner_reg_token_hash: token_hash(token), | |
| 151 | + runner_reg_token_expires_at: expires_at | |
| 152 | + ) | |
| 112 | 153 | |> Repo.update() |
| 113 | 154 | |> case do |
| 114 | 155 | {:ok, _} -> {:ok, token} |
@@ -118,9 +159,11 @@ defmodule GitGud.Workflows.Runners do
| 118 | 159 | |
| 119 | 160 | defp mint_token, do: :crypto.strong_rand_bytes(24) |> Base.url_encode64(padding: false) |
| 120 | 161 | |
| 121 | − @doc "True if `scope` currently has a registration token minted." | |
| 122 | − def has_token?(%Repository{runner_reg_token_hash: h}), do: not is_nil(h) | |
| 123 | − def has_token?(%Organization{runner_reg_token_hash: h}), do: not is_nil(h) | |
| 162 | + @doc "True if `scope` has a registration token that is still good." | |
| 163 | + def has_token?(%{runner_reg_token_hash: h, runner_reg_token_expires_at: %DateTime{} = exp}) | |
| 164 | + when is_binary(h), | |
| 165 | + do: DateTime.compare(exp, DateTime.utc_now()) == :gt | |
| 166 | + | |
| 124 | 167 | def has_token?(_), do: false |
| 125 | 168 | |
| 126 | 169 | # ── runner CRUD ────────────────────────────────────────────────────── |
modified
lib/git_gud_web/controllers/runner_controller.ex
+17
−17
@@ -15,8 +15,9 @@ defmodule GitGudWeb.RunnerController do
| 15 | 15 | etc.). |
| 16 | 16 | |
| 17 | 17 | Auth: all methods except Ping require `Authorization: Bearer <token>`. |
| 18 | − Register additionally requires the one-shot registration token in the | |
| 19 | − body, mirroring `forgejo-runner register --token`. | |
| 18 | + Register instead takes a registration token in the body, mirroring | |
| 19 | + `forgejo-runner register --token`: a repository's or organization's | |
| 20 | + is good for one runner and expires; the operator's global one is not. | |
| 20 | 21 | """ |
| 21 | 22 | |
| 22 | 23 | use GitGudWeb, :controller |
@@ -70,24 +71,23 @@ defmodule GitGudWeb.RunnerController do
| 70 | 71 | error(conn, :invalid_argument, "runner name is required") |
| 71 | 72 | |
| 72 | 73 | true -> |
| 73 | − case Runners.consume_registration_token(req.token) do | |
| 74 | + attrs = %{ | |
| 75 | + name: req.name, | |
| 76 | + version: req.version, | |
| 77 | + labels: %{"labels" => merged_labels(req)} | |
| 78 | + } | |
| 79 | + | |
| 80 | + # A repository's or organization's token registers one runner | |
| 81 | + # and expires; see `Runners.consume_registration_token/1`. | |
| 82 | + case Runners.register_with_token(req.token, attrs) do | |
| 74 | 83 | :error -> |
| 75 | − error(conn, :unauthenticated, "invalid registration token") | |
| 84 | + error(conn, :unauthenticated, "invalid, expired or already used registration token") | |
| 76 | 85 | |
| 77 | − {:ok, scope} -> | |
| 78 | − attrs = %{ | |
| 79 | − name: req.name, | |
| 80 | − version: req.version, | |
| 81 | − labels: %{"labels" => merged_labels(req)} | |
| 82 | − } | |
| 86 | + {:ok, runner, token} -> | |
| 87 | + respond(conn, %RegisterResponse{runner: render_runner_proto(runner, token)}) | |
| 83 | 88 | |
| 84 | − case Runners.register(attrs, scope) do | |
| 85 | − {:ok, runner, token} -> | |
| 86 | − respond(conn, %RegisterResponse{runner: render_runner_proto(runner, token)}) | |
| 87 | − | |
| 88 | − {:error, cs} -> | |
| 89 | − error(conn, :invalid_argument, format_errors(cs)) | |
| 90 | − end | |
| 89 | + {:error, cs} -> | |
| 90 | + error(conn, :invalid_argument, format_errors(cs)) | |
| 91 | 91 | end |
| 92 | 92 | end |
| 93 | 93 | end |
modified
lib/git_gud_web/live/org_live/settings_runners.ex
+13
−1
@@ -203,6 +203,9 @@ defmodule GitGudWeb.OrgLive.SettingsRunners do
| 203 | 203 | class="text-xs bg-base-200 p-3 rounded overflow-x-auto" |
| 204 | 204 | phx-no-curly-interpolation |
| 205 | 205 | ><%= @new_token %></pre> |
| 206 | + <p class="text-xs opacity-70" id="reg-token-terms"> | |
| 207 | + Registers one runner, within {token_ttl_label()}. | |
| 208 | + </p> | |
| 206 | 209 | <p class="text-xs opacity-70">Pick a deployment style:</p> |
| 207 | 210 | <.runner_register_commands |
| 208 | 211 | token={@new_token} |
@@ -212,7 +215,7 @@ defmodule GitGudWeb.OrgLive.SettingsRunners do
| 212 | 215 | </div> |
| 213 | 216 | <% Runners.has_token?(@org) -> %> |
| 214 | 217 | <p class="text-sm opacity-70"> |
| 215 | − Regenerate to invalidate the previously-distributed token. | |
| 218 | + An unused token is outstanding until {format_expiry(@org)}. Regenerate to invalidate it. | |
| 216 | 219 | </p> |
| 217 | 220 | <% true -> %> |
| 218 | 221 | <p class="text-sm opacity-70"> |
@@ -291,4 +294,13 @@ defmodule GitGudWeb.OrgLive.SettingsRunners do
| 291 | 294 | |
| 292 | 295 | defp format_dt(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M") |
| 293 | 296 | defp format_dt(_), do: "" |
| 297 | + | |
| 298 | + defp format_expiry(scope), do: format_dt(scope.runner_reg_token_expires_at) <> " UTC" | |
| 299 | + | |
| 300 | + defp token_ttl_label do | |
| 301 | + case Runners.registration_token_ttl_seconds() do | |
| 302 | + s when rem(s, 3600) == 0 -> "#{div(s, 3600)} hours" | |
| 303 | + s -> "#{div(s, 60)} minutes" | |
| 304 | + end | |
| 305 | + end | |
| 294 | 306 | end |
modified
lib/git_gud_web/live/repo_live/settings_runners.ex
+13
−1
@@ -232,6 +232,9 @@ defmodule GitGudWeb.RepoLive.SettingsRunners do
| 232 | 232 | class="text-xs bg-base-200 p-3 rounded overflow-x-auto" |
| 233 | 233 | phx-no-curly-interpolation |
| 234 | 234 | ><%= @new_token %></pre> |
| 235 | + <p class="text-xs opacity-70" id="reg-token-terms"> | |
| 236 | + Registers one runner, within {token_ttl_label()}. | |
| 237 | + </p> | |
| 235 | 238 | <p class="text-xs opacity-70"> |
| 236 | 239 | Pick a deployment style: |
| 237 | 240 | </p> |
@@ -243,7 +246,7 @@ defmodule GitGudWeb.RepoLive.SettingsRunners do
| 243 | 246 | </div> |
| 244 | 247 | <% Runners.has_token?(@repo) -> %> |
| 245 | 248 | <p class="text-sm opacity-70"> |
| 246 | − A token has been minted previously. Regenerate to invalidate the old token. | |
| 249 | + An unused token is outstanding until {format_expiry(@repo)}. Regenerate to invalidate it. | |
| 247 | 250 | </p> |
| 248 | 251 | <% true -> %> |
| 249 | 252 | <p class="text-sm opacity-70"> |
@@ -327,4 +330,13 @@ defmodule GitGudWeb.RepoLive.SettingsRunners do
| 327 | 330 | |
| 328 | 331 | defp format_dt(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M") |
| 329 | 332 | defp format_dt(_), do: "" |
| 333 | + | |
| 334 | + defp format_expiry(scope), do: format_dt(scope.runner_reg_token_expires_at) <> " UTC" | |
| 335 | + | |
| 336 | + defp token_ttl_label do | |
| 337 | + case Runners.registration_token_ttl_seconds() do | |
| 338 | + s when rem(s, 3600) == 0 -> "#{div(s, 3600)} hours" | |
| 339 | + s -> "#{div(s, 60)} minutes" | |
| 340 | + end | |
| 341 | + end | |
| 330 | 342 | end |
added
priv/repo/migrations/20261020181557_add_runner_reg_token_expiry.exs
+34
−0
@@ -0,0 +1,34 @@
| 1 | +defmodule GitGud.Repo.Migrations.AddRunnerRegTokenExpiry do | |
| 2 | + use Ecto.Migration | |
| 3 | + | |
| 4 | + # A repository's or organization's runner registration token used to be | |
| 5 | + # good forever and for any number of runners. Now it expires | |
| 6 | + # (`GitGud.Workflows.Runners`, 24h by default) and registers one runner. | |
| 7 | + # A token minted before this gets a day from the migration rather than | |
| 8 | + # stopping on the spot. | |
| 9 | + def up do | |
| 10 | + for table <- [:repositories, :organizations] do | |
| 11 | + alter table(table) do | |
| 12 | + add :runner_reg_token_expires_at, :utc_datetime | |
| 13 | + end | |
| 14 | + end | |
| 15 | + | |
| 16 | + flush() | |
| 17 | + | |
| 18 | + for table <- ~w(repositories organizations) do | |
| 19 | + execute """ | |
| 20 | + UPDATE #{table} | |
| 21 | + SET runner_reg_token_expires_at = date_trunc('second', now() AT TIME ZONE 'utc') + interval '1 day' | |
| 22 | + WHERE runner_reg_token_hash IS NOT NULL | |
| 23 | + """ | |
| 24 | + end | |
| 25 | + end | |
| 26 | + | |
| 27 | + def down do | |
| 28 | + for table <- [:repositories, :organizations] do | |
| 29 | + alter table(table) do | |
| 30 | + remove :runner_reg_token_expires_at | |
| 31 | + end | |
| 32 | + end | |
| 33 | + end | |
| 34 | +end |
modified
test/git_gud/workflows/scoped_runners_test.exs
+63
−0
@@ -34,6 +34,69 @@ defmodule GitGud.Workflows.ScopedRunnersTest do
| 34 | 34 | test "an unknown token is rejected" do |
| 35 | 35 | assert :error = Runners.consume_registration_token("definitely-not-a-token") |
| 36 | 36 | end |
| 37 | + | |
| 38 | + test "a token registers one runner", %{repo: repo} do | |
| 39 | + {:ok, token} = Runners.mint_registration_token(repo) | |
| 40 | + assert Runners.has_token?(GitGud.Repo.reload!(repo)) | |
| 41 | + | |
| 42 | + assert {:ok, _runner, _} = Runners.register_with_token(token, %{name: "first"}) | |
| 43 | + assert :error = Runners.register_with_token(token, %{name: "second"}) | |
| 44 | + refute Runners.has_token?(GitGud.Repo.reload!(repo)) | |
| 45 | + end | |
| 46 | + | |
| 47 | + test "an organization's token registers one runner too" do | |
| 48 | + user = GitGud.AccountsFixtures.user_fixture() | |
| 49 | + | |
| 50 | + {:ok, org} = | |
| 51 | + GitGud.Organizations.create_organization(user, %{ | |
| 52 | + "handle" => "org#{System.unique_integer([:positive])}" | |
| 53 | + }) | |
| 54 | + | |
| 55 | + {:ok, token} = Runners.mint_registration_token(org) | |
| 56 | + | |
| 57 | + assert {:ok, %GitGud.Organizations.Organization{}} = | |
| 58 | + Runners.consume_registration_token(token) | |
| 59 | + | |
| 60 | + assert :error = Runners.consume_registration_token(token) | |
| 61 | + end | |
| 62 | + | |
| 63 | + test "a token expires", %{repo: repo} do | |
| 64 | + {:ok, token} = Runners.mint_registration_token(repo) | |
| 65 | + | |
| 66 | + repo | |
| 67 | + |> GitGud.Repo.reload!() | |
| 68 | + |> Ecto.Changeset.change( | |
| 69 | + runner_reg_token_expires_at: DateTime.add(DateTime.utc_now(:second), -1) | |
| 70 | + ) | |
| 71 | + |> GitGud.Repo.update!() | |
| 72 | + | |
| 73 | + refute Runners.has_token?(GitGud.Repo.reload!(repo)) | |
| 74 | + assert :error = Runners.register_with_token(token, %{name: "late"}) | |
| 75 | + end | |
| 76 | + | |
| 77 | + test "expires after the configured time", %{repo: repo} do | |
| 78 | + Application.put_env(:git_gud, Runners, registration_token_ttl_seconds: 600) | |
| 79 | + on_exit(fn -> Application.delete_env(:git_gud, Runners) end) | |
| 80 | + | |
| 81 | + {:ok, _token} = Runners.mint_registration_token(repo) | |
| 82 | + exp = GitGud.Repo.reload!(repo).runner_reg_token_expires_at | |
| 83 | + assert_in_delta DateTime.diff(exp, DateTime.utc_now()), 600, 5 | |
| 84 | + end | |
| 85 | + | |
| 86 | + test "a registration that fails doesn't use the token up", %{repo: repo} do | |
| 87 | + {:ok, token} = Runners.mint_registration_token(repo) | |
| 88 | + | |
| 89 | + assert {:error, %Ecto.Changeset{}} = Runners.register_with_token(token, %{name: ""}) | |
| 90 | + assert {:ok, _runner, _} = Runners.register_with_token(token, %{name: "retry"}) | |
| 91 | + end | |
| 92 | + | |
| 93 | + test "the global token stays good for a pool" do | |
| 94 | + Application.put_env(:git_gud, Runners, registration_token: "pool-token") | |
| 95 | + on_exit(fn -> Application.delete_env(:git_gud, Runners) end) | |
| 96 | + | |
| 97 | + assert {:ok, _, _} = Runners.register_with_token("pool-token", %{name: "a"}) | |
| 98 | + assert {:ok, _, _} = Runners.register_with_token("pool-token", %{name: "b"}) | |
| 99 | + end | |
| 37 | 100 | end |
| 38 | 101 | |
| 39 | 102 | describe "register/2 with scope" do |
modified
test/git_gud_web/controllers/runner_controller_test.exs
+19
−0
@@ -84,6 +84,25 @@ defmodule GitGudWeb.RunnerControllerTest do
| 84 | 84 | |
| 85 | 85 | assert resp.status == 401 |
| 86 | 86 | end |
| 87 | + | |
| 88 | + test "a repository's token registers one runner", %{conn: conn} do | |
| 89 | + {_user, repo} = GitGud.ForgeFixtures.repository_fixture() | |
| 90 | + {:ok, reg} = GitGud.Workflows.Runners.mint_registration_token(repo) | |
| 91 | + | |
| 92 | + register = fn name -> | |
| 93 | + body = | |
| 94 | + %RegisterRequest{name: name, token: reg} | |
| 95 | + |> RegisterRequest.encode() | |
| 96 | + |> IO.iodata_to_binary() | |
| 97 | + | |
| 98 | + conn | |
| 99 | + |> put_req_header("content-type", "application/protobuf") | |
| 100 | + |> post("/api/actions/_apis/runner.v1.RunnerService/Register", body) | |
| 101 | + end | |
| 102 | + | |
| 103 | + assert register.("one").status == 200 | |
| 104 | + assert register.("two").status == 401 | |
| 105 | + end | |
| 87 | 106 | end |
| 88 | 107 | |
| 89 | 108 | describe "built-in CI vars" do |
Parents: 0d2522e