defmodule OpenAgents.Repositories.VisibilityJoinTest do
@moduledoc """
The executable enumeration behind REPOSITORY-001's visibility predicate.
`OpenAgents.Repositories.readable_by/2` is the one predicate that answers who
may read a repository. REPOSITORY-001 used to say every surface that lists or
resolves a repository composes it, which was more than its proof covered and
more than was true: four modules composed it and about thirty joined the
repositories table. Most of those thirty reach a row by an identifier a
caller already passed authorization for — a milestone's repository, a stack
entry's repository, a pull request's repository — and are not deciding
anything. Because nothing separated the two kinds, a module that *did* decide
visibility with its own restated join failed no proof.
The line this file draws, and enforces, is the one issue #175 proposed.
**A visibility decision** starts from something the caller supplied — an
`owner` and a `name`, or a listing with no prior authorization — and ends
with a row. Those go through one of the path resolvers below, or compose
`readable_by/2` into a listing.
**An ownership reach** starts from a row the caller was already authorized
for and follows `repository_id`. It decides nothing and owes nothing.
Three sets close the first kind:
1. `OpenAgents.Repositories`'s own `*_by_path*` exports, so a new way to turn
a caller-supplied path into a row is classified by which predicate it
applies before anything can call it,
2. the callers of the two that do not apply the reader's own predicate — the
unfiltered resolver, and the one that resolves as an anonymous reader —
read from compiled BEAM import tables,
3. every site in `lib/` that names the predicate's own terms, read from the
source tree, so a module that restates the join instead of composing it
lands in an undeclared file.
What this does not close: a listing that composes no predicate at all joins
no term and calls no resolver. REPOSITORY-001 records that residue.
"""
use OpenAgents.DataCase, async: true
import OpenAgents.AccountsFixtures
alias OpenAgents.Issues
alias OpenAgents.Repositories
# Every `OpenAgents.Repositories` export that turns a caller-supplied owner
# and name into a repository row, with the predicate it applies.
@path_resolvers %{
{:get_by_path!, 2} => :no_predicate,
{:get_public_by_path!, 2} => :readable_by_an_anonymous_reader,
{:get_visible_by_path!, 3} => :readable_by_the_caller,
{:visible_by_path, 3} => :readable_by_the_caller,
{:get_writable_by_path!, 3} => :writable_by_the_caller
}
# The one caller of the resolver that applies no predicate, and why.
@unfiltered_callers %{
OpenAgentsWeb.DeploymentController =>
"resolves the deployment plane's :workflow, :operator, and :system principals, " <>
"which are not users and which OpenAgents.Deployments.Authority gates instead"
}
# The callers that resolve as an anonymous reader even when a signed-in one
# is present. Each is a surface whose answer must not vary by reader.
@anonymous_callers %{
OpenAgents.Issues => "the public issue behind an owner/repo/number path",
OpenAgents.Projects => "the public project behind an owner/repo/number path",
OpenAgentsWeb.CommentController => "an agent actor, which holds no repository membership",
OpenAgentsWeb.IssueController => "an agent actor, which holds no repository membership",
OpenAgentsWeb.OgImageController => "an unauthenticated social card"
}
# Every module that composes the predicate into a listing. `readable_by/2` is
# defined in `OpenAgents.Repositories`, and a self-call carries no import
# edge, so the module that owns the predicate is named rather than derived.
@composers %{
OpenAgents.Repositories => "the repository list, its page, and the by-id read",
OpenAgents.Issues => "the workspace-wide issue list and the started-item read",
OpenAgents.Projects => "the workspace-wide project list and the project-item reads",
OpenAgents.Notifications => "the inbox read and the fan-out gate",
OpenAgents.DataRights.AccountExport =>
"the pull requests, stacks, and issue dependencies an export may carry",
OpenAgents.Forge.Anchor =>
"the repositories the published WAL anchor names, which is an anonymous " <>
"surface and must reach exactly what an anonymous reader already reaches",
OpenAgents.Plugins.ForgeSource =>
"the repositories the plugin registry index draws manifests from, which " <>
"is a public listing and must reach exactly what an anonymous reader " <>
"already reaches"
}
# Every site in `lib/` that names the predicate's own terms — a repository's
# `visibility` or `lifecycle_state` against `"public"` or `"ready"`.
#
# `:decides` means the site answers who may reach a repository and states the
# rule itself rather than composing `readable_by/2`. Each is a deliberate
# exception with a reason, and adding one is meant to be uncomfortable.
#
# `:reaches` means the site reads those fields on a row whose reach was
# already decided — a provisioning branch, a rendering choice, a system
# sweep, or a write.
@predicate_sites %{
"lib/openagents/repositories.ex" =>
{:decides, "defines readable_by/2 and the writable path resolver beside it"},
"lib/openagents/forge/git_http.ex" =>
{:decides,
"Git transport admits :operator, :machine, and :assignment principals that " <>
"readable_by/2 does not model; proven by test/openagents/forge/git_http_test.exs"},
"lib/openagents/deployments/authority.ex" =>
{:decides, "the read gate for the deployment plane's non-user principals"},
"lib/openagents/reputation.ex" =>
{:decides, "the attestation transparency tier, which is disclosure rather than row reach"},
"lib/openagents_web/repository_access.ex" =>
{:decides,
"a narrower file-level disclosure allowlist layered above row admission, " <>
"which it takes from Repositories.get_visible_by_path!/3"},
"lib/openagents/tools/workspace_publication.ex" =>
{:reaches, "a provisioning guard on a row already checked writable"},
"lib/openagents/repositories/provisioner.ex" =>
{:reaches, "writes the ready state at the end of provisioning"},
"lib/openagents/forge/janitor.ex" =>
{:reaches, "a system sweep of ready storage keys with no reader"},
"lib/openagents/forge/assignments.ex" =>
{:reaches, "a provisioning guard on the row an assignment credential names"},
"lib/openagents_web/live/home_live.ex" => {:reaches, "renders a provisioning badge"},
"lib/openagents_web/live/code_repo_live.ex" => {:reaches, "branches on provisioning state"},
"lib/openagents_web/live/code_blob_live.ex" =>
{:reaches, "refuses a blob read before provisioning finishes"},
"lib/openagents_web/controllers/repository_controller.ex" =>
{:reaches, "answers 201 or 202 by provisioning state"},
"lib/openagents_web/controllers/repository_import_controller.ex" =>
{:reaches, "answers 201 or 202 by provisioning state"},
"lib/openagents_web/controllers/repository_json.ex" =>
{:reaches, "reports the decision as the permissions.pull field"},
"lib/openagents_web/controllers/og_image_controller.ex" =>
{:reaches, "refuses a card before provisioning finishes"}
}
# A repository's own visibility terms. `@repository.` is excluded: that is a
# LiveView assign read inside a template, which renders a decision already
# made rather than making one.
@predicate_terms ~r/(visibility|lifecycle_state)\s*(==|:)\s*"(public|ready)"/
test "the path resolvers are exactly the ones classified by the predicate they apply" do
actual =
Repositories.__info__(:functions)
|> Enum.filter(fn {name, _arity} -> Atom.to_string(name) =~ "by_path" end)
assert_exact_set(
actual,
Map.keys(@path_resolvers),
"turns a caller-supplied owner and name into a repository row; " <>
"say which predicate it applies"
)
end
test "only the named surfaces resolve a repository path with no predicate" do
assert_exact_set(
callers_of({:get_by_path!, 2}),
Map.keys(@unfiltered_callers),
"resolves a repository path with no read predicate"
)
end
test "only the named surfaces resolve a repository path as an anonymous reader" do
assert_exact_set(
callers_of({:get_public_by_path!, 2}),
Map.keys(@anonymous_callers),
"resolves a repository path as an anonymous reader"
)
end
test "the modules composing the read predicate are exactly the ones named" do
assert_exact_set(
[Repositories | callers_of({:readable_by, 2})],
Map.keys(@composers),
"composes OpenAgents.Repositories.readable_by/2"
)
end
test "every site naming the predicate's terms is classified" do
assert_exact_set(
predicate_sites(),
Map.keys(@predicate_sites),
"names a repository's own visibility terms; say whether it decides reach " <>
"or reads a row whose reach was already decided"
)
end
describe "the resolvers agree because they compose one predicate" do
setup do
owner = repository_user_fixture("visibility-join-owner")
{:ok, repository, :created} =
Repositories.create_user_repository(
owner,
%{name: "half-provisioned", visibility: "public", default_branch: "main"},
"visibility-join-#{System.unique_integer([:positive])}"
)
%{owner: owner, repository: repository}
end
test "a public repository that is not ready is not resolved for anyone", context do
assert context.repository.visibility == "public"
refute context.repository.lifecycle_state == "ready"
path = [context.repository.namespace.slug, context.repository.name]
assert_raise Ecto.NoResultsError, fn ->
apply(Repositories, :get_public_by_path!, path)
end
assert Repositories.visible_by_path(
context.repository.namespace.slug,
context.repository.name,
nil
) == nil
end
test "an issue in a repository that is not ready resolves nowhere", context do
{:ok, issue} =
Issues.create_issue(
context.repository,
%{"title" => "Filed before provisioning finished"},
context.owner
)
# The restated join this replaced omitted `lifecycle_state`, so this
# issue resolved here and nowhere else (REPOSITORY-001).
assert_raise Ecto.NoResultsError, fn ->
Issues.get_issue_by_path!(
context.repository.namespace.slug,
context.repository.name,
issue.number
)
end
end
test "every membership role the database admits is a reading role", context do
# `readable_by/2` filters the membership join to `@all_roles`. Removing
# that filter reddens nothing, because every role the database admits
# reads — the filter is a guard for a role nobody has added yet. The
# vocabulary is pinned here instead, so a fifth role fails until someone
# says whether it reads, and the predicate gets re-checked then.
assert admitted_membership_roles() == ~w(contributor maintainer owner viewer)
# And the vocabulary is closed by the database, not by the changeset that
# also states it.
{:ok, ready} =
context.repository
|> Ecto.Changeset.change(lifecycle_state: "ready", ready_at: DateTime.utc_now())
|> Repo.update()
auditor = repository_user_fixture("visibility-join-auditor")
assert {:error, %Postgrex.Error{postgres: %{constraint: constraint}}} =
Repo.query(
"""
INSERT INTO repository_memberships (repository_id, user_id, role, inserted_at, updated_at)
VALUES ($1, $2, 'auditor', now(), now())
""",
[Ecto.UUID.dump!(ready.id), Ecto.UUID.dump!(auditor.id)]
)
assert constraint == "repository_memberships_role_check"
end
test "a private repository reaches its member and no one else", context do
{:ok, private, :created} =
Repositories.create_user_repository(
context.owner,
%{name: "members-only", visibility: "private", default_branch: "main"},
"visibility-join-private-#{System.unique_integer([:positive])}"
)
{:ok, ready} = mark_ready(private)
stranger = repository_user_fixture("visibility-join-stranger")
slug = private.namespace.slug
assert %{id: id} = Repositories.visible_by_path(slug, ready.name, context.owner)
assert id == ready.id
assert Repositories.visible_by_path(slug, ready.name, stranger) == nil
assert Repositories.visible_by_path(slug, ready.name, nil) == nil
end
end
# ── population ──────────────────────────────────────────────────────────
# Read from each compiled module's import table rather than from source text,
# so a comment cannot add a caller and an alias cannot hide one.
defp callers_of({function, arity}) do
{:ok, modules} = :application.get_key(:openagents, :modules)
wanted = {Repositories, function, arity}
Enum.filter(modules, fn module ->
with path when is_list(path) <- :code.which(module),
{:ok, {^module, [imports: imports]}} <- :beam_lib.chunks(path, [:imports]) do
wanted in imports
else
_unreadable -> false
end
end)
end
# Read from the source tree, because a restated join is text a person wrote
# and there is no import edge to find it by.
defp predicate_sites do
root = Path.expand("../../..", __DIR__)
Path.wildcard(Path.join(root, "lib/**/*.{ex,exs}"))
|> Enum.filter(fn path ->
path
|> File.read!()
|> String.split("\n")
|> Enum.reject(&String.contains?(&1, "@repository."))
|> Enum.any?(&Regex.match?(@predicate_terms, &1))
end)
|> Enum.map(&Path.relative_to(&1, root))
|> Enum.sort()
end
# Read from the check constraint the database enforces, not from the module
# attribute the query also names.
defp admitted_membership_roles do
%{rows: [[definition]]} =
Repo.query!("""
SELECT pg_get_constraintdef(oid) FROM pg_constraint
WHERE conname = 'repository_memberships_role_check'
""")
Regex.scan(~r/'([a-z_]+)'/, definition) |> Enum.map(&List.last/1) |> Enum.sort()
end
defp mark_ready(repository) do
repository
|> Ecto.Changeset.change(lifecycle_state: "ready", ready_at: DateTime.utc_now())
|> Repo.update()
end
defp assert_exact_set(actual, declared, what) do
actual = MapSet.new(actual)
declared = MapSet.new(declared)
assert MapSet.difference(actual, declared) |> MapSet.to_list() == [],
"""
Something that #{what} is not named in
test/openagents/repositories/visibility_join_test.exs. Amend
REPOSITORY-001 in INVARIANTS.md, then add it here.
"""
assert MapSet.difference(declared, actual) |> MapSet.to_list() == [],
"""
This test names something that no longer #{what}. Amend
REPOSITORY-001 in INVARIANTS.md, then remove it here.
"""
end
end