defmodule Tarakan.GitSSHTest do
  @moduledoc """
  End-to-end SSH tests driven by real `git` + OpenSSH clients against the
  OTP `:ssh` daemon.
  """

  use Tarakan.DataCase, async: false

  import Tarakan.AccountsFixtures

  alias Tarakan.Accounts
  alias Tarakan.Accounts.SshKeys
  alias Tarakan.HostedRepositories

  @moduletag :git_client
  @moduletag :tmp_dir

  setup %{tmp_dir: tmp_dir} do
    on_exit(fn ->
      File.rm_rf(Application.fetch_env!(:tarakan, Tarakan.HostedRepositories)[:root])
    end)

    Tarakan.RepositoryCode.Cache.clear()

    start_supervised!(
      {Tarakan.GitSSH.Server, enabled: true, port: 0, host_key_dir: "tmp/test_ssh_host"}
    )

    {:ok, port} = Tarakan.GitSSH.Server.bound_port()

    account = account_fixture()
    scope = Accounts.scope_for_account(account)
    {:ok, repository} = HostedRepositories.create(scope, %{"name" => "sshhosted"})

    key_path = Path.join(tmp_dir, "id_ed25519")

    {_output, 0} =
      System.cmd("ssh-keygen", ["-t", "ed25519", "-f", key_path, "-N", "", "-q"],
        stderr_to_stdout: true
      )

    {:ok, ssh_key} =
      SshKeys.add_key(account, %{
        "name" => "test key",
        "public_key" => File.read!(key_path <> ".pub")
      })

    %{
      port: port,
      account: account,
      repository: repository,
      key_path: key_path,
      ssh_key: ssh_key
    }
  end

  defp git_env(key_path) do
    # tmp_dir contains the test name (spaces, quotes) - the -i path must be
    # quoted or git misparses GIT_SSH_COMMAND.
    ssh_command =
      ~s(ssh -i "#{key_path}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ) <>
        "-o IdentitiesOnly=yes -o IdentityAgent=none"

    [
      {"GIT_SSH_COMMAND", ssh_command},
      {"GIT_TERMINAL_PROMPT", "0"},
      {"GIT_CONFIG_GLOBAL", "/dev/null"},
      {"GIT_CONFIG_SYSTEM", "/dev/null"},
      {"GIT_AUTHOR_NAME", "t"},
      {"GIT_AUTHOR_EMAIL", "t@example.com"},
      {"GIT_COMMITTER_NAME", "t"},
      {"GIT_COMMITTER_EMAIL", "t@example.com"}
    ]
  end

  defp git(key_path, args) do
    System.cmd("git", args, stderr_to_stdout: true, env: git_env(key_path))
  end

  defp ssh_url(%{port: port, account: account}, name),
    do: "ssh://git@127.0.0.1:#{port}/#{account.handle}/#{name}.git"

  defp seed_local_repository(tmp_dir, key_path) do
    work = Path.join(tmp_dir, "seed")
    File.mkdir_p!(work)
    {_out, 0} = git(key_path, ["init", "--quiet", "-b", "main", work])
    File.write!(Path.join(work, "README.md"), "# Over SSH\n")
    {_out, 0} = git(key_path, ["-C", work, "add", "."])
    {_out, 0} = git(key_path, ["-C", work, "commit", "--quiet", "-m", "seed"])
    work
  end

  test "push, clone, and fetch round-trip over SSH", context do
    %{tmp_dir: tmp_dir, key_path: key_path, repository: repository, ssh_key: ssh_key} = context
    work = seed_local_repository(tmp_dir, key_path)
    url = ssh_url(context, "sshhosted")

    {output, status} = git(key_path, ["-C", work, "push", "--quiet", url, "main"])
    assert status == 0, output

    repository = Repo.reload!(repository)
    assert repository.default_branch == "main"
    assert %DateTime{} = repository.pushed_at

    assert %DateTime{} = Repo.reload!(ssh_key).last_used_at

    dest = Path.join(tmp_dir, "clone")
    {output, status} = git(key_path, ["clone", "--quiet", url, dest])
    assert status == 0, output
    assert File.read!(Path.join(dest, "README.md")) == "# Over SSH\n"

    # A second push then a pull exercises multi-round negotiation, which is
    # why SSH transport must not run in stateless-rpc mode.
    File.write!(Path.join(work, "second.txt"), "more\n")
    {_out, 0} = git(key_path, ["-C", work, "add", "."])
    {_out, 0} = git(key_path, ["-C", work, "commit", "--quiet", "-m", "second"])
    {_out, 0} = git(key_path, ["-C", work, "push", "--quiet", url, "main"])

    {output, status} = git(key_path, ["-C", dest, "pull", "--quiet", "origin", "main"])
    assert status == 0, output
    assert File.read!(Path.join(dest, "second.txt")) == "more\n"
  end

  test "an unregistered key cannot authenticate", context do
    %{tmp_dir: tmp_dir} = context

    rogue_key = Path.join(tmp_dir, "rogue")

    {_output, 0} =
      System.cmd("ssh-keygen", ["-t", "ed25519", "-f", rogue_key, "-N", "", "-q"],
        stderr_to_stdout: true
      )

    dest = Path.join(tmp_dir, "clone")
    {output, status} = git(rogue_key, ["clone", "--quiet", ssh_url(context, "sshhosted"), dest])
    assert status != 0
    assert output =~ "Permission denied" or output =~ "denied"
  end

  test "a registered key without membership can read but not push a hosted repository",
       context do
    %{tmp_dir: tmp_dir, key_path: key_path} = context

    other = account_fixture()
    other_key = Path.join(tmp_dir, "other")

    {_output, 0} =
      System.cmd("ssh-keygen", ["-t", "ed25519", "-f", other_key, "-N", "", "-q"],
        stderr_to_stdout: true
      )

    {:ok, _key} =
      SshKeys.add_key(other, %{
        "name" => "other key",
        "public_key" => File.read!(other_key <> ".pub")
      })

    work = seed_local_repository(tmp_dir, key_path)
    url = ssh_url(context, "sshhosted")

    {_output, 0} = git(key_path, ["-C", work, "push", "--quiet", url, "main"])

    dest = Path.join(tmp_dir, "clone")
    {_output, status} = git(other_key, ["clone", "--quiet", url, dest])
    assert status == 0

    {output, status} = git(other_key, ["-C", work, "push", "--quiet", url, "main"])
    assert status != 0

    assert output =~ "repository not found" or output =~ "denied" or
             output =~ "not authorized"
  end

  test "a key cannot log in as somebody else's handle", context do
    %{tmp_dir: tmp_dir, key_path: key_path, port: port} = context

    other = account_fixture()

    url = "ssh://#{other.handle}@127.0.0.1:#{port}/#{other.handle}/anything.git"
    dest = Path.join(tmp_dir, "clone")

    {output, status} = git(key_path, ["clone", "--quiet", url, dest])
    assert status != 0
    assert output =~ "Permission denied" or output =~ "denied", "unexpected output: #{output}"
  end

  # OTP calls `is_auth_key/3` for unsigned key queries as well as for signature
  # verification, so a client can get a public key it does not own recorded
  # against its connection. OpenSSH always signs with the first key the server
  # accepts, so the ambiguity cannot be produced with a real client - it is
  # driven at the handoff table, which is the thing the guard governs.
  describe "auth handoff" do
    defp resolved_account(entries) do
      table = Tarakan.GitSSH.Server.auth_table()
      :ets.delete(table, self())
      for entry <- entries, do: :ets.insert(table, entry)

      {:ok, state} = Tarakan.GitSSH.Channel.init([])
      {:ok, state} = Tarakan.GitSSH.Channel.handle_msg({:ssh_channel_up, 0, self()}, state)
      state.account
    end

    test "resolves the account when every recognized key belongs to it", %{
      account: account,
      ssh_key: ssh_key
    } do
      resolved =
        resolved_account([
          {self(), account.id, ssh_key.id},
          # A second key of the same account: normal, and unambiguous.
          {self(), account.id, ssh_key.id + 1}
        ])

      assert resolved.id == account.id
    end

    test "refuses when keys for different accounts were recognized", %{
      account: account,
      ssh_key: ssh_key
    } do
      other = account_fixture()

      assert resolved_account([
               {self(), account.id, ssh_key.id},
               {self(), other.id, ssh_key.id + 1}
             ]) == nil
    end

    test "an exec is refused when the account could not be resolved", context do
      %{key_path: key_path, port: port, account: account} = context

      # Sanity: with no handoff entry and the conventional `git` username there
      # is nothing to resolve, and the channel must not guess.
      table = Tarakan.GitSSH.Server.auth_table()
      :ets.delete_all_objects(table)

      {output, status} =
        git(key_path, [
          "ls-remote",
          "ssh://git@127.0.0.1:#{port}/#{account.handle}/sshhosted.git"
        ])

      # Either OTP re-authenticates and repopulates the table (success), or the
      # channel fails closed. What must never happen is access as someone else.
      assert status == 0 or output =~ "access denied" or output =~ "denied"
    end
  end

  test "arbitrary exec commands are refused", context do
    %{key_path: key_path, port: port} = context

    {output, status} =
      System.cmd(
        "ssh",
        [
          "-i",
          key_path,
          "-o",
          "StrictHostKeyChecking=no",
          "-o",
          "UserKnownHostsFile=/dev/null",
          "-o",
          "IdentitiesOnly=yes",
          "-p",
          "#{port}",
          "git@127.0.0.1",
          "ls -la /"
        ],
        stderr_to_stdout: true
      )

    assert status != 0
    assert output =~ "only git-upload-pack and git-receive-pack are supported"
  end

  test "shell sessions are refused", context do
    %{key_path: key_path, port: port} = context

    {_output, status} =
      System.cmd(
        "ssh",
        [
          "-i",
          key_path,
          "-o",
          "StrictHostKeyChecking=no",
          "-o",
          "UserKnownHostsFile=/dev/null",
          "-o",
          "IdentitiesOnly=yes",
          "-T",
          "-p",
          "#{port}",
          "git@127.0.0.1"
        ],
        stderr_to_stdout: true
      )

    assert status != 0
  end

  test "traversal-shaped repository paths are rejected", context do
    %{tmp_dir: tmp_dir, key_path: key_path, port: port} = context

    for path <- ["../../etc/passwd", "a/../../b.git", "'; ls '"] do
      url = "ssh://git@127.0.0.1:#{port}/#{path}"
      dest = Path.join(tmp_dir, "clone-#{System.unique_integer([:positive])}")
      {output, status} = git(key_path, ["clone", "--quiet", url, dest])
      assert status != 0

      assert output =~ "only git-upload-pack" or output =~ "repository not found",
             "unexpected output for #{path}: #{output}"
    end
  end

  test "pushes are refused when the repository is over its storage quota", context do
    %{tmp_dir: tmp_dir, key_path: key_path, repository: repository} = context

    quota = Tarakan.HostedRepositories.Storage.quota_bytes()

    repository
    |> Ecto.Changeset.change(disk_size_bytes: quota + 1)
    |> Repo.update!()

    work = seed_local_repository(tmp_dir, key_path)

    {output, status} =
      git(key_path, ["-C", work, "push", "--quiet", ssh_url(context, "sshhosted"), "main"])

    assert status != 0
    assert output =~ "over its storage quota", "unexpected output: #{output}"
  end

  test "a second exec on the same channel is refused", context do
    %{tmp_dir: tmp_dir, port: port, account: account} = context

    {:ok, conn} =
      :ssh.connect(~c"127.0.0.1", port,
        user: ~c"git",
        user_dir: String.to_charlist(tmp_dir),
        silently_accept_hosts: true,
        auth_methods: ~c"publickey"
      )

    {:ok, channel} = :ssh_connection.session_channel(conn, 5_000)
    command = ~c"git-upload-pack '#{account.handle}/sshhosted.git'"

    assert :ssh_connection.exec(conn, channel, command, 5_000) in [:ok, :success]
    assert :failure = :ssh_connection.exec(conn, channel, command, 5_000)

    :ok = :ssh.close(conn)
  end
end
