Initial commit on Forgejo
Fresh repository history for elektrine/tarakan hosted at https://git.elektrine.com/elektrine/tarakan.
This commit is contained in:
commit
af6077b9c3
455 changed files with 78366 additions and 0 deletions
219
test/tarakan_web/controllers/account_session_controller_test.exs
Normal file
219
test/tarakan_web/controllers/account_session_controller_test.exs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
defmodule TarakanWeb.AccountSessionControllerTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
import Tarakan.AccountsFixtures
|
||||
alias Tarakan.Accounts
|
||||
|
||||
setup do
|
||||
%{unconfirmed_account: unconfirmed_account_fixture(), account: account_fixture()}
|
||||
end
|
||||
|
||||
describe "POST /accounts/log-in - handle/email and password" do
|
||||
test "logs the account in", %{conn: conn, account: account} do
|
||||
account = set_password(account)
|
||||
|
||||
conn =
|
||||
post(conn, ~p"/accounts/log-in", %{
|
||||
"account" => %{"identifier" => account.handle, "password" => valid_account_password()}
|
||||
})
|
||||
|
||||
assert get_session(conn, :account_token)
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
|
||||
# Now do a logged in request and assert on the menu
|
||||
conn = get(conn, ~p"/")
|
||||
response = html_response(conn, 200)
|
||||
assert response =~ "@#{account.handle}"
|
||||
assert response =~ ~p"/accounts/settings"
|
||||
assert response =~ ~p"/accounts/log-out"
|
||||
end
|
||||
|
||||
test "logs the account in with remember me", %{conn: conn, account: account} do
|
||||
account = set_password(account)
|
||||
|
||||
conn =
|
||||
post(conn, ~p"/accounts/log-in", %{
|
||||
"account" => %{
|
||||
"identifier" => account.email,
|
||||
"password" => valid_account_password(),
|
||||
"remember_me" => "true"
|
||||
}
|
||||
})
|
||||
|
||||
assert conn.resp_cookies["_tarakan_web_account_remember_me"]
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
end
|
||||
|
||||
test "logs the account in with return to", %{conn: conn, account: account} do
|
||||
account = set_password(account)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> init_test_session(account_return_to: "/foo/bar")
|
||||
|> post(~p"/accounts/log-in", %{
|
||||
"account" => %{
|
||||
"identifier" => account.handle,
|
||||
"password" => valid_account_password()
|
||||
}
|
||||
})
|
||||
|
||||
assert redirected_to(conn) == "/foo/bar"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!"
|
||||
end
|
||||
|
||||
test "redirects to login page with invalid credentials", %{conn: conn, account: account} do
|
||||
conn =
|
||||
post(conn, ~p"/accounts/log-in?mode=password", %{
|
||||
"account" => %{"identifier" => account.handle, "password" => "invalid_password"}
|
||||
})
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
|
||||
"Invalid handle, email, or password"
|
||||
|
||||
assert redirected_to(conn) == ~p"/accounts/log-in"
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /accounts/log-in - magic link" do
|
||||
test "logs the account in", %{conn: conn, account: account} do
|
||||
{token, _hashed_token} = generate_account_magic_link_token(account)
|
||||
|
||||
conn =
|
||||
post(conn, ~p"/accounts/log-in", %{
|
||||
"account" => %{"token" => token}
|
||||
})
|
||||
|
||||
assert get_session(conn, :account_token)
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
|
||||
# Now do a logged in request and assert on the menu
|
||||
conn = get(conn, ~p"/")
|
||||
response = html_response(conn, 200)
|
||||
assert response =~ "@#{account.handle}"
|
||||
assert response =~ ~p"/accounts/settings"
|
||||
assert response =~ ~p"/accounts/log-out"
|
||||
end
|
||||
|
||||
test "confirms unconfirmed account", %{conn: conn, unconfirmed_account: account} do
|
||||
{token, _hashed_token} = generate_account_magic_link_token(account)
|
||||
refute account.confirmed_at
|
||||
|
||||
conn =
|
||||
post(conn, ~p"/accounts/log-in", %{
|
||||
"account" => %{"token" => token},
|
||||
"_action" => "confirmed"
|
||||
})
|
||||
|
||||
assert get_session(conn, :account_token)
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Account confirmed successfully."
|
||||
|
||||
assert Accounts.get_account!(account.id).confirmed_at
|
||||
|
||||
# Now do a logged in request and assert on the menu
|
||||
conn = get(conn, ~p"/")
|
||||
response = html_response(conn, 200)
|
||||
assert response =~ "@#{account.handle}"
|
||||
assert response =~ ~p"/accounts/settings"
|
||||
assert response =~ ~p"/accounts/log-out"
|
||||
end
|
||||
|
||||
test "redirects to login page when magic link is invalid", %{conn: conn} do
|
||||
conn =
|
||||
post(conn, ~p"/accounts/log-in", %{
|
||||
"account" => %{"token" => "invalid"}
|
||||
})
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
|
||||
"The link is invalid or it has expired."
|
||||
|
||||
assert redirected_to(conn) == ~p"/accounts/log-in"
|
||||
end
|
||||
|
||||
test "redirects to login page for a crafted non-base64 magic link", %{conn: conn} do
|
||||
conn =
|
||||
post(conn, ~p"/accounts/log-in", %{
|
||||
"account" => %{"token" => "not base64!"}
|
||||
})
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
|
||||
"The link is invalid or it has expired."
|
||||
|
||||
assert redirected_to(conn) == ~p"/accounts/log-in"
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /accounts/update-password" do
|
||||
test "updates the password for a confirmed account in sudo mode", %{
|
||||
conn: conn,
|
||||
account: account
|
||||
} do
|
||||
conn =
|
||||
conn
|
||||
|> log_in_account(account)
|
||||
|> post(~p"/accounts/update-password", %{
|
||||
"account" => %{
|
||||
"password" => valid_account_password(),
|
||||
"password_confirmation" => valid_account_password()
|
||||
}
|
||||
})
|
||||
|
||||
assert redirected_to(conn) == ~p"/accounts/settings"
|
||||
assert Accounts.get_account_by_email_and_password(account.email, valid_account_password())
|
||||
end
|
||||
|
||||
test "redirects to re-authentication when the sudo window expired", %{
|
||||
conn: conn,
|
||||
account: account
|
||||
} do
|
||||
stale_at = DateTime.add(DateTime.utc_now(:second), -3, :hour)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> log_in_account(account, token_authenticated_at: stale_at)
|
||||
|> post(~p"/accounts/update-password", %{
|
||||
"account" => %{
|
||||
"password" => valid_account_password(),
|
||||
"password_confirmation" => valid_account_password()
|
||||
}
|
||||
})
|
||||
|
||||
assert redirected_to(conn) == ~p"/accounts/log-in?return_to=%2Faccounts%2Fsettings"
|
||||
refute Accounts.get_account_by_email_and_password(account.email, valid_account_password())
|
||||
end
|
||||
|
||||
test "refuses to establish a password before the email is confirmed", %{
|
||||
conn: conn,
|
||||
unconfirmed_account: account
|
||||
} do
|
||||
conn =
|
||||
conn
|
||||
|> log_in_account(account)
|
||||
|> post(~p"/accounts/update-password", %{
|
||||
"account" => %{
|
||||
"password" => valid_account_password(),
|
||||
"password_confirmation" => valid_account_password()
|
||||
}
|
||||
})
|
||||
|
||||
assert redirected_to(conn) == ~p"/accounts/settings"
|
||||
refute Accounts.get_account!(account.id).hashed_password
|
||||
end
|
||||
end
|
||||
|
||||
describe "DELETE /accounts/log-out" do
|
||||
test "logs the account out", %{conn: conn, account: account} do
|
||||
conn = conn |> log_in_account(account) |> delete(~p"/accounts/log-out")
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
refute get_session(conn, :account_token)
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully"
|
||||
end
|
||||
|
||||
test "succeeds even if the account is not logged in", %{conn: conn} do
|
||||
conn = delete(conn, ~p"/accounts/log-out")
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
refute get_session(conn, :account_token)
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully"
|
||||
end
|
||||
end
|
||||
end
|
||||
148
test/tarakan_web/controllers/api/client_auth_controller_test.exs
Normal file
148
test/tarakan_web/controllers/api/client_auth_controller_test.exs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
defmodule TarakanWeb.API.ClientAuthControllerTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Tarakan.Accounts.ApiCredentials
|
||||
|
||||
test "browser approval exchanges a device code for one scoped credential", %{conn: conn} do
|
||||
start_response =
|
||||
conn
|
||||
|> post(~p"/api/client-auth/start", %{client_name: "Tarakan CLI on laptop"})
|
||||
|> json_response(201)
|
||||
|
||||
assert start_response["device_code"] =~ "trkd_"
|
||||
assert start_response["user_code"] =~ ~r/^[A-Z2-7]{4}-[A-Z2-7]{4}$/
|
||||
|
||||
assert start_response["verification_uri_complete"] =~
|
||||
"/client/authorize/#{start_response["user_code"]}"
|
||||
|
||||
pending =
|
||||
build_conn()
|
||||
|> post(~p"/api/client-auth/exchange", %{device_code: start_response["device_code"]})
|
||||
|> json_response(400)
|
||||
|
||||
assert pending == %{"error" => "authorization_pending"}
|
||||
|
||||
account = account_fixture()
|
||||
|
||||
{:ok, view, _html} =
|
||||
build_conn()
|
||||
|> log_in_account(account)
|
||||
|> live(~p"/client/authorize/#{start_response["user_code"]}")
|
||||
|
||||
assert has_element?(view, "#client-authorization-code")
|
||||
assert has_element?(view, "#client-authorization-approve-button")
|
||||
|
||||
view
|
||||
|> element("#client-authorization-approve-button")
|
||||
|> render_click()
|
||||
|
||||
assert has_element?(view, "#client-authorization-approved")
|
||||
|
||||
exchange_response =
|
||||
build_conn()
|
||||
|> post(~p"/api/client-auth/exchange", %{device_code: start_response["device_code"]})
|
||||
|> json_response(200)
|
||||
|
||||
assert exchange_response["token"] =~ "trkn_"
|
||||
assert exchange_response["token_type"] == "Bearer"
|
||||
assert "tasks:read" in exchange_response["scopes"]
|
||||
assert "tasks:claim" in exchange_response["scopes"]
|
||||
assert "contributions:write" in exchange_response["scopes"]
|
||||
assert "reviews:submit" in exchange_response["scopes"]
|
||||
refute "reviews:verify" in exchange_response["scopes"]
|
||||
refute "reviews:read" in exchange_response["scopes"]
|
||||
|
||||
assert {:ok, ^account, credential} = ApiCredentials.authenticate(exchange_response["token"])
|
||||
assert credential.name == "Tarakan CLI on laptop"
|
||||
|
||||
# Device-minted credentials are short-lived (agent blast-radius control).
|
||||
expires_in_days = DateTime.diff(credential.expires_at, DateTime.utc_now(), :day)
|
||||
assert expires_in_days in 6..7
|
||||
|
||||
consumed =
|
||||
build_conn()
|
||||
|> post(~p"/api/client-auth/exchange", %{device_code: start_response["device_code"]})
|
||||
|> json_response(400)
|
||||
|
||||
assert consumed == %{"error" => "invalid_device_code"}
|
||||
|
||||
revoke_conn =
|
||||
build_conn()
|
||||
|> put_req_header("authorization", "Bearer #{exchange_response["token"]}")
|
||||
|> delete(~p"/api/client-auth/session")
|
||||
|
||||
assert response(revoke_conn, 204) == ""
|
||||
assert :error = ApiCredentials.authenticate(exchange_response["token"])
|
||||
end
|
||||
|
||||
test "the browser can deny a login without issuing a credential", %{conn: conn} do
|
||||
start_response =
|
||||
conn
|
||||
|> post(~p"/api/client-auth/start", %{})
|
||||
|> json_response(201)
|
||||
|
||||
{:ok, view, _html} =
|
||||
build_conn()
|
||||
|> log_in_account(account_fixture())
|
||||
|> live(~p"/client/authorize/#{start_response["user_code"]}")
|
||||
|
||||
view
|
||||
|> element("#client-authorization-deny-button")
|
||||
|> render_click()
|
||||
|
||||
assert has_element?(view, "#client-authorization-denied")
|
||||
|
||||
denied =
|
||||
build_conn()
|
||||
|> post(~p"/api/client-auth/exchange", %{device_code: start_response["device_code"]})
|
||||
|> json_response(403)
|
||||
|
||||
assert denied == %{"error" => "access_denied"}
|
||||
end
|
||||
|
||||
test "authorization page requires a signed-in web account", %{conn: conn} do
|
||||
start_response =
|
||||
conn
|
||||
|> post(~p"/api/client-auth/start", %{})
|
||||
|> json_response(201)
|
||||
|
||||
assert {:error, {:redirect, %{to: path}}} =
|
||||
live(build_conn(), ~p"/client/authorize/#{start_response["user_code"]}")
|
||||
|
||||
assert path == ~p"/accounts/log-in"
|
||||
end
|
||||
|
||||
test "a signed-in session can approve without recent reauth", %{conn: conn} do
|
||||
start_response =
|
||||
conn
|
||||
|> post(~p"/api/client-auth/start", %{})
|
||||
|> json_response(201)
|
||||
|
||||
return_to = ~p"/client/authorize/#{start_response["user_code"]}"
|
||||
stale_at = DateTime.add(DateTime.utc_now(:second), -9 * 60, :minute)
|
||||
|
||||
{:ok, view, _html} =
|
||||
build_conn()
|
||||
|> log_in_account(account_fixture(), token_authenticated_at: stale_at)
|
||||
|> live(return_to)
|
||||
|
||||
assert has_element?(view, "#client-authorization-approve-button")
|
||||
|
||||
view
|
||||
|> element("#client-authorization-approve-button")
|
||||
|> render_click()
|
||||
|
||||
assert has_element?(view, "#client-authorization-approved")
|
||||
end
|
||||
|
||||
test "invalid device codes fail without revealing authorization state", %{conn: conn} do
|
||||
response =
|
||||
conn
|
||||
|> post(~p"/api/client-auth/exchange", %{device_code: "not-a-device-code"})
|
||||
|> json_response(400)
|
||||
|
||||
assert response == %{"error" => "invalid_device_code"}
|
||||
end
|
||||
end
|
||||
142
test/tarakan_web/controllers/api/finding_controller_test.exs
Normal file
142
test/tarakan_web/controllers/api/finding_controller_test.exs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
defmodule TarakanWeb.API.FindingControllerTest do
|
||||
use TarakanWeb.ConnCase
|
||||
|
||||
setup do
|
||||
submitter = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
%{repository: repository, submitter: submitter}
|
||||
end
|
||||
|
||||
test "returns a serialized public finding", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
submitter: submitter
|
||||
} do
|
||||
scan =
|
||||
repository
|
||||
|> scan_fixture(submitter, %{"findings_json" => findings_json_with_disclosure_fixture()})
|
||||
|> publish_scan("public")
|
||||
|
||||
[finding] = scan.findings
|
||||
|
||||
conn = get(conn, ~p"/api/findings/#{finding.public_id}")
|
||||
|
||||
assert %{"finding" => data} = json_response(conn, 200)
|
||||
assert data["public_id"] == finding.public_id
|
||||
assert data["title"] == finding.title
|
||||
assert data["severity"] == "high"
|
||||
assert data["status"] == "verified"
|
||||
assert data["description"] =~ "String-built SQL"
|
||||
assert data["reproduction_steps"] =~ "Run the payload"
|
||||
assert data["affected_versions"] == ">= 1.0, < 2.0"
|
||||
assert data["cwe_id"] == "CWE-89"
|
||||
assert data["cve_id"] == "CVE-2026-12345"
|
||||
assert data["file_path"] == "lib/example/module_1.ex"
|
||||
assert data["line_start"] == 10
|
||||
assert data["line_end"] == 15
|
||||
assert data["first_seen_commit_sha"] == scan.commit_sha
|
||||
assert data["last_seen_commit_sha"] == scan.commit_sha
|
||||
assert data["verified_at"]
|
||||
assert is_map(data["counters"])
|
||||
assert data["repository"] == %{"host" => "github.com", "owner" => "openai", "name" => "codex"}
|
||||
assert data["record_url"] == TarakanWeb.Endpoint.url() <> "/findings/#{finding.public_id}"
|
||||
assert data["inserted_at"]
|
||||
end
|
||||
|
||||
test "404s for a restricted finding", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
submitter: submitter
|
||||
} do
|
||||
scan = scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
|
||||
{:ok, _restricted} =
|
||||
Tarakan.Scans.update_visibility(
|
||||
Tarakan.Accounts.Scope.for_account(moderator_account_fixture()),
|
||||
scan,
|
||||
"restricted",
|
||||
%{
|
||||
"moderation_reason" => "takedown_review",
|
||||
"moderation_notes" => "Restricted in this test to keep it off the public API."
|
||||
}
|
||||
)
|
||||
|
||||
[finding] = scan.findings
|
||||
|
||||
conn = get(conn, ~p"/api/findings/#{finding.public_id}")
|
||||
assert json_response(conn, 404)["error"] =~ "not found"
|
||||
end
|
||||
|
||||
test "404s for a summary-only finding", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
submitter: submitter
|
||||
} do
|
||||
scan =
|
||||
repository
|
||||
|> scan_fixture(submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
|> publish_scan("public_summary")
|
||||
|
||||
[finding] = scan.findings
|
||||
|
||||
conn = get(conn, ~p"/api/findings/#{finding.public_id}")
|
||||
assert json_response(conn, 404)["error"] =~ "not found"
|
||||
end
|
||||
|
||||
test "404s for an unknown public id", %{conn: conn} do
|
||||
conn = get(conn, ~p"/api/findings/#{Ecto.UUID.generate()}")
|
||||
assert json_response(conn, 404)["error"] =~ "not found"
|
||||
end
|
||||
|
||||
defp findings_json_with_disclosure_fixture do
|
||||
Jason.encode!(%{
|
||||
"tarakan_scan_format" => 1,
|
||||
"findings" => [
|
||||
%{
|
||||
"file" => "lib/example/module_1.ex",
|
||||
"line_start" => 10,
|
||||
"line_end" => 15,
|
||||
"severity" => "high",
|
||||
"title" => "Unsanitized input reaches interpolated query (1)",
|
||||
"description" => "String-built SQL executed with request parameters.",
|
||||
"reproduction" => "1. Check out the pinned commit\n2. Run the payload",
|
||||
"affected_versions" => ">= 1.0, < 2.0",
|
||||
"cwe" => "CWE-89",
|
||||
"cve" => "CVE-2026-12345"
|
||||
}
|
||||
]
|
||||
})
|
||||
end
|
||||
|
||||
defp publish_scan(scan, visibility) do
|
||||
scan = confirmation_fixture(scan, reviewer_account_fixture())
|
||||
scan = confirmation_fixture(scan, reviewer_account_fixture())
|
||||
|
||||
scope = Tarakan.Accounts.Scope.for_account(moderator_account_fixture())
|
||||
|
||||
if visibility == "public" do
|
||||
scan.repository
|
||||
|> Tarakan.Repositories.Repository.participation_changeset(%{
|
||||
participation_mode: "maintainer_verified"
|
||||
})
|
||||
|> Tarakan.Repo.update!()
|
||||
end
|
||||
|
||||
{:ok, scan} =
|
||||
Tarakan.Scans.accept_scan(scope, scan, %{
|
||||
"moderation_reason" => "evidence_reviewed",
|
||||
"moderation_notes" =>
|
||||
"Two independent reviewers supplied reproducible evidence for the pinned commit."
|
||||
})
|
||||
|
||||
{:ok, scan} =
|
||||
Tarakan.Scans.update_visibility(scope, scan, visibility, %{
|
||||
"moderation_reason" => "disclosure_reviewed",
|
||||
"moderation_notes" =>
|
||||
"Disclosure was separately reviewed for scope, secrets, and personal data.",
|
||||
"sensitive_data_reviewed" => "true"
|
||||
})
|
||||
|
||||
scan
|
||||
end
|
||||
end
|
||||
153
test/tarakan_web/controllers/api/finding_signals_api_test.exs
Normal file
153
test/tarakan_web/controllers/api/finding_signals_api_test.exs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
defmodule TarakanWeb.API.FindingSignalsApiTest do
|
||||
@moduledoc """
|
||||
The three worker submission endpoints that had no HTTP surface until now:
|
||||
calibrated severity, code embeddings, and cluster detectors.
|
||||
"""
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
import Ecto.Query, only: [from: 2]
|
||||
|
||||
alias Tarakan.Accounts.ApiCredentials
|
||||
alias Tarakan.Repo
|
||||
alias Tarakan.Scans.CanonicalFinding
|
||||
|
||||
setup %{conn: conn} do
|
||||
submitter = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
|
||||
findings =
|
||||
Jason.encode!(%{
|
||||
"tarakan_scan_format" => 1,
|
||||
"findings" => [
|
||||
%{
|
||||
"file" => "lib/vulnerable.ex",
|
||||
"severity" => "critical",
|
||||
"title" => "Command injection #{System.unique_integer([:positive])}",
|
||||
"description" => "User input reaches System.cmd without escaping."
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
scan_fixture(repository, submitter, %{"findings_json" => findings})
|
||||
finding = Repo.one!(from f in CanonicalFinding, where: f.repository_id == ^repository.id)
|
||||
|
||||
reviewer = reviewer_account_fixture()
|
||||
|
||||
{:ok, token, _cred} =
|
||||
ApiCredentials.create(reviewer, %{
|
||||
name: "signals",
|
||||
scopes: ["reviews:read", "reviews:verify"]
|
||||
})
|
||||
|
||||
%{
|
||||
conn: put_req_header(conn, "authorization", "Bearer #{token}"),
|
||||
anon: build_conn(),
|
||||
repository: repository,
|
||||
finding: finding
|
||||
}
|
||||
end
|
||||
|
||||
defp signal_path(repository, finding, suffix) do
|
||||
"/api/github.com/#{repository.owner}/#{repository.name}/findings/#{finding.public_id}/#{suffix}"
|
||||
end
|
||||
|
||||
describe "POST .../severity" do
|
||||
test "stores a rescore beside the submitter's claim", ctx do
|
||||
conn =
|
||||
post(ctx.conn, signal_path(ctx.repository, ctx.finding, "severity"), %{
|
||||
"severity" => "low",
|
||||
"rubric" => "rubric-v1"
|
||||
})
|
||||
|
||||
assert %{"calibrated_severity" => "low"} = json_response(conn, 200)
|
||||
assert Repo.get!(CanonicalFinding, ctx.finding.id).severity == "critical"
|
||||
end
|
||||
|
||||
test "rejects a severity outside the scale", ctx do
|
||||
conn =
|
||||
post(ctx.conn, signal_path(ctx.repository, ctx.finding, "severity"), %{
|
||||
"severity" => "catastrophic",
|
||||
"rubric" => "rubric-v1"
|
||||
})
|
||||
|
||||
assert %{"errors" => %{"calibrated_severity" => _}} = json_response(conn, 422)
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST .../embedding" do
|
||||
test "stores the vector and assigns a cluster", ctx do
|
||||
conn =
|
||||
post(ctx.conn, signal_path(ctx.repository, ctx.finding, "embedding"), %{
|
||||
"embedding" => [0.1, 0.9, 0.2],
|
||||
"embedding_model" => "test-embed-v1"
|
||||
})
|
||||
|
||||
assert %{"code_pattern_key" => key} = json_response(conn, 200)
|
||||
assert key =~ ~r/^code:/
|
||||
end
|
||||
|
||||
test "rejects an all-zero vector, which would match everything", ctx do
|
||||
conn =
|
||||
post(ctx.conn, signal_path(ctx.repository, ctx.finding, "embedding"), %{
|
||||
"embedding" => [0.0, 0.0, 0.0],
|
||||
"embedding_model" => "test-embed-v1"
|
||||
})
|
||||
|
||||
assert %{"errors" => %{"embedding" => _}} = json_response(conn, 422)
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /api/patterns/:key/rule" do
|
||||
@rule """
|
||||
rules:
|
||||
- id: tarakan-cmd-injection
|
||||
message: user input reaches System.cmd
|
||||
severity: ERROR
|
||||
languages: [elixir]
|
||||
patterns:
|
||||
- pattern: System.cmd($C, $A)
|
||||
"""
|
||||
|
||||
test "accepts a validated detector and marks it servable", ctx do
|
||||
conn =
|
||||
post(ctx.conn, "/api/patterns/code:abc/rule", %{
|
||||
"rule_yaml" => @rule,
|
||||
"language" => "elixir",
|
||||
"checked_count" => 3,
|
||||
"matched_count" => 2,
|
||||
"matched_finding_ids" => [Ecto.UUID.generate(), Ecto.UUID.generate()]
|
||||
})
|
||||
|
||||
assert %{"servable" => true, "matched_count" => 2} = json_response(conn, 201)
|
||||
end
|
||||
|
||||
test "a detector that matched nothing is stored but not servable", ctx do
|
||||
conn =
|
||||
post(ctx.conn, "/api/patterns/code:abc/rule", %{
|
||||
"rule_yaml" => @rule,
|
||||
"checked_count" => 3,
|
||||
"matched_count" => 0,
|
||||
"matched_finding_ids" => []
|
||||
})
|
||||
|
||||
assert %{"servable" => false, "validated_at" => nil} = json_response(conn, 201)
|
||||
end
|
||||
|
||||
test "rejects a match count with no receipts", ctx do
|
||||
conn =
|
||||
post(ctx.conn, "/api/patterns/code:abc/rule", %{
|
||||
"rule_yaml" => @rule,
|
||||
"checked_count" => 3,
|
||||
"matched_count" => 2,
|
||||
"matched_finding_ids" => []
|
||||
})
|
||||
|
||||
assert %{"errors" => %{"matched_finding_ids" => _}} = json_response(conn, 422)
|
||||
end
|
||||
|
||||
test "anonymous submissions are refused", ctx do
|
||||
conn = post(ctx.anon, "/api/patterns/code:abc/rule", %{"rule_yaml" => @rule})
|
||||
assert json_response(conn, 401)
|
||||
end
|
||||
end
|
||||
end
|
||||
137
test/tarakan_web/controllers/api/infestation_controller_test.exs
Normal file
137
test/tarakan_web/controllers/api/infestation_controller_test.exs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
defmodule TarakanWeb.API.InfestationControllerTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
import Ecto.Query, only: [from: 2]
|
||||
|
||||
alias Tarakan.FindingMemory
|
||||
|
||||
setup do
|
||||
submitter = github_account_fixture()
|
||||
other = github_account_fixture()
|
||||
repo_a = listed_github_repository_fixture(submitter)
|
||||
{:ok, repo_b} = Tarakan.Repositories.register_github_repository("acme/widget", other)
|
||||
repo_b = listed_repository_fixture(repo_b)
|
||||
|
||||
title = "API infestation sample #{System.unique_integer([:positive])}"
|
||||
|
||||
findings = fn path ->
|
||||
Jason.encode!(%{
|
||||
"tarakan_scan_format" => 1,
|
||||
"findings" => [
|
||||
%{
|
||||
"file" => path,
|
||||
"line_start" => 1,
|
||||
"line_end" => 2,
|
||||
"severity" => "high",
|
||||
"title" => title,
|
||||
"description" => "Shared issue class for the patterns API test."
|
||||
}
|
||||
]
|
||||
})
|
||||
end
|
||||
|
||||
scan_fixture(repo_a, submitter, %{"findings_json" => findings.("lib/a.ex")})
|
||||
scan_fixture(repo_b, other, %{"findings_json" => findings.("src/b.rs")})
|
||||
|
||||
%{pattern_key: FindingMemory.pattern_key(title), title: title}
|
||||
end
|
||||
|
||||
test "lists patterns", %{conn: conn, pattern_key: pattern_key} do
|
||||
conn = get(conn, ~p"/api/infestations")
|
||||
|
||||
assert %{"patterns" => patterns} = json_response(conn, 200)
|
||||
assert pattern = Enum.find(patterns, &(&1["pattern_key"] == pattern_key))
|
||||
assert pattern["repo_count"] >= 2
|
||||
assert pattern["instance_count"] >= 2
|
||||
assert pattern["record_url"] =~ "/infestations/#{pattern_key}"
|
||||
end
|
||||
|
||||
test "whitelists query params", %{conn: conn, pattern_key: pattern_key} do
|
||||
conn = get(conn, ~p"/api/infestations?days=13&min_repos=0&limit=5000")
|
||||
assert %{"patterns" => _} = json_response(conn, 200)
|
||||
|
||||
conn = get(conn, ~p"/api/infestations?days=7&min_repos=2&limit=10")
|
||||
assert %{"patterns" => patterns} = json_response(conn, 200)
|
||||
assert Enum.any?(patterns, &(&1["pattern_key"] == pattern_key))
|
||||
end
|
||||
|
||||
test "shows a pattern with its first page of instances", %{
|
||||
conn: conn,
|
||||
pattern_key: pattern_key,
|
||||
title: title
|
||||
} do
|
||||
conn = get(conn, ~p"/api/infestations/#{pattern_key}")
|
||||
|
||||
assert %{"pattern" => pattern, "instances" => instances} = json_response(conn, 200)
|
||||
assert pattern["pattern_key"] == pattern_key
|
||||
assert pattern["title"] =~ title
|
||||
|
||||
assert length(instances) >= 2
|
||||
assert Enum.any?(instances, &(&1["repository"]["owner"] == "openai"))
|
||||
assert Enum.any?(instances, &(&1["repository"]["owner"] == "acme"))
|
||||
end
|
||||
|
||||
test "404s for an unknown pattern", %{conn: conn} do
|
||||
conn = get(conn, ~p"/api/infestations/no-such-pattern")
|
||||
assert json_response(conn, 404)["error"] =~ "not found"
|
||||
end
|
||||
|
||||
describe "vaccine packs" do
|
||||
alias Tarakan.Accounts.Scope
|
||||
alias Tarakan.FindingSignals
|
||||
alias Tarakan.Repo
|
||||
alias Tarakan.Scans.CanonicalFinding
|
||||
|
||||
# The endpoint must refuse to invent a detector. It used to always return
|
||||
# one, and the one it returned matched nothing.
|
||||
test "404s while nobody has written a validated detector", %{
|
||||
conn: conn,
|
||||
pattern_key: pattern_key
|
||||
} do
|
||||
conn = get(conn, ~p"/api/infestations/#{pattern_key}/vaccine.yaml")
|
||||
|
||||
assert %{"error" => error} = json_response(conn, 404)
|
||||
assert error =~ "no validated detector"
|
||||
end
|
||||
|
||||
test "serves the validated rule once one exists", %{conn: conn, pattern_key: pattern_key} do
|
||||
# Put this infestation's findings into one code cluster.
|
||||
cluster = "code:vaccinetest"
|
||||
|
||||
Repo.all(from f in CanonicalFinding, where: f.pattern_key == ^pattern_key)
|
||||
|> Enum.each(fn finding ->
|
||||
finding
|
||||
|> CanonicalFinding.embedding_changeset(%{code_pattern_key: cluster})
|
||||
|> Repo.update!()
|
||||
end)
|
||||
|
||||
scope = Scope.for_account(reviewer_account_fixture())
|
||||
|
||||
{:ok, _rule} =
|
||||
FindingSignals.put_rule(scope, cluster, %{
|
||||
engine: "semgrep",
|
||||
language: "elixir",
|
||||
rule_yaml: """
|
||||
rules:
|
||||
- id: tarakan-real-detector
|
||||
message: Shared issue class
|
||||
severity: ERROR
|
||||
languages: [elixir]
|
||||
patterns:
|
||||
- pattern: System.cmd($C, $A)
|
||||
""",
|
||||
checked_count: 2,
|
||||
matched_count: 2,
|
||||
matched_finding_ids: [Ecto.UUID.generate(), Ecto.UUID.generate()]
|
||||
})
|
||||
|
||||
conn = get(conn, ~p"/api/infestations/#{pattern_key}/vaccine.yaml")
|
||||
|
||||
assert body = response(conn, 200)
|
||||
assert get_resp_header(conn, "content-type") |> hd() =~ "application/x-yaml"
|
||||
assert body =~ "tarakan-real-detector"
|
||||
assert body =~ "matched 2 of 2 known instances"
|
||||
refute body =~ "tarakan-vaccine-placeholder"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
defmodule TarakanWeb.API.LeaderboardControllerTest do
|
||||
use TarakanWeb.ConnCase
|
||||
|
||||
setup do
|
||||
submitter = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
|
||||
repository
|
||||
|> scan_fixture(submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
|> publish_scan("public")
|
||||
|
||||
%{submitter: submitter}
|
||||
end
|
||||
|
||||
test "returns ranked rows", %{conn: conn, submitter: submitter} do
|
||||
conn = get(conn, ~p"/api/leaderboard")
|
||||
|
||||
assert %{"leaderboard" => rows} = json_response(conn, 200)
|
||||
assert row = Enum.find(rows, &(&1["handle"] == submitter.handle))
|
||||
assert row["rank"] == 1
|
||||
assert row["reputation"]["total"] > 0
|
||||
assert is_map(row["stats"])
|
||||
assert row["slashed_stakes"] == 0
|
||||
assert row["profile_url"] == TarakanWeb.Endpoint.url() <> "/" <> submitter.handle
|
||||
end
|
||||
|
||||
test "whitelists sort, severity, and window params", %{conn: conn} do
|
||||
conn = get(conn, ~p"/api/leaderboard?sort=reviews&severity=high&window=7")
|
||||
assert %{"leaderboard" => _} = json_response(conn, 200)
|
||||
|
||||
conn = get(conn, ~p"/api/leaderboard?sort=bogus&severity=bogus&window=bogus")
|
||||
assert %{"leaderboard" => _} = json_response(conn, 200)
|
||||
end
|
||||
|
||||
defp publish_scan(scan, visibility) do
|
||||
scan = confirmation_fixture(scan, reviewer_account_fixture())
|
||||
scan = confirmation_fixture(scan, reviewer_account_fixture())
|
||||
|
||||
scope = Tarakan.Accounts.Scope.for_account(moderator_account_fixture())
|
||||
|
||||
if visibility == "public" do
|
||||
scan.repository
|
||||
|> Tarakan.Repositories.Repository.participation_changeset(%{
|
||||
participation_mode: "maintainer_verified"
|
||||
})
|
||||
|> Tarakan.Repo.update!()
|
||||
end
|
||||
|
||||
{:ok, scan} =
|
||||
Tarakan.Scans.accept_scan(scope, scan, %{
|
||||
"moderation_reason" => "evidence_reviewed",
|
||||
"moderation_notes" =>
|
||||
"Two independent reviewers supplied reproducible evidence for the pinned commit."
|
||||
})
|
||||
|
||||
{:ok, scan} =
|
||||
Tarakan.Scans.update_visibility(scope, scan, visibility, %{
|
||||
"moderation_reason" => "disclosure_reviewed",
|
||||
"moderation_notes" =>
|
||||
"Disclosure was separately reviewed for scope, secrets, and personal data.",
|
||||
"sensitive_data_reviewed" => "true"
|
||||
})
|
||||
|
||||
scan
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
defmodule TarakanWeb.API.RepositoryControllerTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
alias Tarakan.Accounts.ApiCredentials
|
||||
alias Tarakan.Repositories
|
||||
|
||||
setup %{conn: conn} do
|
||||
account = github_account_fixture()
|
||||
token = api_token(account)
|
||||
%{conn: conn, account: account, token: token}
|
||||
end
|
||||
|
||||
defp authed(conn, token), do: put_req_header(conn, "authorization", "Bearer #{token}")
|
||||
|
||||
defp api_token(account) do
|
||||
{:ok, token, _credential} =
|
||||
ApiCredentials.create(account, %{
|
||||
name: "registry importer",
|
||||
scopes: ["findings:submit"]
|
||||
})
|
||||
|
||||
token
|
||||
end
|
||||
|
||||
test "rejects registration without a token", %{conn: conn} do
|
||||
conn = post(conn, ~p"/api/repositories", %{"url" => "openai/codex"})
|
||||
assert json_response(conn, 401)["error"] =~ "API token"
|
||||
end
|
||||
|
||||
test "registers a public repository by owner/name", %{conn: conn, token: token} do
|
||||
conn = conn |> authed(token) |> post(~p"/api/repositories", %{"url" => "openai/codex"})
|
||||
|
||||
assert %{"repository" => repo} = json_response(conn, 200)
|
||||
assert repo["owner"] == "openai"
|
||||
assert repo["name"] == "codex"
|
||||
assert repo["host"] == "github.com"
|
||||
assert repo["record_url"] =~ "/github.com/openai/codex"
|
||||
end
|
||||
|
||||
test "is idempotent on re-register", %{conn: conn, token: token, account: account} do
|
||||
{:ok, first} = Repositories.register_github_repository("openai/codex", account)
|
||||
|
||||
conn = conn |> authed(token) |> post(~p"/api/repositories", %{"url" => "openai/codex"})
|
||||
assert %{"repository" => repo} = json_response(conn, 200)
|
||||
assert repo["owner"] == first.owner
|
||||
assert repo["name"] == first.name
|
||||
end
|
||||
|
||||
test "requires a url", %{conn: conn, token: token} do
|
||||
conn = conn |> authed(token) |> post(~p"/api/repositories", %{})
|
||||
assert json_response(conn, 422)["errors"]["url"] == ["is required"]
|
||||
end
|
||||
|
||||
test "lists reviewable repositories", %{conn: conn, token: token, account: account} do
|
||||
{:ok, _repo} = Repositories.register_github_repository("openai/codex", account)
|
||||
|
||||
conn = conn |> authed(token) |> get(~p"/api/repositories?status=unscanned")
|
||||
assert %{"repositories" => repos} = json_response(conn, 200)
|
||||
assert Enum.any?(repos, &(&1["owner"] == "openai" and &1["name"] == "codex"))
|
||||
end
|
||||
|
||||
test "admin credentials are not registration rate limited", %{conn: conn} do
|
||||
admin =
|
||||
github_account_fixture()
|
||||
|> then(fn account ->
|
||||
account
|
||||
|> Tarakan.Accounts.Account.authorization_changeset(%{
|
||||
state: "active",
|
||||
platform_role: "admin",
|
||||
trust_tier: "reviewer"
|
||||
})
|
||||
|> Tarakan.Repo.update!()
|
||||
end)
|
||||
|
||||
token = api_token(admin)
|
||||
|
||||
# Well above the normal mutation (20) and repository_fetch (10) windows.
|
||||
for _ <- 1..35 do
|
||||
conn =
|
||||
conn
|
||||
|> recycle()
|
||||
|> authed(token)
|
||||
|> post(~p"/api/repositories", %{"url" => "openai/codex"})
|
||||
|
||||
assert json_response(conn, 200)["repository"]["name"] == "codex"
|
||||
end
|
||||
end
|
||||
end
|
||||
548
test/tarakan_web/controllers/api/scan_controller_test.exs
Normal file
548
test/tarakan_web/controllers/api/scan_controller_test.exs
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
defmodule TarakanWeb.API.ScanControllerTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
alias Tarakan.Accounts.ApiCredentials
|
||||
alias Tarakan.Accounts.Scope
|
||||
alias Tarakan.Repositories
|
||||
alias Tarakan.Repositories.Repository
|
||||
alias Tarakan.Repo
|
||||
alias Tarakan.Scans
|
||||
|
||||
setup %{conn: conn} do
|
||||
account = github_account_fixture()
|
||||
repository = github_repository_fixture(account)
|
||||
token = api_token(account)
|
||||
|
||||
%{conn: conn, account: account, repository: repository, token: token}
|
||||
end
|
||||
|
||||
defp authed(conn, token), do: put_req_header(conn, "authorization", "Bearer #{token}")
|
||||
|
||||
defp api_token(account) do
|
||||
{:ok, token, _credential} =
|
||||
ApiCredentials.create(account, %{
|
||||
name: "Scan submitter",
|
||||
scopes: ["findings:submit"]
|
||||
})
|
||||
|
||||
token
|
||||
end
|
||||
|
||||
defp scan_body(overrides \\ %{}) do
|
||||
Map.merge(
|
||||
%{
|
||||
"commit_sha" => random_commit_sha(),
|
||||
"model" => "claude-sonnet-5",
|
||||
"prompt_version" => "tarakan-baseline/v1",
|
||||
"run_id" => "api-run-#{System.unique_integer([:positive, :monotonic])}",
|
||||
"document" => %{"tarakan_scan_format" => 1, "findings" => []}
|
||||
},
|
||||
overrides
|
||||
)
|
||||
end
|
||||
|
||||
test "rejects requests without a token", %{conn: conn} do
|
||||
conn = post(conn, ~p"/api/github.com/openai/codex/reports", scan_body())
|
||||
|
||||
assert json_response(conn, 401)["error"] =~ "API token"
|
||||
end
|
||||
|
||||
test "rejects requests with an invalid or revoked token", %{conn: conn, account: account} do
|
||||
conn1 =
|
||||
conn |> authed("garbage") |> post(~p"/api/github.com/openai/codex/reports", scan_body())
|
||||
|
||||
assert json_response(conn1, 401)
|
||||
|
||||
old_token = api_token(account)
|
||||
[credential | _rest] = ApiCredentials.list(account)
|
||||
{:ok, _credential} = ApiCredentials.revoke(account, credential.id)
|
||||
|
||||
conn2 =
|
||||
conn |> authed(old_token) |> post(~p"/api/github.com/openai/codex/reports", scan_body())
|
||||
|
||||
assert json_response(conn2, 401)
|
||||
end
|
||||
|
||||
test "404s for a repository not in the registry", %{conn: conn, token: token} do
|
||||
conn = conn |> authed(token) |> post(~p"/api/github.com/unknown/repo/reports", scan_body())
|
||||
|
||||
assert json_response(conn, 404)["error"] =~ "not registered"
|
||||
end
|
||||
|
||||
test "does not disclose a quarantined repository to an unrelated credential", %{
|
||||
conn: conn,
|
||||
repository: repository
|
||||
} do
|
||||
_contained =
|
||||
repository
|
||||
|> Repository.listing_changeset(%{listing_status: "quarantined"})
|
||||
|> Repo.update!()
|
||||
|
||||
outsider_token = api_token(account_fixture())
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> authed(outsider_token)
|
||||
|> post(~p"/api/github.com/openai/codex/reports", scan_body())
|
||||
|
||||
assert json_response(conn, 404)["error"] =~ "not registered"
|
||||
end
|
||||
|
||||
test "records a clean scan", %{conn: conn, token: token} do
|
||||
conn = conn |> authed(token) |> post(~p"/api/github.com/openai/codex/reports", scan_body())
|
||||
|
||||
response = json_response(conn, 201)
|
||||
assert response["findings_count"] == 0
|
||||
assert response["verified"] == false
|
||||
assert response["review_status"] == "quarantined"
|
||||
assert response["visibility"] == "public"
|
||||
assert response["provenance_attestation"] == "self_reported"
|
||||
assert response["repository"] == "openai/codex"
|
||||
assert response["record_url"] =~ "/github.com/openai/codex"
|
||||
|
||||
repository = Repositories.get_github_repository("openai", "codex")
|
||||
assert repository.status == "reviewed"
|
||||
assert repository.scan_count == 1
|
||||
end
|
||||
|
||||
test "records a scan with findings", %{conn: conn, token: token} do
|
||||
document = Jason.decode!(findings_json_fixture(2))
|
||||
body = scan_body(%{"document" => document, "notes" => "run #7 of the nightly sweep"})
|
||||
|
||||
conn = conn |> authed(token) |> post(~p"/api/github.com/openai/codex/reports", body)
|
||||
|
||||
response = json_response(conn, 201)
|
||||
assert response["findings_count"] == 2
|
||||
assert response["kind"] == "report"
|
||||
assert response["disclosed"] == true
|
||||
assert is_list(response["findings"])
|
||||
assert length(response["findings"]) == 2
|
||||
assert hd(response["findings"])["url"] =~ "/findings/"
|
||||
|
||||
repository = Repositories.get_github_repository("openai", "codex")
|
||||
assert repository.status == "findings"
|
||||
assert repository.open_findings_count == 2
|
||||
end
|
||||
|
||||
test "mass report path works without a job claim", %{conn: conn, token: token} do
|
||||
document = Jason.decode!(findings_json_fixture(1))
|
||||
body = scan_body(%{"document" => document, "provenance" => "agent"})
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> authed(token)
|
||||
|> post(~p"/api/github.com/openai/codex/reports", body)
|
||||
|
||||
response = json_response(conn, 201)
|
||||
assert response["kind"] == "report"
|
||||
assert response["visibility"] == "public"
|
||||
assert response["findings_count"] == 1
|
||||
assert hd(response["findings"])["public_id"]
|
||||
end
|
||||
|
||||
test "cannot self-accept or self-publish through submission attributes", %{
|
||||
conn: conn,
|
||||
token: token
|
||||
} do
|
||||
body =
|
||||
scan_body(%{
|
||||
"review_status" => "accepted",
|
||||
"visibility" => "public",
|
||||
"verified_at" => DateTime.utc_now()
|
||||
})
|
||||
|
||||
conn = conn |> authed(token) |> post(~p"/api/github.com/openai/codex/reports", body)
|
||||
|
||||
response = json_response(conn, 201)
|
||||
assert response["review_status"] == "quarantined"
|
||||
assert response["visibility"] == "public"
|
||||
assert response["verified"] == false
|
||||
end
|
||||
|
||||
test "records a human-authored review without model metadata", %{conn: conn, token: token} do
|
||||
body = %{
|
||||
"commit_sha" => random_commit_sha(),
|
||||
"provenance" => "human",
|
||||
"review_kind" => "business_logic",
|
||||
"notes" => "Manually traced organization ownership transfer.",
|
||||
"document" => Jason.decode!(findings_json_fixture(1))
|
||||
}
|
||||
|
||||
conn = conn |> authed(token) |> post(~p"/api/github.com/openai/codex/reports", body)
|
||||
|
||||
response = json_response(conn, 201)
|
||||
assert response["provenance"] == "human"
|
||||
assert response["review_kind"] == "business_logic"
|
||||
assert response["model"] == nil
|
||||
assert response["prompt_version"] == nil
|
||||
assert response["findings_count"] == 1
|
||||
end
|
||||
|
||||
test "requires the scan document", %{conn: conn, token: token} do
|
||||
body = Map.delete(scan_body(), "document")
|
||||
conn1 = conn |> authed(token) |> post(~p"/api/github.com/openai/codex/reports", body)
|
||||
assert %{"document" => [message]} = json_response(conn1, 422)["errors"]
|
||||
assert message =~ "required"
|
||||
|
||||
conn2 =
|
||||
conn
|
||||
|> authed(token)
|
||||
|> post(~p"/api/github.com/openai/codex/reports", scan_body(%{"document" => "[]"}))
|
||||
|
||||
assert json_response(conn2, 422)["errors"]["document"]
|
||||
end
|
||||
|
||||
test "rejects an invalid document with the parser's message", %{conn: conn, token: token} do
|
||||
body = scan_body(%{"document" => %{"tarakan_scan_format" => 2, "findings" => []}})
|
||||
conn = conn |> authed(token) |> post(~p"/api/github.com/openai/codex/reports", body)
|
||||
|
||||
assert %{"findings_json" => [message]} = json_response(conn, 422)["errors"]
|
||||
assert message == "tarakan_scan_format must be 1"
|
||||
end
|
||||
|
||||
test "rejects envelope validation errors", %{conn: conn, token: token} do
|
||||
body = scan_body(%{"commit_sha" => "abc123", "model" => nil})
|
||||
conn = conn |> authed(token) |> post(~p"/api/github.com/openai/codex/reports", body)
|
||||
|
||||
errors = json_response(conn, 422)["errors"]
|
||||
assert errors["commit_sha"] == ["must be a full 40-character commit SHA"]
|
||||
assert errors["model"] == ["can't be blank"]
|
||||
end
|
||||
|
||||
test "rejects a commit GitHub does not know", %{conn: conn, token: token} do
|
||||
body = scan_body(%{"commit_sha" => "dead" <> String.duplicate("0", 36)})
|
||||
conn = conn |> authed(token) |> post(~p"/api/github.com/openai/codex/reports", body)
|
||||
|
||||
assert json_response(conn, 422)["errors"]["commit_sha"] == [
|
||||
"commit not found in this repository on GitHub"
|
||||
]
|
||||
end
|
||||
|
||||
test "blocks retrying the same run id", %{conn: conn, token: token} do
|
||||
body = scan_body()
|
||||
|
||||
conn1 = conn |> authed(token) |> post(~p"/api/github.com/openai/codex/reports", body)
|
||||
assert json_response(conn1, 201)
|
||||
|
||||
conn2 = conn |> authed(token) |> post(~p"/api/github.com/openai/codex/reports", body)
|
||||
|
||||
assert json_response(conn2, 422)["errors"]["run_id"] == [
|
||||
"this agent run was already submitted"
|
||||
]
|
||||
end
|
||||
|
||||
describe "GET /reports and verdict" do
|
||||
setup %{repository: repository, account: submitter} do
|
||||
scan =
|
||||
scan_fixture(repository, submitter, %{
|
||||
"findings_json" => findings_json_fixture(1)
|
||||
})
|
||||
|
||||
reviewer = reviewer_tier_account_fixture()
|
||||
|
||||
%{scan: scan, reviewer: reviewer, reviewer_token: reviews_token(reviewer)}
|
||||
end
|
||||
|
||||
test "reviewer-tier reviews:read token sees restricted findings", %{
|
||||
conn: conn,
|
||||
reviewer_token: token,
|
||||
scan: scan
|
||||
} do
|
||||
scan = restrict_scan(scan)
|
||||
|
||||
body =
|
||||
conn
|
||||
|> authed(token)
|
||||
|> get(~p"/api/github.com/openai/codex/reports")
|
||||
|> json_response(200)
|
||||
|
||||
entry = Enum.find(body["reports"], &(&1["id"] == scan.id))
|
||||
assert entry["details_visible"]
|
||||
assert length(entry["findings"]) == 1
|
||||
assert hd(entry["findings"])["severity"]
|
||||
end
|
||||
|
||||
test "returns compact canonical memory for reconciliation", %{
|
||||
conn: conn,
|
||||
token: token,
|
||||
scan: scan
|
||||
} do
|
||||
body =
|
||||
conn
|
||||
|> authed(token)
|
||||
|> get(~p"/api/github.com/openai/codex/memory?commit_sha=#{scan.commit_sha}")
|
||||
|> json_response(200)
|
||||
|
||||
assert body["target_commit_sha"] == scan.commit_sha
|
||||
assert [finding] = body["findings"]
|
||||
assert finding["same_commit"]
|
||||
assert finding["status"] == "open"
|
||||
assert finding["detections_count"] == 1
|
||||
assert finding["public_id"]
|
||||
|
||||
# Nothing disputed yet, but the corpus ships on every response so clients
|
||||
# can rely on the key existing.
|
||||
assert body["suppressions"]["repository"] == []
|
||||
assert body["suppressions"]["patterns"] == []
|
||||
assert body["suppressions"]["note"] =~ "non-bugs"
|
||||
end
|
||||
|
||||
test "memory carries disputed findings as suppressions", %{
|
||||
conn: conn,
|
||||
token: token,
|
||||
scan: scan
|
||||
} do
|
||||
[occurrence] = scan.findings
|
||||
|
||||
Tarakan.Repo.get!(Tarakan.Scans.CanonicalFinding, occurrence.canonical_finding_id)
|
||||
|> Ecto.Changeset.change(status: "disputed", disputes_count: 2)
|
||||
|> Tarakan.Repo.update!()
|
||||
|
||||
body =
|
||||
conn
|
||||
|> authed(token)
|
||||
|> get(~p"/api/github.com/openai/codex/memory?commit_sha=#{scan.commit_sha}")
|
||||
|> json_response(200)
|
||||
|
||||
assert [suppression] = body["suppressions"]["repository"]
|
||||
assert suppression["scope"] == "repository"
|
||||
assert suppression["fingerprint"]
|
||||
assert suppression["file_path"]
|
||||
end
|
||||
|
||||
test "records a check on one canonical finding", %{
|
||||
conn: conn,
|
||||
scan: scan
|
||||
} do
|
||||
token = reviews_token(moderator_account_fixture())
|
||||
|
||||
memory =
|
||||
conn
|
||||
|> authed(token)
|
||||
|> get(~p"/api/github.com/openai/codex/memory?commit_sha=#{scan.commit_sha}")
|
||||
|> json_response(200)
|
||||
|
||||
[finding] = memory["findings"]
|
||||
|
||||
body =
|
||||
conn
|
||||
|> recycle()
|
||||
|> authed(token)
|
||||
|> post(~p"/api/github.com/openai/codex/findings/#{finding["public_id"]}/check", %{
|
||||
"commit_sha" => scan.commit_sha,
|
||||
"verdict" => "confirmed",
|
||||
"provenance" => "human",
|
||||
"notes" => "Independently reproduced this individual finding at the pinned commit."
|
||||
})
|
||||
|> json_response(201)
|
||||
|
||||
assert body["status"] == "open"
|
||||
assert body["confirmations_count"] == 1
|
||||
end
|
||||
|
||||
test "a plain findings:submit token cannot see restricted findings", %{
|
||||
conn: conn,
|
||||
token: token,
|
||||
scan: scan
|
||||
} do
|
||||
scan = restrict_scan(scan)
|
||||
|
||||
body =
|
||||
conn
|
||||
|> authed(token)
|
||||
|> get(~p"/api/github.com/openai/codex/reports")
|
||||
|> json_response(200)
|
||||
|
||||
# After a moderator takedown, the submitter's own restricted scan is not
|
||||
# exposed to a non-reviewer token.
|
||||
refute Enum.any?(body["reports"], &(&1["id"] == scan.id and &1["findings"] != []))
|
||||
end
|
||||
|
||||
test "records a verdict with a proof-of-concept", %{
|
||||
conn: conn,
|
||||
reviewer_token: token,
|
||||
scan: scan
|
||||
} do
|
||||
body =
|
||||
conn
|
||||
|> authed(token)
|
||||
|> post(~p"/api/github.com/openai/codex/reports/#{scan.id}/check", %{
|
||||
"verdict" => "confirmed",
|
||||
"provenance" => "agent",
|
||||
"notes" => "Reproduced the reported issue against the pinned commit source.",
|
||||
"evidence" => "import test; test('repro', t => t.throws(() => vulnerable()))"
|
||||
})
|
||||
|> json_response(201)
|
||||
|
||||
assert length(body["confirmations"]) == 1
|
||||
assert hd(body["confirmations"])["verdict"] == "confirmed"
|
||||
end
|
||||
|
||||
test "the submitter cannot verify their own review", %{
|
||||
conn: conn,
|
||||
account: submitter,
|
||||
scan: scan
|
||||
} do
|
||||
token = reviews_token(reviewer_tier(submitter))
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> authed(token)
|
||||
|> post(~p"/api/github.com/openai/codex/reports/#{scan.id}/check", %{
|
||||
"verdict" => "confirmed",
|
||||
"notes" => "Trying to verify my own submission, which must be refused."
|
||||
})
|
||||
|
||||
assert json_response(conn, 409)["error"] =~ "submitter"
|
||||
end
|
||||
|
||||
test "a read-only reviewer credential cannot record a verdict", %{conn: conn, scan: scan} do
|
||||
# Reviewer-tier account (can see the scan) but the credential lacks
|
||||
# reviews:verify, so recording a verdict is forbidden.
|
||||
{:ok, read_only, _cred} =
|
||||
ApiCredentials.create(reviewer_tier_account_fixture(), %{
|
||||
name: "Read-only reviewer",
|
||||
scopes: ["reviews:read"]
|
||||
})
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> authed(read_only)
|
||||
|> post(~p"/api/github.com/openai/codex/reports/#{scan.id}/check", %{
|
||||
"verdict" => "confirmed",
|
||||
"notes" => "Has read access but no verify scope, so this must be denied."
|
||||
})
|
||||
|
||||
assert json_response(conn, 403)["error"] =~ "not authorized"
|
||||
end
|
||||
|
||||
test "a token that cannot see the review gets 404, not a leak", %{
|
||||
conn: conn,
|
||||
token: token,
|
||||
scan: scan
|
||||
} do
|
||||
scan = restrict_scan(scan)
|
||||
|
||||
# A plain findings:submit token lacks read scope, so the restricted scan
|
||||
# is invisible - the endpoint must 404 rather than reveal it exists.
|
||||
conn =
|
||||
conn
|
||||
|> authed(token)
|
||||
|> post(~p"/api/github.com/openai/codex/reports/#{scan.id}/check", %{
|
||||
"verdict" => "confirmed",
|
||||
"notes" => "This token cannot see the scan, so it must not verify it."
|
||||
})
|
||||
|
||||
assert json_response(conn, 404)["error"] =~ "not found"
|
||||
end
|
||||
|
||||
test "an unrelated credential cannot distinguish a quarantined repository", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
scan: scan
|
||||
} do
|
||||
_contained =
|
||||
repository
|
||||
|> Repository.listing_changeset(%{listing_status: "quarantined"})
|
||||
|> Repo.update!()
|
||||
|
||||
outsider_token = reviews_token(account_fixture())
|
||||
|
||||
conn1 =
|
||||
conn
|
||||
|> authed(outsider_token)
|
||||
|> get(~p"/api/github.com/openai/codex/reports")
|
||||
|
||||
assert json_response(conn1, 404)["error"] =~ "not registered"
|
||||
|
||||
conn2 =
|
||||
conn
|
||||
|> recycle()
|
||||
|> authed(outsider_token)
|
||||
|> post(~p"/api/github.com/openai/codex/reports/#{scan.id}/check", %{
|
||||
"verdict" => "confirmed",
|
||||
"notes" => "Quarantined repositories must look exactly like unregistered ones."
|
||||
})
|
||||
|
||||
assert json_response(conn2, 404)["error"] =~ "not registered"
|
||||
end
|
||||
|
||||
test "the submitter keeps access to their quarantined repository", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
account: submitter,
|
||||
scan: scan
|
||||
} do
|
||||
_contained =
|
||||
repository
|
||||
|> Repository.listing_changeset(%{listing_status: "quarantined"})
|
||||
|> Repo.update!()
|
||||
|
||||
{:ok, read_token, _credential} =
|
||||
ApiCredentials.create(submitter, %{name: "Reader", scopes: ["reviews:read"]})
|
||||
|
||||
body =
|
||||
conn
|
||||
|> authed(read_token)
|
||||
|> get(~p"/api/github.com/openai/codex/reports")
|
||||
|> json_response(200)
|
||||
|
||||
assert Enum.any?(body["reports"], &(&1["id"] == scan.id))
|
||||
end
|
||||
|
||||
test "an unrelated credential cannot list a pending repository's reviews", %{
|
||||
conn: conn,
|
||||
repository: repository
|
||||
} do
|
||||
_pending =
|
||||
repository
|
||||
|> Repository.listing_changeset(%{listing_status: "pending"})
|
||||
|> Repo.update!()
|
||||
|
||||
outsider_token = reviews_token(account_fixture())
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> authed(outsider_token)
|
||||
|> get(~p"/api/github.com/openai/codex/reports")
|
||||
|
||||
assert json_response(conn, 404)["error"] =~ "not registered"
|
||||
end
|
||||
end
|
||||
|
||||
defp restrict_scan(scan) do
|
||||
moderator_scope = Scope.for_account(moderator_account_fixture())
|
||||
|
||||
{:ok, scan} =
|
||||
Scans.update_visibility(moderator_scope, scan, "restricted", %{
|
||||
"moderation_reason" => "evidence_reviewed",
|
||||
"moderation_notes" => "Moderator takedown recorded for this visibility boundary test."
|
||||
})
|
||||
|
||||
scan
|
||||
end
|
||||
|
||||
defp reviewer_tier_account_fixture do
|
||||
account_fixture() |> reviewer_tier()
|
||||
end
|
||||
|
||||
defp reviewer_tier(account) do
|
||||
account
|
||||
|> Tarakan.Accounts.Account.authorization_changeset(%{
|
||||
state: "active",
|
||||
platform_role: "member",
|
||||
trust_tier: "reviewer"
|
||||
})
|
||||
|> Repo.update!()
|
||||
end
|
||||
|
||||
defp reviews_token(account) do
|
||||
{:ok, token, _credential} =
|
||||
ApiCredentials.create(account, %{
|
||||
name: "Verifier",
|
||||
scopes: ["reviews:read", "reviews:verify"]
|
||||
})
|
||||
|
||||
token
|
||||
end
|
||||
end
|
||||
601
test/tarakan_web/controllers/api/work_controller_test.exs
Normal file
601
test/tarakan_web/controllers/api/work_controller_test.exs
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
defmodule TarakanWeb.API.WorkControllerTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
alias Tarakan.Accounts
|
||||
alias Tarakan.Accounts.ApiCredentials
|
||||
alias Tarakan.Repositories.Repository
|
||||
alias Tarakan.Repo
|
||||
alias Tarakan.Work
|
||||
|
||||
setup %{conn: conn} do
|
||||
creator = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(creator)
|
||||
worker = account_fixture()
|
||||
worker_token = client_token(worker)
|
||||
creator_token = client_token(creator)
|
||||
|
||||
%{
|
||||
conn: conn,
|
||||
creator: creator,
|
||||
creator_token: creator_token,
|
||||
repository: repository,
|
||||
worker: worker,
|
||||
worker_token: worker_token
|
||||
}
|
||||
end
|
||||
|
||||
defp authed(conn, token), do: put_req_header(conn, "authorization", "Bearer #{token}")
|
||||
|
||||
defp client_token(account) do
|
||||
{:ok, token, _credential} =
|
||||
ApiCredentials.create(account, %{
|
||||
name: "work controller test",
|
||||
scopes: ["tasks:read", "tasks:claim", "contributions:write"]
|
||||
})
|
||||
|
||||
token
|
||||
end
|
||||
|
||||
test "all work queue endpoints require an API token", %{conn: conn} do
|
||||
conn = get(conn, ~p"/api/github.com/openai/codex/jobs")
|
||||
|
||||
assert json_response(conn, 401)["error"] =~ "API token"
|
||||
end
|
||||
|
||||
test "lists the global open jobs queue", %{
|
||||
conn: conn,
|
||||
creator: creator,
|
||||
repository: repository,
|
||||
worker_token: token
|
||||
} do
|
||||
task = review_task_fixture(repository, creator)
|
||||
|
||||
conn = conn |> authed(token) |> get(~p"/api/jobs")
|
||||
|
||||
assert %{"jobs" => jobs} = json_response(conn, 200)
|
||||
assert Enum.any?(jobs, &(&1["id"] == task.id))
|
||||
match = Enum.find(jobs, &(&1["id"] == task.id))
|
||||
assert match["status"] == "open"
|
||||
assert match["repository"]["owner"] == repository.owner
|
||||
assert match["repository"]["name"] == repository.name
|
||||
assert match["repository"]["primary_language"] == repository.primary_language
|
||||
assert match["repository"]["stars_count"] == repository.stars_count
|
||||
end
|
||||
|
||||
test "global jobs queue filters by language and min stars", %{
|
||||
conn: conn,
|
||||
creator: creator,
|
||||
repository: repository,
|
||||
worker_token: token
|
||||
} do
|
||||
task = review_task_fixture(repository, creator)
|
||||
|
||||
# Fixture repo is Rust with 42000 stars (GitHub stub).
|
||||
conn =
|
||||
conn
|
||||
|> authed(token)
|
||||
|> get(~p"/api/jobs", %{"language" => "Rust", "min_stars" => "1000"})
|
||||
|
||||
assert %{"jobs" => jobs} = json_response(conn, 200)
|
||||
assert Enum.any?(jobs, &(&1["id"] == task.id))
|
||||
|
||||
conn =
|
||||
build_conn()
|
||||
|> authed(token)
|
||||
|> get(~p"/api/jobs", %{"language" => "Elixir"})
|
||||
|
||||
assert %{"jobs" => jobs} = json_response(conn, 200)
|
||||
refute Enum.any?(jobs, &(&1["id"] == task.id))
|
||||
|
||||
conn =
|
||||
build_conn()
|
||||
|> authed(token)
|
||||
|> get(~p"/api/jobs", %{"min_stars" => "999999"})
|
||||
|
||||
assert %{"jobs" => jobs} = json_response(conn, 200)
|
||||
refute Enum.any?(jobs, &(&1["id"] == task.id))
|
||||
end
|
||||
|
||||
# A worker declares what it can run so it never claims a kind it does not
|
||||
# implement and then holds the lease until it expires.
|
||||
test "global jobs queue filters by the kinds a worker can run", %{
|
||||
conn: conn,
|
||||
creator: creator,
|
||||
repository: repository,
|
||||
worker_token: token
|
||||
} do
|
||||
task = review_task_fixture(repository, creator)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> authed(token)
|
||||
|> get(~p"/api/jobs", %{"kinds" => "#{task.kind},refute_finding"})
|
||||
|
||||
assert %{"jobs" => jobs} = json_response(conn, 200)
|
||||
assert Enum.any?(jobs, &(&1["id"] == task.id))
|
||||
|
||||
conn =
|
||||
build_conn()
|
||||
|> authed(token)
|
||||
|> get(~p"/api/jobs", %{"kinds" => "reproduce_finding"})
|
||||
|
||||
assert %{"jobs" => jobs} = json_response(conn, 200)
|
||||
refute Enum.any?(jobs, &(&1["id"] == task.id))
|
||||
end
|
||||
|
||||
# An old client and a new server must still be able to talk, so a name the
|
||||
# server does not know is dropped rather than erroring or filtering to empty.
|
||||
test "unknown kind names are ignored rather than rejected", %{
|
||||
conn: conn,
|
||||
creator: creator,
|
||||
repository: repository,
|
||||
worker_token: token
|
||||
} do
|
||||
task = review_task_fixture(repository, creator)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> authed(token)
|
||||
|> get(~p"/api/jobs", %{"kinds" => "not_a_real_kind"})
|
||||
|
||||
assert %{"jobs" => jobs} = json_response(conn, 200)
|
||||
assert Enum.any?(jobs, &(&1["id"] == task.id))
|
||||
end
|
||||
|
||||
test "global jobs queue includes the caller's active claims", %{
|
||||
conn: conn,
|
||||
creator: creator,
|
||||
repository: repository,
|
||||
worker: worker,
|
||||
worker_token: token
|
||||
} do
|
||||
task =
|
||||
review_task_fixture(repository, creator, %{"kind" => "code_review", "capability" => "agent"})
|
||||
|
||||
{:ok, claimed} = Work.claim_task(task, worker)
|
||||
|
||||
assert claimed.status == "claimed"
|
||||
|
||||
conn = conn |> authed(token) |> get(~p"/api/jobs")
|
||||
assert %{"jobs" => jobs} = json_response(conn, 200)
|
||||
match = Enum.find(jobs, &(&1["id"] == claimed.id))
|
||||
assert match
|
||||
assert match["status"] == "claimed"
|
||||
assert match["lease"]["active"] == true
|
||||
end
|
||||
|
||||
test "lists a repository's tasks with client-ready relationships", %{
|
||||
conn: conn,
|
||||
creator: creator,
|
||||
repository: repository,
|
||||
worker_token: token
|
||||
} do
|
||||
task = review_task_fixture(repository, creator)
|
||||
|
||||
conn = conn |> authed(token) |> get(~p"/api/github.com/openai/codex/jobs")
|
||||
|
||||
assert %{"jobs" => [response]} = json_response(conn, 200)
|
||||
assert response["id"] == task.id
|
||||
assert response["kind"] == "threat_model"
|
||||
assert response["capability"] == "human"
|
||||
assert response["status"] == "open"
|
||||
assert response["visibility"] == "public"
|
||||
assert response["commit_sha"] == task.commit_sha
|
||||
assert response["commit_committed_at"] == "2026-07-01T12:00:00.000000Z"
|
||||
|
||||
assert response["repository"]["canonical_url"] == "https://github.com/openai/codex"
|
||||
assert response["repository"]["host"] == "github.com"
|
||||
assert response["repository"]["id"] == repository.id
|
||||
assert response["repository"]["name"] == "codex"
|
||||
assert response["repository"]["owner"] == "openai"
|
||||
assert response["repository"]["participation_mode"] == repository.participation_mode
|
||||
assert response["repository"]["record_url"] =~ "/github.com/openai/codex"
|
||||
|
||||
assert response["creator"]["id"] == creator.id
|
||||
assert response["creator"]["handle"] == creator.handle
|
||||
assert response["creator"] |> Map.keys() |> Enum.sort() == ["handle", "id"]
|
||||
assert response["claimant"] == nil
|
||||
assert response["lease"] == nil
|
||||
assert response["contribution"] == nil
|
||||
assert response["job_url"] =~ "/jobs/#{task.id}"
|
||||
end
|
||||
|
||||
test "returns an empty list and 404s an unregistered repository", %{
|
||||
conn: conn,
|
||||
worker_token: token
|
||||
} do
|
||||
conn1 = conn |> authed(token) |> get(~p"/api/github.com/openai/codex/jobs")
|
||||
assert json_response(conn1, 200) == %{"jobs" => []}
|
||||
|
||||
conn2 =
|
||||
conn
|
||||
|> recycle()
|
||||
|> authed(token)
|
||||
|> get(~p"/api/github.com/unknown/repository/jobs")
|
||||
|
||||
assert json_response(conn2, 404)["error"] =~ "not registered"
|
||||
end
|
||||
|
||||
test "does not disclose a quarantined repository to an unrelated credential", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
worker_token: token
|
||||
} do
|
||||
_contained =
|
||||
repository
|
||||
|> Repository.listing_changeset(%{listing_status: "quarantined"})
|
||||
|> Repo.update!()
|
||||
|
||||
conn = conn |> authed(token) |> get(~p"/api/github.com/openai/codex/jobs")
|
||||
|
||||
assert json_response(conn, 404)["error"] =~ "not registered"
|
||||
end
|
||||
|
||||
test "shows a task and returns 404 for missing or malformed ids", %{
|
||||
conn: conn,
|
||||
creator: creator,
|
||||
repository: repository,
|
||||
worker_token: token
|
||||
} do
|
||||
task = review_task_fixture(repository, creator)
|
||||
|
||||
conn1 = conn |> authed(token) |> get(~p"/api/jobs/#{task.id}")
|
||||
assert json_response(conn1, 200)["id"] == task.id
|
||||
|
||||
conn2 = conn |> recycle() |> authed(token) |> get("/api/jobs/not-an-id")
|
||||
assert json_response(conn2, 404)["error"] == "job not found"
|
||||
|
||||
conn3 = conn |> recycle() |> authed(token) |> get("/api/jobs/999999999")
|
||||
assert json_response(conn3, 404)["error"] == "job not found"
|
||||
end
|
||||
|
||||
test "claims a task and exposes the active lease", %{
|
||||
conn: conn,
|
||||
creator: creator,
|
||||
repository: repository,
|
||||
worker: worker,
|
||||
worker_token: token
|
||||
} do
|
||||
task = review_task_fixture(repository, creator)
|
||||
|
||||
conn = conn |> authed(token) |> post(~p"/api/jobs/#{task.id}/claim")
|
||||
|
||||
response = json_response(conn, 200)
|
||||
assert response["status"] == "claimed"
|
||||
assert response["claimant"]["id"] == worker.id
|
||||
assert response["lease"]["active"] == true
|
||||
assert response["lease"]["claimed_at"]
|
||||
assert response["lease"]["expires_at"]
|
||||
|
||||
original_expiry = response["lease"]["expires_at"]
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> recycle()
|
||||
|> authed(token)
|
||||
|> post(~p"/api/jobs/#{task.id}/claim")
|
||||
|
||||
repeated = json_response(conn, 200)
|
||||
assert repeated["claimant"]["id"] == worker.id
|
||||
assert repeated["lease"]["expires_at"] == original_expiry
|
||||
end
|
||||
|
||||
test "creators may claim their own jobs; active claims conflict for others", %{
|
||||
conn: conn,
|
||||
creator: creator,
|
||||
creator_token: creator_token,
|
||||
repository: repository,
|
||||
worker_token: worker_token
|
||||
} do
|
||||
task = review_task_fixture(repository, creator)
|
||||
|
||||
# Solo/hosted workflow: job creators may claim and perform their own Jobs.
|
||||
conn1 = conn |> authed(creator_token) |> post(~p"/api/jobs/#{task.id}/claim")
|
||||
assert json_response(conn1, 200)["claimant"]["id"] == creator.id
|
||||
|
||||
conn_release =
|
||||
conn |> recycle() |> authed(creator_token) |> delete(~p"/api/jobs/#{task.id}/claim")
|
||||
|
||||
assert json_response(conn_release, 200)["status"] == "open"
|
||||
|
||||
conn2 = conn |> recycle() |> authed(worker_token) |> post(~p"/api/jobs/#{task.id}/claim")
|
||||
assert json_response(conn2, 200)
|
||||
|
||||
outsider = account_fixture()
|
||||
outsider_token = client_token(outsider)
|
||||
|
||||
conn3 = conn |> recycle() |> authed(outsider_token) |> post(~p"/api/jobs/#{task.id}/claim")
|
||||
assert json_response(conn3, 409)["error"] =~ "active claim"
|
||||
end
|
||||
|
||||
test "only the claimant can release a task", %{
|
||||
conn: conn,
|
||||
creator: creator,
|
||||
repository: repository,
|
||||
worker: worker,
|
||||
worker_token: worker_token
|
||||
} do
|
||||
task = review_task_fixture(repository, creator)
|
||||
{:ok, task} = Work.claim_task(task, worker)
|
||||
outsider_token = client_token(account_fixture())
|
||||
|
||||
conn1 = conn |> authed(outsider_token) |> delete(~p"/api/jobs/#{task.id}/claim")
|
||||
assert json_response(conn1, 403)["error"] =~ "current claimant"
|
||||
|
||||
conn2 =
|
||||
conn
|
||||
|> recycle()
|
||||
|> authed(worker_token)
|
||||
|> delete(~p"/api/jobs/#{task.id}/claim")
|
||||
|
||||
response = json_response(conn2, 200)
|
||||
assert response["status"] == "open"
|
||||
assert response["claimant"] == nil
|
||||
assert response["lease"] == nil
|
||||
end
|
||||
|
||||
test "claimant can renew an active lease", %{
|
||||
conn: conn,
|
||||
creator: creator,
|
||||
repository: repository,
|
||||
worker_token: token
|
||||
} do
|
||||
task = review_task_fixture(repository, creator)
|
||||
|
||||
conn = conn |> authed(token) |> post(~p"/api/jobs/#{task.id}/claim")
|
||||
first = json_response(conn, 200)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> recycle()
|
||||
|> authed(token)
|
||||
|> post(~p"/api/jobs/#{task.id}/claim/renew")
|
||||
|
||||
renewed = json_response(conn, 200)
|
||||
assert renewed["lease"]["active"]
|
||||
assert renewed["lease"]["expires_at"] >= first["lease"]["expires_at"]
|
||||
end
|
||||
|
||||
test "completion path submits a claimed task for independent review", %{
|
||||
conn: conn,
|
||||
creator: creator,
|
||||
repository: repository,
|
||||
worker: worker,
|
||||
worker_token: token
|
||||
} do
|
||||
task = review_task_fixture(repository, creator)
|
||||
{:ok, task} = Work.claim_task(task, worker)
|
||||
|
||||
body = %{
|
||||
"provenance" => "hybrid",
|
||||
"summary" => "The authorization boundary is enforced.",
|
||||
"evidence" => "Reviewed both entry points and ran the focused regression test."
|
||||
}
|
||||
|
||||
conn = conn |> authed(token) |> post(~p"/api/jobs/#{task.id}/complete", body)
|
||||
|
||||
response = json_response(conn, 200)
|
||||
assert response["status"] == "submitted"
|
||||
assert response["visibility"] == "public"
|
||||
assert response["submitted_at"]
|
||||
assert response["completed_at"] == nil
|
||||
assert response["lease"]["active"] == false
|
||||
|
||||
assert response["contribution"] == %{
|
||||
"contributor" => %{
|
||||
"handle" => worker.handle,
|
||||
"id" => worker.id
|
||||
},
|
||||
"evidence" => body["evidence"],
|
||||
"id" => response["contribution"]["id"],
|
||||
"version" => 1,
|
||||
"provenance" => "hybrid",
|
||||
"submitted_at" => response["contribution"]["submitted_at"],
|
||||
"summary" => body["summary"]
|
||||
}
|
||||
|
||||
conn2 =
|
||||
conn
|
||||
|> recycle()
|
||||
|> authed(token)
|
||||
|> delete(~p"/api/jobs/#{task.id}/claim")
|
||||
|
||||
assert json_response(conn2, 403)["error"] =~ "current claimant"
|
||||
end
|
||||
|
||||
test "completion requires the active claimant and valid evidence metadata", %{
|
||||
conn: conn,
|
||||
creator: creator,
|
||||
repository: repository,
|
||||
worker: worker,
|
||||
worker_token: token
|
||||
} do
|
||||
task = review_task_fixture(repository, creator)
|
||||
|
||||
conn1 =
|
||||
conn
|
||||
|> authed(token)
|
||||
|> post(~p"/api/jobs/#{task.id}/complete", %{
|
||||
"provenance" => "human",
|
||||
"summary" => "Completed",
|
||||
"evidence" => "Attempted submission without first holding the task's active claim."
|
||||
})
|
||||
|
||||
assert json_response(conn1, 403)["error"] =~ "current claimant"
|
||||
|
||||
{:ok, _task} = Work.claim_task(task, worker)
|
||||
|
||||
conn2 =
|
||||
conn
|
||||
|> recycle()
|
||||
|> authed(token)
|
||||
|> post(~p"/api/jobs/#{task.id}/complete", %{
|
||||
"provenance" => "unverifiable",
|
||||
"summary" => ""
|
||||
})
|
||||
|
||||
errors = json_response(conn2, 422)["errors"]
|
||||
assert errors["provenance"] == ["is invalid"]
|
||||
assert errors["summary"] == ["can't be blank"]
|
||||
end
|
||||
|
||||
test "completion provenance must satisfy the requested capability", %{
|
||||
conn: conn,
|
||||
creator: creator,
|
||||
repository: repository,
|
||||
worker: worker,
|
||||
worker_token: token
|
||||
} do
|
||||
task = review_task_fixture(repository, creator, %{"capability" => "hybrid"})
|
||||
{:ok, _task} = Work.claim_task(task, worker)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> authed(token)
|
||||
|> post(~p"/api/jobs/#{task.id}/complete", %{
|
||||
"provenance" => "human",
|
||||
"summary" => "Manual review completed.",
|
||||
"evidence" => "Manually traced the requested paths and recorded reproducible notes."
|
||||
})
|
||||
|
||||
assert json_response(conn, 422)["errors"]["provenance"] == [
|
||||
"does not satisfy this task's required capability"
|
||||
]
|
||||
end
|
||||
|
||||
test "proposals are publicly listed and publishing is not exposed to client credentials", %{
|
||||
conn: conn,
|
||||
creator: creator,
|
||||
repository: repository,
|
||||
creator_token: creator_token,
|
||||
worker_token: worker_token
|
||||
} do
|
||||
proposal = proposed_review_task_fixture(repository, creator)
|
||||
|
||||
conn1 =
|
||||
conn
|
||||
|> authed(worker_token)
|
||||
|> get(~p"/api/github.com/openai/codex/jobs")
|
||||
|
||||
assert [listed] = json_response(conn1, 200)["jobs"]
|
||||
assert listed["id"] == proposal.id
|
||||
assert listed["status"] == "proposed"
|
||||
assert listed["visibility"] == "public"
|
||||
|
||||
conn2 =
|
||||
conn
|
||||
|> recycle()
|
||||
|> authed(creator_token)
|
||||
|> get(~p"/api/jobs/#{proposal.id}")
|
||||
|
||||
assert json_response(conn2, 200)["status"] == "proposed"
|
||||
|
||||
moderator = moderator_account_fixture()
|
||||
moderator_token = Accounts.create_account_api_token(moderator)
|
||||
|
||||
conn3 =
|
||||
conn
|
||||
|> recycle()
|
||||
|> authed(moderator_token)
|
||||
|> post("/api/jobs/#{proposal.id}/publish", %{
|
||||
"reason" => "The task scope is safe, bounded, and useful to contributors."
|
||||
})
|
||||
|
||||
assert response(conn3, 404) == "Not Found"
|
||||
|
||||
assert {:ok, open} =
|
||||
Work.publish_task(proposal, moderator, %{
|
||||
"reason" => "The task scope is safe, bounded, and useful to contributors."
|
||||
})
|
||||
|
||||
assert open.status == "open"
|
||||
end
|
||||
|
||||
test "acceptance is not exposed to client credentials", %{
|
||||
conn: conn,
|
||||
creator: creator,
|
||||
repository: repository,
|
||||
worker: worker,
|
||||
worker_token: worker_token
|
||||
} do
|
||||
task = review_task_fixture(repository, creator)
|
||||
{:ok, task} = Work.claim_task(task, worker)
|
||||
|
||||
{:ok, submitted} =
|
||||
Work.submit_task(task, worker, %{
|
||||
"provenance" => "human",
|
||||
"summary" => "The requested boundary was independently traced.",
|
||||
"evidence" => "Ran the negative authorization suite against both repository entry points."
|
||||
})
|
||||
|
||||
conn1 =
|
||||
conn
|
||||
|> authed(worker_token)
|
||||
|> post("/api/jobs/#{submitted.id}/accept", %{
|
||||
"reason" => "I should not be allowed to approve my own contribution.",
|
||||
"evidence" => "This evidence comes from the original contributor and is not independent."
|
||||
})
|
||||
|
||||
assert response(conn1, 404) == "Not Found"
|
||||
|
||||
reviewer = moderator_account_fixture()
|
||||
reviewer_token = Accounts.create_account_api_token(reviewer)
|
||||
assert Work.get_visible_task(submitted.id, Tarakan.Accounts.Scope.for_account(reviewer))
|
||||
|
||||
conn2 =
|
||||
conn
|
||||
|> recycle()
|
||||
|> authed(reviewer_token)
|
||||
|> post("/api/jobs/#{submitted.id}/accept", %{
|
||||
"reason" => "The result was independently reproduced at the pinned commit.",
|
||||
"evidence" => "Checked out the pinned SHA and ran the documented negative-path tests."
|
||||
})
|
||||
|
||||
assert response(conn2, 404) == "Not Found"
|
||||
|
||||
assert {:ok, accepted} =
|
||||
Work.accept_task(submitted, reviewer, %{
|
||||
"reason" => "The result was independently reproduced at the pinned commit.",
|
||||
"evidence" =>
|
||||
"Checked out the pinned SHA and ran the documented negative-path tests."
|
||||
})
|
||||
|
||||
assert accepted.status == "accepted"
|
||||
assert accepted.visibility == "public"
|
||||
|
||||
outsider = account_fixture()
|
||||
outsider_token = Accounts.create_account_api_token(outsider)
|
||||
|
||||
conn3 =
|
||||
conn
|
||||
|> recycle()
|
||||
|> authed(outsider_token)
|
||||
|> get(~p"/api/jobs/#{accepted.id}")
|
||||
|
||||
response = json_response(conn3, 200)
|
||||
assert response["status"] == "accepted"
|
||||
assert response["visibility"] == "public"
|
||||
assert response["contribution"]["summary"] =~ "boundary"
|
||||
assert response["contribution"]["evidence"] =~ "negative authorization suite"
|
||||
|
||||
assert {:ok, redacted} =
|
||||
Work.disclose_task(accepted, reviewer, "public_summary", %{
|
||||
"reason" =>
|
||||
"The redacted result is safe and useful without publishing raw evidence."
|
||||
})
|
||||
|
||||
conn4 =
|
||||
conn
|
||||
|> recycle()
|
||||
|> authed(outsider_token)
|
||||
|> get(~p"/api/jobs/#{redacted.id}")
|
||||
|
||||
response = json_response(conn4, 200)
|
||||
assert response["visibility"] == "public_summary"
|
||||
assert response["contribution"]["summary"] =~ "boundary"
|
||||
assert response["contribution"]["evidence"] == nil
|
||||
assert response["decisions"] == []
|
||||
assert response["disclosed_at"]
|
||||
assert response["sensitive_data_reviewed"] == false
|
||||
end
|
||||
end
|
||||
71
test/tarakan_web/controllers/badge_controller_test.exs
Normal file
71
test/tarakan_web/controllers/badge_controller_test.exs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
defmodule TarakanWeb.BadgeControllerTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
alias Tarakan.Repo
|
||||
alias Tarakan.Scans.CanonicalFinding
|
||||
|
||||
defp fixed_finding_fixture(repository, days_to_fix) do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
%CanonicalFinding{}
|
||||
|> Ecto.Changeset.change(%{
|
||||
repository_id: repository.id,
|
||||
fingerprint: "fp-#{System.unique_integer([:positive])}",
|
||||
file_path: "lib/app.ex",
|
||||
line_start: 1,
|
||||
severity: "high",
|
||||
title: "Finding",
|
||||
description: "Body.",
|
||||
status: "fixed",
|
||||
first_seen_commit_sha: random_commit_sha(),
|
||||
last_seen_commit_sha: random_commit_sha(),
|
||||
fixed_commit_sha: random_commit_sha(),
|
||||
verified_at: DateTime.add(now, -days_to_fix * 86_400, :second),
|
||||
fixed_at: now,
|
||||
inserted_at: now,
|
||||
updated_at: now
|
||||
})
|
||||
|> Repo.insert!()
|
||||
end
|
||||
|
||||
test "serves an SVG carrying the median fix time", %{conn: conn} do
|
||||
repository = listed_github_repository_fixture()
|
||||
fixed_finding_fixture(repository, 3)
|
||||
|
||||
conn = get(conn, ~p"/badges/github.com/#{repository.owner}/#{repository.name}")
|
||||
|
||||
assert response_content_type(conn, :svg) =~ "image/svg+xml"
|
||||
body = response(conn, 200)
|
||||
|
||||
assert body =~ "<svg"
|
||||
assert body =~ "security"
|
||||
assert body =~ "3d median fix"
|
||||
assert get_resp_header(conn, "cache-control") == ["public, max-age=900"]
|
||||
end
|
||||
|
||||
test "resolves a hosted repository from its bare slug", %{conn: conn} do
|
||||
repository = listed_hosted_repository_fixture()
|
||||
|
||||
conn = get(conn, ~p"/badges/#{repository.owner}/#{repository.name}")
|
||||
|
||||
assert response(conn, 200) =~ "no findings"
|
||||
end
|
||||
|
||||
test "does not leak whether an unlisted repository exists", %{conn: conn} do
|
||||
repository =
|
||||
github_repository_fixture()
|
||||
|> Tarakan.Repositories.Repository.listing_changeset(%{listing_status: "quarantined"})
|
||||
|> Repo.update!()
|
||||
|
||||
quarantined =
|
||||
get(conn, ~p"/badges/github.com/#{repository.owner}/#{repository.name}")
|
||||
|> response(200)
|
||||
|
||||
missing =
|
||||
get(conn, ~p"/badges/github.com/nobody/nothing")
|
||||
|> response(200)
|
||||
|
||||
assert quarantined =~ "not listed"
|
||||
assert quarantined == missing
|
||||
end
|
||||
end
|
||||
14
test/tarakan_web/controllers/error_html_test.exs
Normal file
14
test/tarakan_web/controllers/error_html_test.exs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
defmodule TarakanWeb.ErrorHTMLTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
# Bring render_to_string/4 for testing custom views
|
||||
import Phoenix.Template, only: [render_to_string: 4]
|
||||
|
||||
test "renders 404.html" do
|
||||
assert render_to_string(TarakanWeb.ErrorHTML, "404", "html", []) == "Not Found"
|
||||
end
|
||||
|
||||
test "renders 500.html" do
|
||||
assert render_to_string(TarakanWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
|
||||
end
|
||||
end
|
||||
12
test/tarakan_web/controllers/error_json_test.exs
Normal file
12
test/tarakan_web/controllers/error_json_test.exs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
defmodule TarakanWeb.ErrorJSONTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
test "renders 404" do
|
||||
assert TarakanWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
|
||||
end
|
||||
|
||||
test "renders 500" do
|
||||
assert TarakanWeb.ErrorJSON.render("500.json", %{}) ==
|
||||
%{errors: %{detail: "Internal Server Error"}}
|
||||
end
|
||||
end
|
||||
143
test/tarakan_web/controllers/feed_controller_test.exs
Normal file
143
test/tarakan_web/controllers/feed_controller_test.exs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
defmodule TarakanWeb.FeedControllerTest do
|
||||
use TarakanWeb.ConnCase
|
||||
|
||||
alias Tarakan.FindingMemory
|
||||
|
||||
test "the findings feed is a well-formed Atom document with absolute entry links", %{
|
||||
conn: conn
|
||||
} do
|
||||
submitter = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
|
||||
scan =
|
||||
repository
|
||||
|> scan_fixture(submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
|> publish_scan("public")
|
||||
|
||||
[finding] = scan.findings
|
||||
base = TarakanWeb.Endpoint.url()
|
||||
|
||||
conn = get(conn, ~p"/feeds/findings.xml")
|
||||
|
||||
assert response_content_type(conn, :xml) =~ "application/atom+xml"
|
||||
assert get_resp_header(conn, "cache-control") == ["public, max-age=300"]
|
||||
|
||||
body = response(conn, 200)
|
||||
assert_well_formed(body)
|
||||
|
||||
assert body =~ ~s(<feed xmlns="http://www.w3.org/2005/Atom">)
|
||||
assert body =~ ~s(<link rel="self" href="#{base}/feeds/findings.xml"/>)
|
||||
assert body =~ "<id>#{base}/findings/#{finding.public_id}</id>"
|
||||
assert body =~ ~s(<link href="#{base}/findings/#{finding.public_id}"/>)
|
||||
assert body =~ finding.title
|
||||
end
|
||||
|
||||
test "the findings feed excludes open and restricted findings", %{conn: conn} do
|
||||
submitter = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
|
||||
open_scan =
|
||||
scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
|
||||
restricted =
|
||||
scan_fixture(repository, account_fixture(), %{"findings_json" => findings_json_fixture(1)})
|
||||
|
||||
{:ok, _restricted} =
|
||||
Tarakan.Scans.update_visibility(
|
||||
Tarakan.Accounts.Scope.for_account(moderator_account_fixture()),
|
||||
restricted,
|
||||
"restricted",
|
||||
%{
|
||||
"moderation_reason" => "takedown_review",
|
||||
"moderation_notes" => "Restricted in this test to keep it out of the feed."
|
||||
}
|
||||
)
|
||||
|
||||
body = conn |> get(~p"/feeds/findings.xml") |> response(200)
|
||||
|
||||
for scan <- [open_scan, restricted], finding <- scan.findings do
|
||||
refute body =~ to_string(finding.public_id)
|
||||
end
|
||||
end
|
||||
|
||||
test "the infestations feed is a well-formed Atom document with absolute entry links", %{
|
||||
conn: conn
|
||||
} do
|
||||
submitter = github_account_fixture()
|
||||
other = github_account_fixture()
|
||||
repo_a = listed_github_repository_fixture(submitter)
|
||||
{:ok, repo_b} = Tarakan.Repositories.register_github_repository("acme/widget", other)
|
||||
repo_b = listed_repository_fixture(repo_b)
|
||||
|
||||
title = "Feed infestation sample #{System.unique_integer([:positive])}"
|
||||
|
||||
findings = fn path ->
|
||||
Jason.encode!(%{
|
||||
"tarakan_scan_format" => 1,
|
||||
"findings" => [
|
||||
%{
|
||||
"file" => path,
|
||||
"severity" => "high",
|
||||
"title" => title,
|
||||
"description" => "Shared issue class for the patterns feed test."
|
||||
}
|
||||
]
|
||||
})
|
||||
end
|
||||
|
||||
scan_fixture(repo_a, submitter, %{"findings_json" => findings.("lib/a.ex")})
|
||||
scan_fixture(repo_b, other, %{"findings_json" => findings.("src/b.rs")})
|
||||
|
||||
pattern_key = FindingMemory.pattern_key(title)
|
||||
base = TarakanWeb.Endpoint.url()
|
||||
|
||||
conn = get(conn, ~p"/feeds/infestations.xml")
|
||||
|
||||
assert response_content_type(conn, :xml) =~ "application/atom+xml"
|
||||
|
||||
body = response(conn, 200)
|
||||
assert_well_formed(body)
|
||||
|
||||
assert body =~ ~s(<link rel="self" href="#{base}/feeds/infestations.xml"/>)
|
||||
assert body =~ "<id>#{base}/infestations/#{pattern_key}</id>"
|
||||
assert body =~ title
|
||||
end
|
||||
|
||||
defp assert_well_formed(xml) do
|
||||
{document, rest} = :xmerl_scan.string(String.to_charlist(xml))
|
||||
assert rest == []
|
||||
assert {:xmlElement, :feed, :feed, _, _, _, _, _, _, _, _, _} = document
|
||||
end
|
||||
|
||||
defp publish_scan(scan, visibility) do
|
||||
scan = confirmation_fixture(scan, reviewer_account_fixture())
|
||||
scan = confirmation_fixture(scan, reviewer_account_fixture())
|
||||
|
||||
scope = Tarakan.Accounts.Scope.for_account(moderator_account_fixture())
|
||||
|
||||
if visibility == "public" do
|
||||
scan.repository
|
||||
|> Tarakan.Repositories.Repository.participation_changeset(%{
|
||||
participation_mode: "maintainer_verified"
|
||||
})
|
||||
|> Tarakan.Repo.update!()
|
||||
end
|
||||
|
||||
{:ok, scan} =
|
||||
Tarakan.Scans.accept_scan(scope, scan, %{
|
||||
"moderation_reason" => "evidence_reviewed",
|
||||
"moderation_notes" =>
|
||||
"Two independent reviewers supplied reproducible evidence for the pinned commit."
|
||||
})
|
||||
|
||||
{:ok, scan} =
|
||||
Tarakan.Scans.update_visibility(scope, scan, visibility, %{
|
||||
"moderation_reason" => "disclosure_reviewed",
|
||||
"moderation_notes" =>
|
||||
"Disclosure was separately reviewed for scope, secrets, and personal data.",
|
||||
"sensitive_data_reviewed" => "true"
|
||||
})
|
||||
|
||||
scan
|
||||
end
|
||||
end
|
||||
121
test/tarakan_web/controllers/github_auth_controller_test.exs
Normal file
121
test/tarakan_web/controllers/github_auth_controller_test.exs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
defmodule TarakanWeb.GitHubAuthControllerTest do
|
||||
use TarakanWeb.ConnCase
|
||||
|
||||
alias Tarakan.Accounts
|
||||
|
||||
test "starts GitHub authorization with state and PKCE", %{conn: conn} do
|
||||
conn = get(conn, ~p"/auth/github?return_to=/")
|
||||
|
||||
location = redirected_to(conn, 302)
|
||||
query = location |> URI.parse() |> Map.fetch!(:query) |> URI.decode_query()
|
||||
|
||||
assert String.starts_with?(location, "https://github.com/login/oauth/authorize?")
|
||||
assert query["client_id"] == "test-client-id"
|
||||
assert query["code_challenge_method"] == "S256"
|
||||
assert query["code_challenge"]
|
||||
assert query["state"] == get_session(conn, :github_oauth_state)
|
||||
assert get_session(conn, :github_oauth_verifier)
|
||||
end
|
||||
|
||||
test "creates a session after GitHub authorizes the user", %{conn: conn} do
|
||||
authorization_conn = get(conn, ~p"/auth/github?return_to=/")
|
||||
state = get_session(authorization_conn, :github_oauth_state)
|
||||
|
||||
callback_conn =
|
||||
authorization_conn
|
||||
|> recycle()
|
||||
|> get(~p"/auth/github/callback?code=valid-code&state=#{state}")
|
||||
|
||||
assert redirected_to(callback_conn) == "/"
|
||||
token = get_session(callback_conn, :account_token)
|
||||
assert {account, _inserted_at} = Accounts.get_account_by_session_token(token)
|
||||
assert account.handle == "tarakantester"
|
||||
assert is_nil(account.display_name)
|
||||
assert is_nil(account.email)
|
||||
refute get_session(callback_conn, :github_oauth_state)
|
||||
refute get_session(callback_conn, :github_oauth_verifier)
|
||||
|
||||
assert Tarakan.Repo.get_by!(Tarakan.Accounts.Identity,
|
||||
account_id: account.id,
|
||||
provider: "github"
|
||||
)
|
||||
end
|
||||
|
||||
test "rejects a callback with an invalid state", %{conn: conn} do
|
||||
authorization_conn = get(conn, ~p"/auth/github")
|
||||
|
||||
callback_conn =
|
||||
authorization_conn
|
||||
|> recycle()
|
||||
|> get(~p"/auth/github/callback?code=valid-code&state=wrong-state")
|
||||
|
||||
assert redirected_to(callback_conn) == "/"
|
||||
refute get_session(callback_conn, :account_token)
|
||||
end
|
||||
|
||||
test "links GitHub to the signed-in Tarakan account", %{conn: conn} do
|
||||
account = account_fixture()
|
||||
|
||||
authorization_conn =
|
||||
conn
|
||||
|> log_in_account(account)
|
||||
|> get(~p"/auth/github?return_to=/accounts/settings")
|
||||
|
||||
state = get_session(authorization_conn, :github_oauth_state)
|
||||
|
||||
callback_conn =
|
||||
authorization_conn
|
||||
|> recycle()
|
||||
|> get(~p"/auth/github/callback?code=valid-code&state=#{state}")
|
||||
|
||||
assert redirected_to(callback_conn) == "/accounts/settings"
|
||||
token = get_session(callback_conn, :account_token)
|
||||
assert {linked_account, _inserted_at} = Accounts.get_account_by_session_token(token)
|
||||
assert linked_account.id == account.id
|
||||
|
||||
assert Tarakan.Repo.get_by!(Tarakan.Accounts.Identity,
|
||||
account_id: account.id,
|
||||
provider: "github"
|
||||
)
|
||||
end
|
||||
|
||||
test "refuses to link an identity from a stale signed-in session", %{conn: conn} do
|
||||
account = account_fixture()
|
||||
stale_at = DateTime.add(DateTime.utc_now(:second), -9 * 60, :minute)
|
||||
|
||||
authorization_conn =
|
||||
conn
|
||||
|> log_in_account(account, token_authenticated_at: stale_at)
|
||||
|> get(~p"/auth/github?return_to=/accounts/settings")
|
||||
|
||||
state = get_session(authorization_conn, :github_oauth_state)
|
||||
|
||||
callback_conn =
|
||||
authorization_conn
|
||||
|> recycle()
|
||||
|> get(~p"/auth/github/callback?code=valid-code&state=#{state}")
|
||||
|
||||
assert redirected_to(callback_conn) == "/accounts/settings"
|
||||
|
||||
refute Tarakan.Repo.get_by(Tarakan.Accounts.Identity,
|
||||
account_id: account.id,
|
||||
provider: "github"
|
||||
)
|
||||
end
|
||||
|
||||
test "does not retain a malicious return path in the OAuth session" do
|
||||
for return_to <- ["//attacker.example", "/\\attacker.example", "/%255cattacker.example"] do
|
||||
query = URI.encode_query(%{"return_to" => return_to})
|
||||
authorization_conn = get(build_conn(), "/auth/github?#{query}")
|
||||
|
||||
assert get_session(authorization_conn, :github_oauth_return_to) == "/"
|
||||
end
|
||||
end
|
||||
|
||||
test "signs the current user out", %{conn: conn} do
|
||||
conn = conn |> log_in_account(account_fixture()) |> delete(~p"/accounts/log-out")
|
||||
|
||||
assert redirected_to(conn) == "/"
|
||||
refute get_session(conn, :account_token)
|
||||
end
|
||||
end
|
||||
115
test/tarakan_web/controllers/gitlab_auth_controller_test.exs
Normal file
115
test/tarakan_web/controllers/gitlab_auth_controller_test.exs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
defmodule TarakanWeb.GitLabAuthControllerTest do
|
||||
use TarakanWeb.ConnCase
|
||||
|
||||
alias Tarakan.Accounts
|
||||
|
||||
test "starts GitLab authorization with state, PKCE, and read_user", %{conn: conn} do
|
||||
conn = get(conn, ~p"/auth/gitlab?return_to=/")
|
||||
|
||||
location = redirected_to(conn, 302)
|
||||
query = location |> URI.parse() |> Map.fetch!(:query) |> URI.decode_query()
|
||||
|
||||
assert String.starts_with?(location, "https://gitlab.com/oauth/authorize?")
|
||||
assert query["client_id"] == "test-gitlab-client-id"
|
||||
assert query["response_type"] == "code"
|
||||
assert query["scope"] == "read_user"
|
||||
assert query["code_challenge_method"] == "S256"
|
||||
assert query["code_challenge"]
|
||||
assert query["state"] == get_session(conn, :gitlab_oauth_state)
|
||||
assert get_session(conn, :gitlab_oauth_verifier)
|
||||
end
|
||||
|
||||
test "creates an account and session without native registration", %{conn: conn} do
|
||||
authorization_conn = get(conn, ~p"/auth/gitlab?return_to=/")
|
||||
state = get_session(authorization_conn, :gitlab_oauth_state)
|
||||
|
||||
callback_conn =
|
||||
authorization_conn
|
||||
|> recycle()
|
||||
|> get(~p"/auth/gitlab/callback?code=valid-gitlab-code&state=#{state}")
|
||||
|
||||
assert redirected_to(callback_conn) == "/"
|
||||
token = get_session(callback_conn, :account_token)
|
||||
assert {account, _inserted_at} = Accounts.get_account_by_session_token(token)
|
||||
assert account.handle == "gitlabsignal"
|
||||
assert is_nil(account.display_name)
|
||||
assert is_nil(account.email)
|
||||
refute get_session(callback_conn, :gitlab_oauth_state)
|
||||
refute get_session(callback_conn, :gitlab_oauth_verifier)
|
||||
|
||||
assert Tarakan.Repo.get_by!(Tarakan.Accounts.Identity,
|
||||
account_id: account.id,
|
||||
provider: "gitlab",
|
||||
provider_uid: "24680"
|
||||
)
|
||||
end
|
||||
|
||||
test "rejects a callback with an invalid state", %{conn: conn} do
|
||||
authorization_conn = get(conn, ~p"/auth/gitlab")
|
||||
|
||||
callback_conn =
|
||||
authorization_conn
|
||||
|> recycle()
|
||||
|> get(~p"/auth/gitlab/callback?code=valid-gitlab-code&state=wrong-state")
|
||||
|
||||
assert redirected_to(callback_conn) == "/"
|
||||
refute get_session(callback_conn, :account_token)
|
||||
end
|
||||
|
||||
test "links GitLab to the signed-in Tarakan account", %{conn: conn} do
|
||||
account = account_fixture()
|
||||
|
||||
authorization_conn =
|
||||
conn
|
||||
|> log_in_account(account)
|
||||
|> get(~p"/auth/gitlab?return_to=/accounts/settings")
|
||||
|
||||
state = get_session(authorization_conn, :gitlab_oauth_state)
|
||||
|
||||
callback_conn =
|
||||
authorization_conn
|
||||
|> recycle()
|
||||
|> get(~p"/auth/gitlab/callback?code=valid-gitlab-code&state=#{state}")
|
||||
|
||||
assert redirected_to(callback_conn) == "/accounts/settings"
|
||||
token = get_session(callback_conn, :account_token)
|
||||
assert {linked_account, _inserted_at} = Accounts.get_account_by_session_token(token)
|
||||
assert linked_account.id == account.id
|
||||
|
||||
assert Tarakan.Repo.get_by!(Tarakan.Accounts.Identity,
|
||||
account_id: account.id,
|
||||
provider: "gitlab"
|
||||
)
|
||||
end
|
||||
|
||||
test "refuses to link an identity from a stale signed-in session", %{conn: conn} do
|
||||
account = account_fixture()
|
||||
stale_at = DateTime.add(DateTime.utc_now(:second), -9 * 60, :minute)
|
||||
|
||||
authorization_conn =
|
||||
conn
|
||||
|> log_in_account(account, token_authenticated_at: stale_at)
|
||||
|> get(~p"/auth/gitlab?return_to=/accounts/settings")
|
||||
|
||||
state = get_session(authorization_conn, :gitlab_oauth_state)
|
||||
|
||||
callback_conn =
|
||||
authorization_conn
|
||||
|> recycle()
|
||||
|> get(~p"/auth/gitlab/callback?code=valid-gitlab-code&state=#{state}")
|
||||
|
||||
assert redirected_to(callback_conn) == "/accounts/settings"
|
||||
|
||||
refute Tarakan.Repo.get_by(Tarakan.Accounts.Identity,
|
||||
account_id: account.id,
|
||||
provider: "gitlab"
|
||||
)
|
||||
end
|
||||
|
||||
test "does not retain an encoded authority return path in the OAuth session" do
|
||||
query = URI.encode_query(%{"return_to" => "/%252f%252fattacker.example"})
|
||||
authorization_conn = get(build_conn(), "/auth/gitlab?#{query}")
|
||||
|
||||
assert get_session(authorization_conn, :gitlab_oauth_return_to) == "/"
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
defmodule TarakanWeb.InfestationRedirectControllerTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
test "GET /patterns permanently redirects to /infestations", %{conn: conn} do
|
||||
conn = get(conn, ~p"/patterns")
|
||||
assert redirected_to(conn, 301) == ~p"/infestations"
|
||||
end
|
||||
|
||||
test "GET /patterns/:pattern_key permanently redirects to /infestations/:pattern_key", %{
|
||||
conn: conn
|
||||
} do
|
||||
conn = get(conn, ~p"/patterns/some-pattern-key")
|
||||
assert redirected_to(conn, 301) == ~p"/infestations/some-pattern-key"
|
||||
end
|
||||
end
|
||||
138
test/tarakan_web/controllers/seo_controller_test.exs
Normal file
138
test/tarakan_web/controllers/seo_controller_test.exs
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
defmodule TarakanWeb.SEOControllerTest do
|
||||
use TarakanWeb.ConnCase
|
||||
|
||||
test "robots.txt allows crawling and points at the sitemap", %{conn: conn} do
|
||||
response = conn |> get(~p"/robots.txt") |> text_response(200)
|
||||
|
||||
assert response =~ "User-agent: *"
|
||||
assert response =~ "Allow: /"
|
||||
assert response =~ "Disallow: /*/code/"
|
||||
assert response =~ "Sitemap: " <> TarakanWeb.Endpoint.url() <> "/sitemap.xml"
|
||||
end
|
||||
|
||||
test "security.txt follows RFC 9116 with a fresh expiry", %{conn: conn} do
|
||||
conn = get(conn, "/.well-known/security.txt")
|
||||
|
||||
assert response_content_type(conn, :text) =~ "text/plain"
|
||||
|
||||
response = response(conn, 200)
|
||||
base = TarakanWeb.Endpoint.url()
|
||||
|
||||
assert response =~ "Contact: mailto:security@example.com"
|
||||
assert response =~ "Preferred-Languages: en"
|
||||
assert response =~ "Canonical: #{base}/.well-known/security.txt"
|
||||
assert response =~ "Policy: #{base}/policies/disclosure"
|
||||
|
||||
assert [_, expires] = Regex.run(~r/^Expires: (.+)$/m, response)
|
||||
assert {:ok, expires_at, _} = DateTime.from_iso8601(expires)
|
||||
assert DateTime.diff(expires_at, DateTime.utc_now(), :day) in 360..366
|
||||
end
|
||||
|
||||
test "the sitemap lists hubs, listed repositories, public findings, and open jobs", %{
|
||||
conn: conn
|
||||
} do
|
||||
submitter = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
|
||||
scan =
|
||||
repository
|
||||
|> scan_fixture(submitter, %{"findings_json" => findings_json_fixture(2)})
|
||||
|> publish_scan("public")
|
||||
|
||||
response = conn |> get(~p"/sitemap.xml") |> response(200)
|
||||
base = TarakanWeb.Endpoint.url()
|
||||
|
||||
assert response =~ ~s(<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">)
|
||||
assert response =~ "<loc>#{base}/</loc>"
|
||||
assert response =~ "<loc>#{base}/explore</loc>"
|
||||
assert response =~ "<loc>#{base}/leaderboard</loc>"
|
||||
assert response =~ "<loc>#{base}/jobs</loc>"
|
||||
assert response =~ "<loc>#{base}/agents</loc>"
|
||||
assert response =~ "<loc>#{base}/github.com/openai/codex/security</loc>"
|
||||
|
||||
for finding <- scan.findings do
|
||||
assert response =~ "<loc>#{base}/findings/#{finding.public_id}</loc>"
|
||||
end
|
||||
end
|
||||
|
||||
test "the sitemap indexes fresh findings but not restricted or summary ones", %{conn: conn} do
|
||||
submitter = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
|
||||
fresh = scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
|
||||
restricted =
|
||||
scan_fixture(repository, account_fixture(), %{"findings_json" => findings_json_fixture(1)})
|
||||
|
||||
{:ok, _restricted} =
|
||||
Tarakan.Scans.update_visibility(
|
||||
Tarakan.Accounts.Scope.for_account(moderator_account_fixture()),
|
||||
restricted,
|
||||
"restricted",
|
||||
%{
|
||||
"moderation_reason" => "takedown_review",
|
||||
"moderation_notes" =>
|
||||
"Deliberately restricted in this test to keep it out of search discovery."
|
||||
}
|
||||
)
|
||||
|
||||
summary =
|
||||
scan_fixture(repository, account_fixture(), %{"findings_json" => findings_json_fixture(1)})
|
||||
|
||||
publish_scan(summary, "public_summary")
|
||||
|
||||
response = conn |> get(~p"/sitemap.xml") |> response(200)
|
||||
|
||||
for finding <- fresh.findings do
|
||||
assert response =~ "/findings/#{finding.public_id}"
|
||||
end
|
||||
|
||||
for scan <- [restricted, summary], finding <- scan.findings do
|
||||
refute response =~ to_string(finding.public_id)
|
||||
end
|
||||
end
|
||||
|
||||
test "quarantined repositories stay out of the sitemap", %{conn: conn} do
|
||||
repository = github_repository_fixture()
|
||||
|
||||
repository
|
||||
|> Tarakan.Repositories.Repository.listing_changeset(%{listing_status: "quarantined"})
|
||||
|> Tarakan.Repo.update!()
|
||||
|
||||
response = conn |> get(~p"/sitemap.xml") |> response(200)
|
||||
|
||||
refute response =~ "openai/codex"
|
||||
end
|
||||
|
||||
defp publish_scan(scan, visibility) do
|
||||
scan = confirmation_fixture(scan, reviewer_account_fixture())
|
||||
scan = confirmation_fixture(scan, reviewer_account_fixture())
|
||||
|
||||
scope = Tarakan.Accounts.Scope.for_account(moderator_account_fixture())
|
||||
|
||||
if visibility == "public" do
|
||||
scan.repository
|
||||
|> Tarakan.Repositories.Repository.participation_changeset(%{
|
||||
participation_mode: "maintainer_verified"
|
||||
})
|
||||
|> Tarakan.Repo.update!()
|
||||
end
|
||||
|
||||
{:ok, scan} =
|
||||
Tarakan.Scans.accept_scan(scope, scan, %{
|
||||
"moderation_reason" => "evidence_reviewed",
|
||||
"moderation_notes" =>
|
||||
"Two independent reviewers supplied reproducible evidence for the pinned commit."
|
||||
})
|
||||
|
||||
{:ok, scan} =
|
||||
Tarakan.Scans.update_visibility(scope, scan, visibility, %{
|
||||
"moderation_reason" => "disclosure_reviewed",
|
||||
"moderation_notes" =>
|
||||
"Disclosure was separately reviewed for scope, secrets, and personal data.",
|
||||
"sensitive_data_reviewed" => "true"
|
||||
})
|
||||
|
||||
scan
|
||||
end
|
||||
end
|
||||
281
test/tarakan_web/controllers/webhooks/stripe_controller_test.exs
Normal file
281
test/tarakan_web/controllers/webhooks/stripe_controller_test.exs
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
defmodule TarakanWeb.Webhooks.StripeControllerTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
alias Tarakan.Accounts.Scope
|
||||
alias Tarakan.Market.Bounty
|
||||
alias Tarakan.Repo
|
||||
|
||||
@secret "whsec_test_secret"
|
||||
|
||||
defp fiat_bounty do
|
||||
repository = listed_github_repository_fixture()
|
||||
sponsor = active_account_fixture() |> fund_credits(1_000)
|
||||
|
||||
{bounty, _checkout_url} = fiat_bounty_fixture(Scope.for_account(sponsor), repository)
|
||||
bounty
|
||||
end
|
||||
|
||||
defp signed_post(conn, payload, opts \\ []) do
|
||||
timestamp = Keyword.get(opts, :timestamp, System.system_time(:second))
|
||||
secret = Keyword.get(opts, :secret, @secret)
|
||||
|
||||
signature =
|
||||
:crypto.mac(:hmac, :sha256, secret, "#{timestamp}.#{payload}")
|
||||
|> Base.encode16(case: :lower)
|
||||
|
||||
conn
|
||||
|> put_req_header("content-type", "application/json")
|
||||
|> put_req_header("stripe-signature", "t=#{timestamp},v1=#{signature}")
|
||||
|> post(~p"/webhooks/stripe", payload)
|
||||
end
|
||||
|
||||
defp checkout_completed_payload(session_id) do
|
||||
Jason.encode!(%{
|
||||
"id" => "evt_1",
|
||||
"type" => "checkout.session.completed",
|
||||
"data" => %{"object" => %{"id" => session_id, "payment_intent" => "pi_1"}}
|
||||
})
|
||||
end
|
||||
|
||||
test "a validly signed checkout.session.completed funds the bounty", %{conn: conn} do
|
||||
bounty = fiat_bounty()
|
||||
|
||||
conn = signed_post(conn, checkout_completed_payload(bounty.stripe_checkout_session_id))
|
||||
|
||||
assert json_response(conn, 200) == %{"received" => true}
|
||||
assert Repo.get!(Bounty, bounty.id).status == "open"
|
||||
end
|
||||
|
||||
defp checkout_payload(session_id, type, extra) do
|
||||
Jason.encode!(%{
|
||||
"id" => "evt_1",
|
||||
"type" => type,
|
||||
"data" => %{"object" => Map.merge(%{"id" => session_id}, extra)}
|
||||
})
|
||||
end
|
||||
|
||||
test "a completed but unpaid session does not fund the bounty", %{conn: conn} do
|
||||
bounty = fiat_bounty()
|
||||
|
||||
# Delayed-notification methods complete the session before the money
|
||||
# arrives. Funding here would let work be claimed against nothing.
|
||||
conn =
|
||||
signed_post(
|
||||
conn,
|
||||
checkout_payload(
|
||||
bounty.stripe_checkout_session_id,
|
||||
"checkout.session.completed",
|
||||
%{"payment_status" => "unpaid"}
|
||||
)
|
||||
)
|
||||
|
||||
assert json_response(conn, 200) == %{"received" => true}
|
||||
assert Repo.get!(Bounty, bounty.id).status == "pending_funding"
|
||||
end
|
||||
|
||||
test "the later async payment confirmation funds it", %{conn: conn} do
|
||||
bounty = fiat_bounty()
|
||||
|
||||
conn =
|
||||
signed_post(
|
||||
conn,
|
||||
checkout_payload(
|
||||
bounty.stripe_checkout_session_id,
|
||||
"checkout.session.completed",
|
||||
%{"payment_status" => "unpaid"}
|
||||
)
|
||||
)
|
||||
|
||||
assert json_response(conn, 200) == %{"received" => true}
|
||||
assert Repo.get!(Bounty, bounty.id).status == "pending_funding"
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> recycle()
|
||||
|> signed_post(
|
||||
checkout_payload(
|
||||
bounty.stripe_checkout_session_id,
|
||||
"checkout.session.async_payment_succeeded",
|
||||
%{}
|
||||
)
|
||||
)
|
||||
|
||||
assert json_response(conn, 200) == %{"received" => true}
|
||||
assert Repo.get!(Bounty, bounty.id).status == "open"
|
||||
end
|
||||
|
||||
test "a failed async payment leaves the bounty unfunded", %{conn: conn} do
|
||||
bounty = fiat_bounty()
|
||||
|
||||
conn =
|
||||
signed_post(
|
||||
conn,
|
||||
checkout_payload(
|
||||
bounty.stripe_checkout_session_id,
|
||||
"checkout.session.async_payment_failed",
|
||||
%{}
|
||||
)
|
||||
)
|
||||
|
||||
assert json_response(conn, 200) == %{"received" => true}
|
||||
assert Repo.get!(Bounty, bounty.id).status == "pending_funding"
|
||||
end
|
||||
|
||||
test "an invalid signature is rejected", %{conn: conn} do
|
||||
bounty = fiat_bounty()
|
||||
|
||||
conn =
|
||||
signed_post(conn, checkout_completed_payload(bounty.stripe_checkout_session_id),
|
||||
secret: "whsec_wrong"
|
||||
)
|
||||
|
||||
assert json_response(conn, 400) == %{"error" => "invalid_signature"}
|
||||
assert Repo.get!(Bounty, bounty.id).status == "pending_funding"
|
||||
end
|
||||
|
||||
test "a stale timestamp is rejected", %{conn: conn} do
|
||||
bounty = fiat_bounty()
|
||||
|
||||
conn =
|
||||
signed_post(conn, checkout_completed_payload(bounty.stripe_checkout_session_id),
|
||||
timestamp: System.system_time(:second) - 600
|
||||
)
|
||||
|
||||
assert json_response(conn, 400) == %{"error" => "timestamp_outside_tolerance"}
|
||||
assert Repo.get!(Bounty, bounty.id).status == "pending_funding"
|
||||
end
|
||||
|
||||
test "a missing signature header is rejected", %{conn: conn} do
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("content-type", "application/json")
|
||||
|> post(~p"/webhooks/stripe", checkout_completed_payload("cs_test_whatever"))
|
||||
|
||||
assert json_response(conn, 400) == %{"error" => "missing_signature"}
|
||||
end
|
||||
|
||||
test "a tampered body is rejected", %{conn: conn} do
|
||||
timestamp = System.system_time(:second)
|
||||
|
||||
signature =
|
||||
:crypto.mac(
|
||||
:hmac,
|
||||
:sha256,
|
||||
@secret,
|
||||
"#{timestamp}.{\"type\":\"checkout.session.completed\"}"
|
||||
)
|
||||
|> Base.encode16(case: :lower)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("content-type", "application/json")
|
||||
|> put_req_header("stripe-signature", "t=#{timestamp},v1=#{signature}")
|
||||
|> post(~p"/webhooks/stripe", checkout_completed_payload("cs_test_tampered"))
|
||||
|
||||
assert json_response(conn, 400) == %{"error" => "invalid_signature"}
|
||||
end
|
||||
|
||||
test "unknown events are acknowledged and ignored", %{conn: conn} do
|
||||
payload = Jason.encode!(%{"id" => "evt_2", "type" => "payment_intent.created", "data" => %{}})
|
||||
|
||||
conn = signed_post(conn, payload)
|
||||
|
||||
assert json_response(conn, 200) == %{"received" => true}
|
||||
end
|
||||
|
||||
test "funding an unknown session still returns 200", %{conn: conn} do
|
||||
conn = signed_post(conn, checkout_completed_payload("cs_test_unknown"))
|
||||
|
||||
assert json_response(conn, 200) == %{"received" => true}
|
||||
end
|
||||
|
||||
describe "subscription events" do
|
||||
defp subscription_event_payload(type, object) do
|
||||
Jason.encode!(%{"id" => "evt_sub", "type" => type, "data" => %{"object" => object}})
|
||||
end
|
||||
|
||||
defp checkout_subscription_payload(account) do
|
||||
subscription_event_payload("checkout.session.completed", %{
|
||||
"id" => "cs_test_sub",
|
||||
"mode" => "subscription",
|
||||
"client_reference_id" => Integer.to_string(account.id),
|
||||
"customer" => "cus_webhook_1",
|
||||
"subscription" => "sub_webhook_1",
|
||||
"metadata" => %{"plan" => "team"}
|
||||
})
|
||||
end
|
||||
|
||||
test "a signed subscription checkout activates the account's subscription", %{conn: conn} do
|
||||
account = account_fixture()
|
||||
|
||||
conn = signed_post(conn, checkout_subscription_payload(account))
|
||||
|
||||
assert json_response(conn, 200) == %{"received" => true}
|
||||
|
||||
subscription = Tarakan.Billing.subscription(account)
|
||||
assert subscription.status == "active"
|
||||
assert subscription.plan == "team"
|
||||
assert subscription.stripe_subscription_id == "sub_webhook_1"
|
||||
end
|
||||
|
||||
test "customer.subscription.updated syncs the subscription", %{conn: conn} do
|
||||
account = account_fixture()
|
||||
|
||||
conn = signed_post(conn, checkout_subscription_payload(account))
|
||||
assert json_response(conn, 200) == %{"received" => true}
|
||||
|
||||
payload =
|
||||
subscription_event_payload("customer.subscription.updated", %{
|
||||
"id" => "sub_webhook_1",
|
||||
"status" => "active",
|
||||
"current_period_end" => 1_900_000_000,
|
||||
"items" => %{"data" => [%{"price" => %{"id" => "price_enterprise_test"}}]}
|
||||
})
|
||||
|
||||
conn = signed_post(build_conn(), payload)
|
||||
|
||||
assert json_response(conn, 200) == %{"received" => true}
|
||||
|
||||
subscription = Tarakan.Billing.subscription(account)
|
||||
assert subscription.plan == "enterprise"
|
||||
assert DateTime.to_unix(subscription.current_period_end) == 1_900_000_000
|
||||
end
|
||||
|
||||
test "invoice.payment_failed and customer.subscription.deleted move the status", %{
|
||||
conn: conn
|
||||
} do
|
||||
account = account_fixture()
|
||||
|
||||
conn = signed_post(conn, checkout_subscription_payload(account))
|
||||
assert json_response(conn, 200) == %{"received" => true}
|
||||
|
||||
failed =
|
||||
subscription_event_payload("invoice.payment_failed", %{
|
||||
"subscription" => "sub_webhook_1"
|
||||
})
|
||||
|
||||
conn = signed_post(build_conn(), failed)
|
||||
assert json_response(conn, 200) == %{"received" => true}
|
||||
assert Tarakan.Billing.subscription(account).status == "past_due"
|
||||
|
||||
deleted =
|
||||
subscription_event_payload("customer.subscription.deleted", %{
|
||||
"id" => "sub_webhook_1"
|
||||
})
|
||||
|
||||
conn = signed_post(build_conn(), deleted)
|
||||
assert json_response(conn, 200) == %{"received" => true}
|
||||
assert Tarakan.Billing.subscription(account).status == "canceled"
|
||||
end
|
||||
|
||||
test "subscription events require a valid signature", %{conn: conn} do
|
||||
account = account_fixture()
|
||||
|
||||
conn =
|
||||
signed_post(conn, checkout_subscription_payload(account), secret: "whsec_wrong")
|
||||
|
||||
assert json_response(conn, 400) == %{"error" => "invalid_signature"}
|
||||
assert Tarakan.Billing.subscription(account) == nil
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue