|
1
|
+ |
defmodule OpenAgents.Forge.RepoRefTest do
|
|
2
|
+ |
@moduledoc """
|
|
3
|
+ |
Issue #190: the name a person is told to use and the key the forge stores
|
|
4
|
+ |
under are different strings, and the verifier is the surface where confusing
|
|
5
|
+ |
them does the most damage.
|
|
6
|
+ |
|
|
7
|
+ |
A rehearsal reads `OpenAgents.Forge.Repos.allowed_repos/0`, gets a name, and
|
|
8
|
+ |
runs the verifier on it. Before this, the name went straight into a path, the
|
|
9
|
+ |
path held a bare repository projecting nothing, and the report said
|
|
10
|
+ |
`wal_unreadable` — "your write-ahead log is gone" — about a log that was
|
|
11
|
+ |
intact under a different key. These tests hold the name-to-key step in place
|
|
12
|
+ |
and hold the shadow directory harmless.
|
|
13
|
+ |
"""
|
|
14
|
+ |
|
|
15
|
+ |
use OpenAgents.DataCase, async: false
|
|
16
|
+ |
|
|
17
|
+ |
alias OpenAgents.Forge.{CacheReadiness, RepoRef, Repos, Sync, Verification, WAL}
|
|
18
|
+ |
|
|
19
|
+ |
@name "openagents.com"
|
|
20
|
+ |
@owner "OpenAgentsInc"
|
|
21
|
+ |
|
|
22
|
+ |
setup do
|
|
23
|
+ |
Ecto.Adapters.SQL.Sandbox.mode(OpenAgents.Repo, {:shared, self()})
|
|
24
|
+ |
|
|
25
|
+ |
root =
|
|
26
|
+ |
Path.join(
|
|
27
|
+ |
System.tmp_dir!(),
|
|
28
|
+ |
"forge-repo-ref-#{System.unique_integer([:positive, :monotonic])}"
|
|
29
|
+ |
)
|
|
30
|
+ |
|
|
31
|
+ |
previous_data = Application.get_env(:openagents, :forge_data_dir)
|
|
32
|
+ |
previous_wal = Application.get_env(:openagents, :forge_wal_dir)
|
|
33
|
+ |
previous_adapter = Application.get_env(:openagents, :forge_wal_adapter)
|
|
34
|
+ |
previous_repos = Application.get_env(:openagents, :forge_repos)
|
|
35
|
+ |
|
|
36
|
+ |
Application.put_env(:openagents, :forge_data_dir, Path.join(root, "data"))
|
|
37
|
+ |
Application.put_env(:openagents, :forge_wal_dir, Path.join(root, "wal"))
|
|
38
|
+ |
Application.put_env(:openagents, :forge_wal_adapter, OpenAgents.Forge.WAL.Local)
|
|
39
|
+ |
Application.put_env(:openagents, :forge_repos, [@name])
|
|
40
|
+ |
CacheReadiness.reset()
|
|
41
|
+ |
|
|
42
|
+ |
on_exit(fn ->
|
|
43
|
+ |
restore(:forge_data_dir, previous_data)
|
|
44
|
+ |
restore(:forge_wal_dir, previous_wal)
|
|
45
|
+ |
restore(:forge_wal_adapter, previous_adapter)
|
|
46
|
+ |
restore(:forge_repos, previous_repos)
|
|
47
|
+ |
CacheReadiness.reset()
|
|
48
|
+ |
File.rm_rf!(root)
|
|
49
|
+ |
end)
|
|
50
|
+ |
|
|
51
|
+ |
# Production's shape, exactly: the first repository's row has moved off the
|
|
52
|
+ |
# historical name-as-key and onto an opaque one, while the name it is
|
|
53
|
+ |
# cloned and configured by has not moved at all.
|
|
54
|
+ |
storage_key = Ecto.UUID.generate()
|
|
55
|
+ |
|
|
56
|
+ |
repository =
|
|
57
|
+ |
OpenAgents.Repo.get_by!(OpenAgents.Repositories.Repository,
|
|
58
|
+ |
owner_key: String.downcase(@owner),
|
|
59
|
+ |
name_key: @name
|
|
60
|
+ |
)
|
|
61
|
+ |
|
|
62
|
+ |
repository
|
|
63
|
+ |
|> Ecto.Changeset.change(storage_key: storage_key)
|
|
64
|
+ |
|> OpenAgents.Repo.update!()
|
|
65
|
+ |
|
|
66
|
+ |
sha = seed_wal!(root, storage_key)
|
|
67
|
+ |
|
|
68
|
+ |
%{root: root, storage_key: storage_key, sha: sha}
|
|
69
|
+ |
end
|
|
70
|
+ |
|
|
71
|
+ |
describe "the name a rehearsal is handed" do
|
|
72
|
+ |
test "verifies the repository the forge actually serves", context do
|
|
73
|
+ |
assert {:ok, report} = Verification.verify(@name)
|
|
74
|
+ |
|
|
75
|
+ |
assert report.repo == @name
|
|
76
|
+ |
assert report.storage_key == context.storage_key
|
|
77
|
+ |
assert report.entries == 1
|
|
78
|
+ |
assert report.findings == []
|
|
79
|
+ |
end
|
|
80
|
+ |
|
|
81
|
+ |
test "is the name the configuration admits", context do
|
|
82
|
+ |
# `allowed_repos/0` is the list an operator reads before verifying, so
|
|
83
|
+ |
# what it returns has to be a reference the verifier accepts.
|
|
84
|
+ |
assert [name] = Repos.allowed_repos()
|
|
85
|
+ |
assert {:ok, %{storage_key: storage_key}} = Verification.verify(name)
|
|
86
|
+ |
assert storage_key == context.storage_key
|
|
87
|
+ |
end
|
|
88
|
+ |
|
|
89
|
+ |
test "resolves the same through the owner/name path, in any case", context do
|
|
90
|
+ |
assert {:ok, %{storage_key: key}} = Verification.verify("#{@owner}/#{@name}")
|
|
91
|
+ |
assert key == context.storage_key
|
|
92
|
+ |
|
|
93
|
+ |
assert {:ok, %{storage_key: downcased}} =
|
|
94
|
+ |
Verification.verify("openagentsinc/#{@name}")
|
|
95
|
+ |
|
|
96
|
+ |
assert downcased == context.storage_key
|
|
97
|
+ |
end
|
|
98
|
+ |
|
|
99
|
+ |
test "is not shadowed by a bare repository standing under it", context do
|
|
100
|
+ |
# The live node carries exactly this: a bare repository under the *name*,
|
|
101
|
+ |
# holding one ref, beside the one under the key. It is a projection of no
|
|
102
|
+ |
# log, so it must not be able to answer for the repository.
|
|
103
|
+ |
shadow = seed_shadow!(context.root, @name)
|
|
104
|
+ |
refute Repos.refs_at(shadow)["refs/heads/main"] == context.sha
|
|
105
|
+ |
|
|
106
|
+ |
assert {:ok, report} = Verification.verify(@name)
|
|
107
|
+ |
assert report.storage_key == context.storage_key
|
|
108
|
+ |
assert report.findings == []
|
|
109
|
+ |
assert report.entries == 1
|
|
110
|
+ |
|
|
111
|
+ |
# And the shadow is still there afterwards: cleaning up production state
|
|
112
|
+ |
# is an operator's decision, not a side effect of reading it.
|
|
113
|
+ |
assert File.dir?(shadow)
|
|
114
|
+ |
end
|
|
115
|
+ |
end
|
|
116
|
+ |
|
|
117
|
+ |
describe "a reference that names no repository" do
|
|
118
|
+ |
test "is a typed finding rather than an empty repository", _context do
|
|
119
|
+ |
assert {:error, report} = Verification.verify("no-such-repository")
|
|
120
|
+ |
|
|
121
|
+ |
assert report.storage_key == nil
|
|
122
|
+ |
assert report.entries == 0
|
|
123
|
+ |
|
|
124
|
+ |
assert [%{code: "repository_not_found", detail: %{"repo" => "no-such-repository"}}] =
|
|
125
|
+ |
report.findings
|
|
126
|
+ |
|
|
127
|
+ |
# The old answer. `wal_unreadable` means the log of a known repository
|
|
128
|
+ |
# could not be read, which is a very different report from "that name is
|
|
129
|
+ |
# not a repository here".
|
|
130
|
+ |
refute Enum.any?(report.findings, &(&1.code == "wal_unreadable"))
|
|
131
|
+ |
end
|
|
132
|
+ |
|
|
133
|
+ |
test "reports an unknown owner/name path the same way", _context do
|
|
134
|
+ |
assert {:error, report} = Verification.verify("#{@owner}/no-such-repository")
|
|
135
|
+ |
assert report.storage_key == nil
|
|
136
|
+ |
assert [%{code: "repository_not_found"}] = report.findings
|
|
137
|
+ |
end
|
|
138
|
+ |
|
|
139
|
+ |
test "reports a name two repositories answer to as ambiguous", _context do
|
|
140
|
+ |
for owner <- ["FirstOwner", "SecondOwner"] do
|
|
141
|
+ |
{:ok, _repository} =
|
|
142
|
+ |
OpenAgents.Repositories.create_repository(%{
|
|
143
|
+ |
owner: owner,
|
|
144
|
+ |
name: "shared",
|
|
145
|
+ |
visibility: "public",
|
|
146
|
+ |
default_branch: "main",
|
|
147
|
+ |
storage_key: Ecto.UUID.generate()
|
|
148
|
+ |
})
|
|
149
|
+ |
end
|
|
150
|
+ |
|
|
151
|
+ |
assert {:error, %{storage_key: nil, findings: findings}} = Verification.verify("shared")
|
|
152
|
+ |
assert [%{code: "repository_name_ambiguous", detail: %{"repo" => "shared"}}] = findings
|
|
153
|
+ |
|
|
154
|
+ |
# Naming the owner settles it.
|
|
155
|
+ |
assert {:error, %{storage_key: storage_key, findings: settled}} =
|
|
156
|
+ |
Verification.verify("FirstOwner/shared")
|
|
157
|
+ |
|
|
158
|
+ |
refute is_nil(storage_key)
|
|
159
|
+ |
assert [%{code: "wal_unreadable"}] = settled
|
|
160
|
+ |
end
|
|
161
|
+ |
end
|
|
162
|
+ |
|
|
163
|
+ |
describe "a storage key" do
|
|
164
|
+ |
test "resolves to itself without consulting the repositories table", context do
|
|
165
|
+ |
assert RepoRef.storage_key(context.storage_key) == {:ok, context.storage_key}
|
|
166
|
+ |
assert {:ok, %{repo: repo, storage_key: key}} = Verification.verify(context.storage_key)
|
|
167
|
+ |
assert repo == context.storage_key
|
|
168
|
+ |
assert key == context.storage_key
|
|
169
|
+ |
end
|
|
170
|
+ |
|
|
171
|
+ |
test "resolves to itself when its log predates the repositories table", context do
|
|
172
|
+ |
# A repository whose key is its own name and which has no row at all —
|
|
173
|
+ |
# the shape of the forge's oldest logs. The WAL is what settles it.
|
|
174
|
+ |
_sha = seed_wal!(context.root, "legacy-key")
|
|
175
|
+ |
|
|
176
|
+ |
assert RepoRef.storage_key("legacy-key") == {:ok, "legacy-key"}
|
|
177
|
+ |
assert {:ok, %{findings: [], storage_key: "legacy-key"}} = Verification.verify("legacy-key")
|
|
178
|
+ |
end
|
|
179
|
+ |
end
|
|
180
|
+ |
|
|
181
|
+ |
# The live node's shadow: a bare repository under the *name*, holding one ref
|
|
182
|
+ |
# at a commit the served repository never had, and no WAL of its own.
|
|
183
|
+ |
defp seed_shadow!(root, name) do
|
|
184
|
+ |
stale = Path.join(root, "stale-source")
|
|
185
|
+ |
File.mkdir_p!(stale)
|
|
186
|
+ |
git!(stale, ["init", "--initial-branch=main"])
|
|
187
|
+ |
git!(stale, ["config", "user.email", "test@example.com"])
|
|
188
|
+ |
git!(stale, ["config", "user.name", "Forge test"])
|
|
189
|
+ |
File.write!(Path.join(stale, "README.md"), "left behind\n")
|
|
190
|
+ |
git!(stale, ["add", "README.md"])
|
|
191
|
+ |
git!(stale, ["commit", "-m", "A commit the served repository passed long ago"])
|
|
192
|
+ |
|
|
193
|
+ |
shadow = Repos.ensure_repo!(name)
|
|
194
|
+ |
{_output, 0} = Repos.git(shadow, ["fetch", stale, "main:refs/heads/main"])
|
|
195
|
+ |
|
|
196
|
+ |
shadow
|
|
197
|
+ |
end
|
|
198
|
+ |
|
|
199
|
+ |
defp restore(key, nil), do: Application.delete_env(:openagents, key)
|
|
200
|
+ |
defp restore(key, value), do: Application.put_env(:openagents, key, value)
|
|
201
|
+ |
|
|
202
|
+ |
# One real commit, bundled, recorded as one WAL entry, materialized into the
|
|
203
|
+ |
# bare repository the way a push would leave it.
|
|
204
|
+ |
defp seed_wal!(root, storage_key) do
|
|
205
|
+ |
source = Path.join(root, "source-#{storage_key}")
|
|
206
|
+ |
File.mkdir_p!(source)
|
|
207
|
+ |
git!(source, ["init", "--initial-branch=main"])
|
|
208
|
+ |
git!(source, ["config", "user.email", "test@example.com"])
|
|
209
|
+ |
git!(source, ["config", "user.name", "Forge test"])
|
|
210
|
+ |
File.write!(Path.join(source, "README.md"), "served\n")
|
|
211
|
+ |
git!(source, ["add", "README.md"])
|
|
212
|
+ |
git!(source, ["commit", "-m", "Served commit"])
|
|
213
|
+ |
|
|
214
|
+ |
sha = source |> git!(["rev-parse", "HEAD"]) |> String.trim()
|
|
215
|
+ |
bundle = Path.join(root, "#{storage_key}.bundle")
|
|
216
|
+ |
git!(source, ["bundle", "create", bundle, "--all"])
|
|
217
|
+ |
|
|
218
|
+ |
{:ok, object} = WAL.put_entry_file(storage_key, 0, bundle)
|
|
219
|
+ |
|
|
220
|
+ |
entry = %{
|
|
221
|
+ |
"seq" => 0,
|
|
222
|
+ |
"object" => object,
|
|
223
|
+ |
"format" => "git_bundle",
|
|
224
|
+ |
"refs" => %{"refs/heads/main" => sha},
|
|
225
|
+ |
"principal" => "test:repo-ref",
|
|
226
|
+ |
"pushed_at" => DateTime.to_iso8601(DateTime.utc_now())
|
|
227
|
+ |
}
|
|
228
|
+ |
|
|
229
|
+ |
{:ok, _generation} =
|
|
230
|
+ |
WAL.cas_index(storage_key, :none, WAL.append_entry(WAL.new_index(), entry))
|
|
231
|
+ |
|
|
232
|
+ |
:ok = Sync.ensure_fresh(storage_key)
|
|
233
|
+ |
|
|
234
|
+ |
sha
|
|
235
|
+ |
end
|
|
236
|
+ |
|
|
237
|
+ |
defp git!(dir, args) do
|
|
238
|
+ |
{output, 0} = System.cmd("git", ["-C", dir | args], stderr_to_stdout: true)
|
|
239
|
+ |
output
|
|
240
|
+ |
end
|
|
241
|
+ |
end
|