From 55ae5693f748e93dbb0089a3bd596e328a3ebe1f Mon Sep 17 00:00:00 2001 From: "Adam C. Stephens" Date: Mon, 31 Aug 2026 12:02:02 -0400 Subject: [PATCH] feat: answer a development instance's own HTTP so the pages render Refs: GS-34 Assisted-By: Claude Opus 5 --- config/dev.exs | 4 + lib/git_sync/dev_forge.ex | 113 +++++++++++++++++ lib/git_sync/dev_seeds.ex | 115 +++++++++++++++--- .../controllers/dev_session_controller.ex | 8 +- test/git_sync/dev_forge_test.exs | 79 ++++++++++++ test/git_sync/dev_seeds_test.exs | 43 ++++++- .../dev_session_controller_test.exs | 8 ++ 7 files changed, 347 insertions(+), 23 deletions(-) create mode 100644 lib/git_sync/dev_forge.ex create mode 100644 test/git_sync/dev_forge_test.exs diff --git a/config/dev.exs b/config/dev.exs index 8f73827..10d7255 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -51,6 +51,10 @@ config :git_sync, GitSyncWeb.Endpoint, # Enable dev routes for dashboard and mailbox config :git_sync, dev_routes: true +# Every outbound request is answered from the seeded data, so a development +# instance reaches no forge and needs no real credential. +config :git_sync, :req_options, plug: GitSync.DevForge, retry: false + # Do not include metadata nor timestamps in development logs config :logger, :default_formatter, format: "[$level] $message\n" diff --git a/lib/git_sync/dev_forge.ex b/lib/git_sync/dev_forge.ex new file mode 100644 index 0000000..233fd76 --- /dev/null +++ b/lib/git_sync/dev_forge.ex @@ -0,0 +1,113 @@ +if Application.compile_env(:git_sync, :dev_routes) do + defmodule GitSync.DevForge do + @moduledoc """ + Answers every outbound HTTP request a development instance makes, so the + pages that are driven by a forge's API render without a forge anywhere near + them. `config/dev.exs` installs it as the plug `GitSync.Http` builds each + request against, which is also what stops a dev instance reaching the real + Forgejo, GitHub or atproto hosts it is configured with. + + What it holds is what `GitSync.DevSeeds` seeded, and the only credential it + accepts is `GitSync.DevSeeds.token/0`. + + This module is compiled out of any build that does not set `:dev_routes`. + """ + + @behaviour Plug + + import Plug.Conn + + alias GitSync.DevSeeds + + @impl Plug + def init(options), do: options + + @impl Plug + def call(%Plug.Conn{} = conn, _options) do + {status, body} = answer(route(conn), conn) + + conn + |> put_resp_content_type("application/json") + |> send_resp(status, JSON.encode!(body)) + end + + # A `file://` base URL leaves the directory the seeded repositories are in + # in front of the path, so a Forgejo request arrives with its API path + # buried rather than at the root. + defp route(%Plug.Conn{path_info: path}) do + case Enum.split_while(path, &(&1 != "api")) do + {_disk, ["api" | rest]} -> ["api" | rest] + {path, []} -> path + end + end + + defp answer(["api", "v1", "user"], conn), do: authorized(conn, account()) + defp answer(["api", "v1", "user", "repos"], conn), do: authorized(conn, repos(:forgejo)) + + defp answer(["user"], conn), do: authorized(conn, account()) + defp answer(["user", "repos"], conn), do: authorized(conn, repos(:github)) + + defp answer(["xrpc", "com.atproto.identity.resolveHandle"], _conn), + do: {200, %{did: DevSeeds.account().did}} + + defp answer(["did:" <> _rest], _conn), do: {200, document()} + + defp answer(["xrpc", "com.atproto.repo.describeRepo"], _conn), + do: {200, %{did: DevSeeds.account().did}} + + defp answer(["xrpc", "com.atproto.repo.getRecord"], conn), do: knot(conn) + + defp answer(["xrpc", "com.atproto.repo.listRecords"], _conn), do: {200, %{records: records()}} + + defp answer(_path, _conn), + do: {404, %{message: "GitSync.DevForge stands in for no such host"}} + + defp authorized(%Plug.Conn{} = conn, body) do + if get_req_header(conn, "authorization") == ["Bearer " <> DevSeeds.token()], + do: {200, body}, + else: {401, %{message: "GitSync.DevForge holds no such credential"}} + end + + defp account, do: %{login: DevSeeds.operator(), id: 1} + + defp repos(kind) do + repo = DevSeeds.repo(kind) + + [%{full_name: repo, clone_url: DevSeeds.clone_url(kind, repo), private: false}] + end + + defp document do + %{ + id: DevSeeds.account().did, + alsoKnownAs: ["at://" <> DevSeeds.account().handle], + service: [ + %{ + id: "#atproto_pds", + type: "AtprotoPersonalDataServer", + serviceEndpoint: DevSeeds.account().pds_url + } + ] + } + end + + # A knot record under the appview's own account is how Tangled says it runs + # that knot, and so takes its pushes on the appview rather than the knot. + defp knot(%Plug.Conn{} = conn) do + appview = URI.parse(DevSeeds.account().base_url).host + rkey = conn |> fetch_query_params() |> Map.fetch!(:query_params) |> Map.get("rkey") + + if Enum.any?(DevSeeds.knots(), &(&1.host == rkey and &1.ssh_host == appview)), + do: {200, %{uri: "at://#{DevSeeds.account().did}/sh.tangled.knot/#{rkey}"}}, + else: {404, %{message: "GitSync.DevForge registers no such knot"}} + end + + defp records do + for knot <- DevSeeds.knots() do + %{ + uri: "at://#{DevSeeds.account().did}/sh.tangled.repo/#{knot.repo}", + value: %{knot: knot.host, name: knot.repo} + } + end + end + end +end diff --git a/lib/git_sync/dev_seeds.ex b/lib/git_sync/dev_seeds.ex index c38edb8..fdbb181 100644 --- a/lib/git_sync/dev_seeds.ex +++ b/lib/git_sync/dev_seeds.ex @@ -1,11 +1,15 @@ if Application.compile_env(:git_sync, :dev_routes) do defmodule GitSync.DevSeeds do @moduledoc """ - Fills a development database with a mirror that actually runs: two forges - whose repositories are bare clones on disk, and a source pointing at one of - them. No credential is stored, so a seeded instance cannot reach a real - forge — `GitSync.Forge.clone_url/3` is the base URL with the repository - appended, which a `file://` base URL satisfies without any client knowing. + Fills a development database with everything the pages need to render: two + forges whose repositories are bare clones on disk, a source pointing at one + of them, and a Tangled account with its knots pinned. + + The credentials it stores are the ones `GitSync.DevForge` answers to and + nothing else, and a `file://` base URL is all `GitSync.Forge.clone_url/3` + needs to reach a repository, so a seeded instance cannot touch a real + forge. GitHub's is deliberately stale, so the connections page shows an + unreachable forge next to a healthy one. This module is compiled out of any build that does not set `:dev_routes`. """ @@ -14,62 +18,137 @@ if Application.compile_env(:git_sync, :dev_routes) do alias GitSync.Connections alias GitSync.Destinations alias GitSync.Forge.Token + alias GitSync.Knot alias GitSync.Repo alias GitSync.Source alias GitSync.Sources + alias GitSync.Ssh @operator "dev" - @source_repo "acme/upstream" - @destination_repo "acme/mirror" + @token "dev" + @stale "expired" + + @repos %{forgejo: "acme/upstream", github: "acme/mirror"} + + @account %{ + base_url: "https://tangled.dev.invalid", + handle: "dev.tangled.dev.invalid", + did: "did:plc:devseeddevseeddevseed", + pds_url: "https://pds.dev.invalid" + } + + @knots [ + %{host: "knot1.tangled.dev.invalid", ssh_host: "tangled.dev.invalid", repo: "upstream"}, + %{host: "knot.dev.invalid", ssh_host: "knot.dev.invalid", repo: "notes"} + ] @doc """ The operator seat `GET /dev/login` signs in as. """ def operator, do: @operator + @doc """ + The access token `GitSync.DevForge` answers to. + """ + def token, do: @token + @doc """ The default home for the seeded repositories. """ def root, do: Path.expand("priv/dev_repos", File.cwd!()) + @doc """ + The repository a forge holds, and where it is on disk. + """ + def repo(kind), do: Map.fetch!(@repos, kind) + + def clone_url(kind, repo), do: "file://" <> Path.join([root(), to_string(kind), repo]) + + @doc """ + The Tangled account the seeded connection is saved under. + """ + def account, do: @account + + @doc """ + The knots the account's repositories live on. The first is one the appview + runs itself, so its pushes go to the appview rather than to the knot. + """ + def knots, do: @knots + @doc """ Seeds the database and the repositories behind it, leaving an instance that is already set up and already claimed. Running it again changes nothing. """ def seed(root \\ root()) do - origin = repository(root, :forgejo, @source_repo) - repository(root, :github, @destination_repo) + origin = repository(root, :forgejo, @repos.forgejo) + repository(root, :github, @repos.github) commit(origin) - forgejo = claim(connection(:forgejo, Path.join(root, "forgejo"))) - github = connection(:github, Path.join(root, "github")) + forgejo = claim(connection(:forgejo, root, @token)) + github = connection(:github, root, @stale) + knots(tangled()) source(forgejo, github) end - defp connection(kind, base_url) do + defp connection(kind, root, token) do Repo.get_by(Connection, kind: kind) || Repo.insert!(%Connection{ kind: kind, - base_url: "file://" <> base_url, + base_url: "file://" <> Path.join(root, to_string(kind)), client_id: "dev", - client_secret: "dev" + client_secret: "dev", + token: token }) end defp claim(%Connection{} = connection) do - {:ok, connection} = Connections.record_login(connection, @operator, %Token{}) + {:ok, connection} = + Connections.record_login(connection, @operator, %Token{access: connection.token}) + connection end + defp tangled do + connection = + Repo.get_by(Connection, kind: :tangled) || + Repo.insert!(struct(%Connection{kind: :tangled}, @account)) + + if connection.public_key do + connection + else + {:ok, connection} = Connections.generate_tangled_key(connection) + connection + end + end + + defp knots(%Connection{} = tangled) do + for knot <- @knots, is_nil(Repo.get_by(Knot, connection_id: tangled.id, host: knot.host)) do + Repo.insert!(%Knot{ + connection_id: tangled.id, + host: knot.host, + ssh_host: knot.ssh_host, + host_key: host_key(knot.ssh_host) + }) + end + end + + # Nothing on a development machine answers `ssh-keyscan`, so a key is + # generated and written as the `known_hosts` line a scan would have given. + defp host_key(ssh_host) do + {:ok, %{public: public}} = Ssh.generate_key() + + ssh_host <> " " <> String.trim(public) <> "\n" + end + defp source(forgejo, github) do - Repo.get_by(Source, repo: @source_repo) || + Repo.get_by(Source, repo: @repos.forgejo) || with {:ok, source} <- - Sources.create(%{connection_id: forgejo.id, repo: @source_repo}), + Sources.create(%{connection_id: forgejo.id, repo: @repos.forgejo}), {:ok, _} <- Destinations.create(source, %{ connection_id: github.id, - repo: @destination_repo + repo: @repos.github }) do source end diff --git a/lib/git_sync_web/controllers/dev_session_controller.ex b/lib/git_sync_web/controllers/dev_session_controller.ex index 08412e7..65f8fa3 100644 --- a/lib/git_sync_web/controllers/dev_session_controller.ex +++ b/lib/git_sync_web/controllers/dev_session_controller.ex @@ -2,7 +2,9 @@ if Application.compile_env(:git_sync, :dev_routes) do defmodule GitSyncWeb.DevSessionController do @moduledoc """ Signs in as the seeded operator without going near Forgejo, so a - development instance needs no identity provider and no real credential. + development instance needs no identity provider and no real credential. The + credential recorded is the seeded one, which `GitSync.DevForge` answers to, + so signing in leaves the connection as healthy as it found it. The seat is claimed through `GitSync.Connections.record_login/3` rather than around it, so the gate that refuses a second operator is still the one under @@ -19,7 +21,9 @@ if Application.compile_env(:git_sync, :dev_routes) do def create(conn, _params) do operator = DevSeeds.operator() - case Connections.record_login(Connections.forgejo(), operator, %Token{}) do + token = %Token{access: DevSeeds.token()} + + case Connections.record_login(Connections.forgejo(), operator, token) do {:ok, _connection} -> conn |> put_flash(:info, "Signed in as #{operator}.") diff --git a/test/git_sync/dev_forge_test.exs b/test/git_sync/dev_forge_test.exs new file mode 100644 index 0000000..8714f4d --- /dev/null +++ b/test/git_sync/dev_forge_test.exs @@ -0,0 +1,79 @@ +defmodule GitSync.DevForgeTest do + use GitSync.DataCase + + alias GitSync.Connections + alias GitSync.DevForge + alias GitSync.DevSeeds + alias GitSync.Forge + alias GitSync.Forge.Token + alias GitSync.Tangled.Identity + + setup do + root = Path.join(System.tmp_dir!(), "git-sync-forge-#{System.unique_integer([:positive])}") + options = Application.get_env(:git_sync, :req_options) + + Application.put_env(:git_sync, :req_options, plug: DevForge, retry: false) + DevSeeds.seed(root) + + on_exit(fn -> + Application.put_env(:git_sync, :req_options, options) + File.rm_rf!(root) + end) + + :ok + end + + test "accepts the credential the seeds hold for Forgejo" do + assert Forge.check(Connections.forgejo()) == :ok + end + + test "refuses the spent credential the seeds hold for GitHub" do + assert Forge.check(Connections.github()) == {:error, "GitHub returned HTTP 401"} + end + + test "lists the Forgejo repository the seeds put on disk" do + assert {:ok, [%{full_name: "acme/upstream", clone_url: clone_url}]} = + Forge.list_repos(Connections.forgejo()) + + assert String.starts_with?(clone_url, "file://") + assert String.ends_with?(clone_url, "/forgejo/acme/upstream") + end + + test "lists GitHub's repositories once a credential it accepts is stored" do + {:ok, github} = + Connections.store_token(Connections.github(), %Token{access: DevSeeds.token()}) + + assert {:ok, [%{full_name: "acme/mirror"}]} = Forge.list_repos(github) + end + + test "resolves the handle the seeded Tangled account was saved under" do + tangled = Connections.tangled() + + assert Identity.resolve(tangled.handle) == + {:ok, %{did: tangled.did, handle: tangled.handle, pds_url: tangled.pds_url}} + end + + test "answers for the Tangled account's repository server" do + assert Forge.check(Connections.tangled()) == :ok + end + + test "holds one Tangled repository on each knot the seeds pin" do + assert {:ok, repos} = Forge.list_repos(Connections.tangled()) + + assert Enum.map(repos, &(&1.full_name |> String.split("/") |> hd())) == + Enum.map(DevSeeds.knots(), & &1.host) + end + + test "runs the knots the appview takes pushes for itself" do + tangled = Connections.tangled() + [proxied, direct] = DevSeeds.knots() + + assert GitSync.Tangled.Client.appview_knot?(tangled, proxied.host) + refute GitSync.Tangled.Client.appview_knot?(tangled, direct.host) + end + + test "answers nothing it was not asked to stand in for" do + assert Forge.create_webhook(Connections.forgejo(), "acme/upstream", "http://dev", "s") == + {:error, "Forgejo returned HTTP 404"} + end +end diff --git a/test/git_sync/dev_seeds_test.exs b/test/git_sync/dev_seeds_test.exs index fd9c38c..d6c8f40 100644 --- a/test/git_sync/dev_seeds_test.exs +++ b/test/git_sync/dev_seeds_test.exs @@ -3,7 +3,9 @@ defmodule GitSync.DevSeedsTest do alias GitSync.Connections alias GitSync.DevSeeds + alias GitSync.Knots alias GitSync.Mirror + alias GitSync.Ssh alias GitSync.Sources setup do @@ -22,11 +24,46 @@ defmodule GitSync.DevSeedsTest do assert %{operator: "dev"} = Connections.forgejo() end - test "holds no credential, so nothing it seeds can reach a forge", %{root: root} do + test "holds only the credential GitSync.DevForge answers to", %{root: root} do DevSeeds.seed(root) - assert Connections.token(Connections.forgejo()) == nil - assert Connections.token(Connections.github()) == nil + assert %{access: token} = Connections.token(Connections.forgejo()) + assert token == DevSeeds.token() + assert %{access: "expired"} = Connections.token(Connections.github()) + end + + test "registers a GitHub application, so its form starts closed", %{root: root} do + DevSeeds.seed(root) + + assert Connections.github_enabled?() + end + + test "seeds a Tangled account with a key to copy", %{root: root} do + DevSeeds.seed(root) + + assert %{handle: handle, did: "did:plc:" <> _, public_key: public_key} = + Connections.tangled() + + assert handle == DevSeeds.account().handle + assert String.starts_with?(public_key, "ssh-ed25519 ") + end + + test "pins every knot with a key that fingerprints", %{root: root} do + DevSeeds.seed(root) + + knots = Knots.list(Connections.tangled()) + + assert Enum.map(knots, & &1.host) == Enum.map(DevSeeds.knots(), & &1.host) + assert Enum.all?(knots, &match?([_fingerprint], Ssh.fingerprints(&1.host_key))) + end + + test "seeding twice leaves the pinned knots alone", %{root: root} do + DevSeeds.seed(root) + keys = Enum.map(Knots.list(Connections.tangled()), & &1.host_key) + + DevSeeds.seed(root) + + assert Enum.map(Knots.list(Connections.tangled()), & &1.host_key) == keys end test "points the source and its destination at repositories on disk", %{root: root} do diff --git a/test/git_sync_web/controllers/dev_session_controller_test.exs b/test/git_sync_web/controllers/dev_session_controller_test.exs index 4627454..16b95f0 100644 --- a/test/git_sync_web/controllers/dev_session_controller_test.exs +++ b/test/git_sync_web/controllers/dev_session_controller_test.exs @@ -2,6 +2,7 @@ defmodule GitSyncWeb.DevSessionControllerTest do use GitSyncWeb.ConnCase alias GitSync.Connections + alias GitSync.DevSeeds setup :configure_forgejo @@ -13,6 +14,13 @@ defmodule GitSyncWeb.DevSessionControllerTest do assert %{operator: "dev"} = Connections.forgejo() end + test "leaves the seeded credential in place", %{conn: conn} do + get(conn, ~p"/dev/login") + + assert %{access: token} = Connections.token(Connections.forgejo()) + assert token == DevSeeds.token() + end + test "signing in twice does not trip the seat claim", %{conn: conn} do conn |> get(~p"/dev/login") |> recycle() -- 2.51.2