Adds `Dexcord.Api.Coverage`, a testable core that diffs the compiled endpoint route table against a pinned snapshot of Discord's official OpenAPI spec (`priv/discord_openapi.json`). The spec is a coverage CHECKLIST, not codegen input: it is refreshed only by the deliberate `mix dexcord.coverage.refresh`, so upstream churn never breaks CI spontaneously. - `mix dexcord.coverage` compiles then exits non-zero on any in-scope endpoint missing from the declared surface (AC1.8/AC1.9). - `mix dexcord.coverage.refresh` pins the spec via :httpc + castore, adding inets/ssl to the code path since Mix prunes them. - `priv/coverage_allowlist.exs` records out-of-scope spec routes with a comment per family; reconciled against the real pin (Social SDK lobbies/partner-sdk, OIDC, application-by-id, scheduled-event exceptions, join requests, thread search, monetization). - `.gitea/workflows/ci.yml` runs the suite + gate (Actions must be enabled on the Gitea remote). - ratelimit_test.exs gains AC1.10 spot-checks proving the new Phase 5 route shapes collapse to bounded, id-collapsed keys.
81 lines
2.7 KiB
Elixir
81 lines
2.7 KiB
Elixir
defmodule Mix.Tasks.Dexcord.Coverage.Refresh do
|
|
@shortdoc "Downloads and pins the latest Discord OpenAPI spec into priv/"
|
|
@moduledoc """
|
|
Refreshes `priv/discord_openapi.json` from the upstream
|
|
`discord/discord-api-spec` repo.
|
|
|
|
This is a DELIBERATE, human-initiated action — it is never run in CI. Pinning
|
|
means upstream churn can only affect coverage when an operator explicitly runs
|
|
this task, reviews the `git diff`, reconciles the allowlist, and reruns
|
|
`mix dexcord.coverage`.
|
|
"""
|
|
use Mix.Task
|
|
|
|
@url "https://raw.githubusercontent.com/discord/discord-api-spec/main/specs/openapi.json"
|
|
|
|
@impl Mix.Task
|
|
def run(_argv) do
|
|
# Mix prunes OTP apps it doesn't depend on from the code path, so inets/ssl
|
|
# (needed only by this deliberate, dev-only task) may not be loadable. Add
|
|
# their ebin dirs from the Erlang root before starting them.
|
|
ensure_otp_app_on_path(:ssl)
|
|
ensure_otp_app_on_path(:inets)
|
|
{:ok, _} = Application.ensure_all_started(:ssl)
|
|
{:ok, _} = Application.ensure_all_started(:inets)
|
|
|
|
dest = Path.join(:code.priv_dir(:dexcord), "discord_openapi.json")
|
|
File.mkdir_p!(Path.dirname(dest))
|
|
|
|
Mix.shell().info("fetching #{@url} ...")
|
|
|
|
body = fetch!(@url)
|
|
File.write!(dest, body)
|
|
|
|
Mix.shell().info("wrote #{dest} (#{byte_size(body)} bytes)")
|
|
|
|
Mix.shell().info(
|
|
"review `git diff priv/discord_openapi.json`, reconcile priv/coverage_allowlist.exs, then rerun `mix dexcord.coverage`"
|
|
)
|
|
end
|
|
|
|
# Adds an OTP application's ebin dir (under the Erlang root) to the code path
|
|
# if its modules aren't already loadable.
|
|
defp ensure_otp_app_on_path(app) do
|
|
if :code.lib_dir(app) == {:error, :bad_name} do
|
|
root = to_string(:code.root_dir())
|
|
|
|
case Path.wildcard(Path.join([root, "lib", "#{app}-*", "ebin"])) do
|
|
[ebin | _] -> :code.add_patha(String.to_charlist(ebin))
|
|
[] -> Mix.raise("cannot locate OTP application #{app} under #{root}/lib")
|
|
end
|
|
end
|
|
end
|
|
|
|
# Verifies the peer with castore's CA bundle so the pin can't be MITM'd.
|
|
defp fetch!(url) do
|
|
cacerts = CAStore.file_path() |> to_charlist()
|
|
|
|
http_opts = [
|
|
ssl: [
|
|
verify: :verify_peer,
|
|
cacertfile: cacerts,
|
|
depth: 3,
|
|
customize_hostname_check: [
|
|
match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
|
|
]
|
|
],
|
|
autoredirect: true
|
|
]
|
|
|
|
case :httpc.request(:get, {to_charlist(url), []}, http_opts, body_format: :binary) do
|
|
{:ok, {{_v, 200, _r}, _headers, body}} ->
|
|
body
|
|
|
|
{:ok, {{_v, status, _r}, _headers, _body}} ->
|
|
Mix.raise("failed to fetch spec: HTTP #{status}")
|
|
|
|
{:error, reason} ->
|
|
Mix.raise("failed to fetch spec: #{inspect(reason)}")
|
|
end
|
|
end
|
|
end
|