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
489
test/tarakan_web/account_auth_test.exs
Normal file
489
test/tarakan_web/account_auth_test.exs
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
defmodule TarakanWeb.AccountAuthTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
alias Phoenix.LiveView
|
||||
alias Tarakan.Accounts
|
||||
alias Tarakan.Accounts.ApiCredentials
|
||||
alias Tarakan.Accounts.Scope
|
||||
alias TarakanWeb.AccountAuth
|
||||
|
||||
import Tarakan.AccountsFixtures
|
||||
|
||||
@remember_me_cookie "_tarakan_web_account_remember_me"
|
||||
@remember_me_cookie_max_age 60 * 60 * 24 * 60
|
||||
|
||||
setup %{conn: conn} do
|
||||
conn =
|
||||
conn
|
||||
|> Map.replace!(:secret_key_base, TarakanWeb.Endpoint.config(:secret_key_base))
|
||||
|> init_test_session(%{})
|
||||
|
||||
%{account: %{account_fixture() | authenticated_at: DateTime.utc_now(:second)}, conn: conn}
|
||||
end
|
||||
|
||||
describe "log_in_account/3" do
|
||||
test "stores the account token in the session", %{conn: conn, account: account} do
|
||||
conn = AccountAuth.log_in_account(conn, account)
|
||||
assert token = get_session(conn, :account_token)
|
||||
assert get_session(conn, :live_socket_id) == Accounts.account_sessions_topic(account.id)
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
assert Accounts.get_account_by_session_token(token)
|
||||
end
|
||||
|
||||
test "clears everything previously stored in the session", %{conn: conn, account: account} do
|
||||
conn = conn |> put_session(:to_be_removed, "value") |> AccountAuth.log_in_account(account)
|
||||
refute get_session(conn, :to_be_removed)
|
||||
end
|
||||
|
||||
test "keeps session when re-authenticating", %{conn: conn, account: account} do
|
||||
conn =
|
||||
conn
|
||||
|> assign(:current_scope, Scope.for_account(account))
|
||||
|> put_session(:to_be_removed, "value")
|
||||
|> AccountAuth.log_in_account(account)
|
||||
|
||||
assert get_session(conn, :to_be_removed)
|
||||
end
|
||||
|
||||
test "clears session when account does not match when re-authenticating", %{
|
||||
conn: conn,
|
||||
account: account
|
||||
} do
|
||||
other_account = account_fixture()
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> assign(:current_scope, Scope.for_account(other_account))
|
||||
|> put_session(:to_be_removed, "value")
|
||||
|> AccountAuth.log_in_account(account)
|
||||
|
||||
refute get_session(conn, :to_be_removed)
|
||||
end
|
||||
|
||||
test "redirects to the configured path", %{conn: conn, account: account} do
|
||||
conn =
|
||||
conn |> put_session(:account_return_to, "/hello") |> AccountAuth.log_in_account(account)
|
||||
|
||||
assert redirected_to(conn) == "/hello"
|
||||
end
|
||||
|
||||
test "writes a cookie if remember_me is configured", %{conn: conn, account: account} do
|
||||
conn =
|
||||
conn |> fetch_cookies() |> AccountAuth.log_in_account(account, %{"remember_me" => "true"})
|
||||
|
||||
assert get_session(conn, :account_token) == conn.cookies[@remember_me_cookie]
|
||||
assert get_session(conn, :account_remember_me) == true
|
||||
|
||||
assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
|
||||
assert signed_token != get_session(conn, :account_token)
|
||||
assert max_age == @remember_me_cookie_max_age
|
||||
end
|
||||
|
||||
test "writes the persistent cookie without remember_me params (OAuth logins)", %{
|
||||
conn: conn,
|
||||
account: account
|
||||
} do
|
||||
conn = conn |> fetch_cookies() |> AccountAuth.log_in_account(account)
|
||||
|
||||
assert get_session(conn, :account_token) == conn.cookies[@remember_me_cookie]
|
||||
assert get_session(conn, :account_remember_me) == true
|
||||
assert %{max_age: @remember_me_cookie_max_age} = conn.resp_cookies[@remember_me_cookie]
|
||||
end
|
||||
|
||||
test "redirects home when account is already logged in and no return_to is set", %{
|
||||
conn: conn,
|
||||
account: account
|
||||
} do
|
||||
conn =
|
||||
conn
|
||||
|> assign(:current_scope, Scope.for_account(account))
|
||||
|> AccountAuth.log_in_account(account)
|
||||
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
end
|
||||
|
||||
test "writes a cookie if remember_me was set in previous session", %{
|
||||
conn: conn,
|
||||
account: account
|
||||
} do
|
||||
conn =
|
||||
conn |> fetch_cookies() |> AccountAuth.log_in_account(account, %{"remember_me" => "true"})
|
||||
|
||||
assert get_session(conn, :account_token) == conn.cookies[@remember_me_cookie]
|
||||
assert get_session(conn, :account_remember_me) == true
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> recycle()
|
||||
|> Map.replace!(:secret_key_base, TarakanWeb.Endpoint.config(:secret_key_base))
|
||||
|> fetch_cookies()
|
||||
|> init_test_session(%{account_remember_me: true})
|
||||
|
||||
# the conn is already logged in and has the remember_me cookie set,
|
||||
# now we log in again and even without explicitly setting remember_me,
|
||||
# the cookie should be set again
|
||||
conn = conn |> AccountAuth.log_in_account(account, %{})
|
||||
assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
|
||||
assert signed_token != get_session(conn, :account_token)
|
||||
assert max_age == @remember_me_cookie_max_age
|
||||
assert get_session(conn, :account_remember_me) == true
|
||||
end
|
||||
end
|
||||
|
||||
describe "logout_account/1" do
|
||||
test "erases session and cookies", %{conn: conn, account: account} do
|
||||
account_token = Accounts.generate_account_session_token(account)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_session(:account_token, account_token)
|
||||
|> put_req_cookie(@remember_me_cookie, account_token)
|
||||
|> fetch_cookies()
|
||||
|> AccountAuth.log_out_account()
|
||||
|
||||
refute get_session(conn, :account_token)
|
||||
refute conn.cookies[@remember_me_cookie]
|
||||
assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
refute Accounts.get_account_by_session_token(account_token)
|
||||
end
|
||||
|
||||
test "broadcasts to the given live_socket_id", %{conn: conn} do
|
||||
live_socket_id = "accounts_sessions:abcdef-token"
|
||||
TarakanWeb.Endpoint.subscribe(live_socket_id)
|
||||
|
||||
conn
|
||||
|> put_session(:live_socket_id, live_socket_id)
|
||||
|> AccountAuth.log_out_account()
|
||||
|
||||
assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id}
|
||||
end
|
||||
|
||||
test "works even if account is already logged out", %{conn: conn} do
|
||||
conn = conn |> fetch_cookies() |> AccountAuth.log_out_account()
|
||||
refute get_session(conn, :account_token)
|
||||
assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_current_scope_for_account/2" do
|
||||
test "authenticates account from session", %{conn: conn, account: account} do
|
||||
account_token = Accounts.generate_account_session_token(account)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_session(:account_token, account_token)
|
||||
|> AccountAuth.fetch_current_scope_for_account([])
|
||||
|
||||
assert conn.assigns.current_scope.account.id == account.id
|
||||
assert conn.assigns.current_scope.account.authenticated_at == account.authenticated_at
|
||||
assert get_session(conn, :account_token) == account_token
|
||||
end
|
||||
|
||||
test "authenticates account from cookies", %{conn: conn, account: account} do
|
||||
logged_in_conn =
|
||||
conn |> fetch_cookies() |> AccountAuth.log_in_account(account, %{"remember_me" => "true"})
|
||||
|
||||
account_token = logged_in_conn.cookies[@remember_me_cookie]
|
||||
%{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie]
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_req_cookie(@remember_me_cookie, signed_token)
|
||||
|> AccountAuth.fetch_current_scope_for_account([])
|
||||
|
||||
assert conn.assigns.current_scope.account.id == account.id
|
||||
assert conn.assigns.current_scope.account.authenticated_at == account.authenticated_at
|
||||
assert get_session(conn, :account_token) == account_token
|
||||
assert get_session(conn, :account_remember_me)
|
||||
|
||||
assert get_session(conn, :live_socket_id) == Accounts.account_sessions_topic(account.id)
|
||||
end
|
||||
|
||||
test "does not authenticate if data is missing", %{conn: conn, account: account} do
|
||||
_ = Accounts.generate_account_session_token(account)
|
||||
conn = AccountAuth.fetch_current_scope_for_account(conn, [])
|
||||
refute get_session(conn, :account_token)
|
||||
refute conn.assigns.current_scope
|
||||
end
|
||||
|
||||
test "reissues a new token after a few days and refreshes cookie", %{
|
||||
conn: conn,
|
||||
account: account
|
||||
} do
|
||||
logged_in_conn =
|
||||
conn |> fetch_cookies() |> AccountAuth.log_in_account(account, %{"remember_me" => "true"})
|
||||
|
||||
token = logged_in_conn.cookies[@remember_me_cookie]
|
||||
%{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie]
|
||||
|
||||
offset_account_token(token, -10, :day)
|
||||
{account, _} = Accounts.get_account_by_session_token(token)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_session(:account_token, token)
|
||||
|> put_session(:account_remember_me, true)
|
||||
|> put_req_cookie(@remember_me_cookie, signed_token)
|
||||
|> AccountAuth.fetch_current_scope_for_account([])
|
||||
|
||||
assert conn.assigns.current_scope.account.id == account.id
|
||||
assert conn.assigns.current_scope.account.authenticated_at == account.authenticated_at
|
||||
assert new_token = get_session(conn, :account_token)
|
||||
assert new_token != token
|
||||
assert %{value: new_signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
|
||||
assert new_signed_token != signed_token
|
||||
assert max_age == @remember_me_cookie_max_age
|
||||
|
||||
# The predecessor token is deleted so a stolen cookie dies at rotation.
|
||||
refute Accounts.get_account_by_session_token(token)
|
||||
assert Accounts.get_account_by_session_token(new_token)
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_api_account/2" do
|
||||
test "assigns credential scopes, repository boundary, and authentication method", %{
|
||||
conn: conn,
|
||||
account: account
|
||||
} do
|
||||
repository = github_repository_fixture(account)
|
||||
|
||||
assert {:ok, token, credential} =
|
||||
ApiCredentials.create(account, %{
|
||||
name: "repository worker",
|
||||
scopes: ["tasks:read", "tasks:claim"],
|
||||
repository_id: repository.id
|
||||
})
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_req_header("authorization", "Bearer #{token}")
|
||||
|> AccountAuth.fetch_api_account([])
|
||||
|
||||
scope = conn.assigns.current_scope
|
||||
assert scope.account_id == account.id
|
||||
assert scope.token_id == credential.id
|
||||
assert scope.token_scopes == MapSet.new(["tasks:read", "tasks:claim"])
|
||||
assert scope.token_repository_id == repository.id
|
||||
assert scope.authentication_method == :api_credential
|
||||
end
|
||||
|
||||
test "rejects malformed and revoked credentials", %{conn: conn, account: account} do
|
||||
invalid =
|
||||
conn
|
||||
|> put_req_header("authorization", "Bearer not-a-token")
|
||||
|> AccountAuth.fetch_api_account([])
|
||||
|
||||
assert invalid.halted
|
||||
assert invalid.status == 401
|
||||
|
||||
assert {:ok, token, credential} = ApiCredentials.create(account, %{name: "revoked"})
|
||||
assert {:ok, _credential} = ApiCredentials.revoke(account, credential.id)
|
||||
|
||||
revoked =
|
||||
conn
|
||||
|> recycle()
|
||||
|> put_req_header("authorization", "Bearer #{token}")
|
||||
|> AccountAuth.fetch_api_account([])
|
||||
|
||||
assert revoked.halted
|
||||
assert revoked.status == 401
|
||||
end
|
||||
end
|
||||
|
||||
describe "on_mount :mount_current_scope" do
|
||||
setup %{conn: conn} do
|
||||
%{conn: AccountAuth.fetch_current_scope_for_account(conn, [])}
|
||||
end
|
||||
|
||||
test "assigns current_scope based on a valid account_token", %{conn: conn, account: account} do
|
||||
account_token = Accounts.generate_account_session_token(account)
|
||||
session = conn |> put_session(:account_token, account_token) |> get_session()
|
||||
|
||||
{:cont, updated_socket} =
|
||||
AccountAuth.on_mount(:mount_current_scope, %{}, session, %LiveView.Socket{})
|
||||
|
||||
assert updated_socket.assigns.current_scope.account.id == account.id
|
||||
end
|
||||
|
||||
test "assigns nil to current_scope assign if there isn't a valid account_token", %{conn: conn} do
|
||||
account_token = "invalid_token"
|
||||
session = conn |> put_session(:account_token, account_token) |> get_session()
|
||||
|
||||
{:cont, updated_socket} =
|
||||
AccountAuth.on_mount(:mount_current_scope, %{}, session, %LiveView.Socket{})
|
||||
|
||||
assert updated_socket.assigns.current_scope == nil
|
||||
end
|
||||
|
||||
test "assigns nil to current_scope assign if there isn't a account_token", %{conn: conn} do
|
||||
session = conn |> get_session()
|
||||
|
||||
{:cont, updated_socket} =
|
||||
AccountAuth.on_mount(:mount_current_scope, %{}, session, %LiveView.Socket{})
|
||||
|
||||
assert updated_socket.assigns.current_scope == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "on_mount :require_authenticated" do
|
||||
test "authenticates current_scope based on a valid account_token", %{
|
||||
conn: conn,
|
||||
account: account
|
||||
} do
|
||||
account_token = Accounts.generate_account_session_token(account)
|
||||
session = conn |> put_session(:account_token, account_token) |> get_session()
|
||||
|
||||
{:cont, updated_socket} =
|
||||
AccountAuth.on_mount(:require_authenticated, %{}, session, %LiveView.Socket{})
|
||||
|
||||
assert updated_socket.assigns.current_scope.account.id == account.id
|
||||
end
|
||||
|
||||
test "redirects to login page if there isn't a valid account_token", %{conn: conn} do
|
||||
account_token = "invalid_token"
|
||||
session = conn |> put_session(:account_token, account_token) |> get_session()
|
||||
|
||||
socket = %LiveView.Socket{
|
||||
endpoint: TarakanWeb.Endpoint,
|
||||
assigns: %{__changed__: %{}, flash: %{}}
|
||||
}
|
||||
|
||||
{:halt, updated_socket} = AccountAuth.on_mount(:require_authenticated, %{}, session, socket)
|
||||
assert updated_socket.assigns.current_scope == nil
|
||||
end
|
||||
|
||||
test "redirects to login page if there isn't a account_token", %{conn: conn} do
|
||||
session = conn |> get_session()
|
||||
|
||||
socket = %LiveView.Socket{
|
||||
endpoint: TarakanWeb.Endpoint,
|
||||
assigns: %{__changed__: %{}, flash: %{}}
|
||||
}
|
||||
|
||||
{:halt, updated_socket} = AccountAuth.on_mount(:require_authenticated, %{}, session, socket)
|
||||
assert updated_socket.assigns.current_scope == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "on_mount :require_sudo_mode" do
|
||||
test "allows accounts that have authenticated in the last 10 minutes", %{
|
||||
conn: conn,
|
||||
account: account
|
||||
} do
|
||||
account_token = Accounts.generate_account_session_token(account)
|
||||
session = conn |> put_session(:account_token, account_token) |> get_session()
|
||||
|
||||
socket = %LiveView.Socket{
|
||||
endpoint: TarakanWeb.Endpoint,
|
||||
assigns: %{__changed__: %{}, flash: %{}}
|
||||
}
|
||||
|
||||
assert {:cont, _updated_socket} =
|
||||
AccountAuth.on_mount(:require_sudo_mode, %{}, session, socket)
|
||||
end
|
||||
|
||||
test "redirects when authentication is too old", %{conn: conn, account: account} do
|
||||
nine_hours_ago = DateTime.utc_now(:second) |> DateTime.add(-9 * 60, :minute)
|
||||
account = %{account | authenticated_at: nine_hours_ago}
|
||||
account_token = Accounts.generate_account_session_token(account)
|
||||
{account, token_inserted_at} = Accounts.get_account_by_session_token(account_token)
|
||||
assert DateTime.compare(token_inserted_at, account.authenticated_at) == :gt
|
||||
session = conn |> put_session(:account_token, account_token) |> get_session()
|
||||
|
||||
socket = %LiveView.Socket{
|
||||
endpoint: TarakanWeb.Endpoint,
|
||||
assigns: %{__changed__: %{}, flash: %{}}
|
||||
}
|
||||
|
||||
assert {:halt, _updated_socket} =
|
||||
AccountAuth.on_mount(:require_sudo_mode, %{}, session, socket)
|
||||
end
|
||||
|
||||
test "redirects when there is no authenticated account", %{conn: conn} do
|
||||
session = conn |> get_session()
|
||||
|
||||
socket = %LiveView.Socket{
|
||||
endpoint: TarakanWeb.Endpoint,
|
||||
assigns: %{__changed__: %{}, flash: %{}}
|
||||
}
|
||||
|
||||
assert {:halt, updated_socket} =
|
||||
AccountAuth.on_mount(:require_sudo_mode, %{}, session, socket)
|
||||
|
||||
assert updated_socket.assigns.current_scope == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "require_authenticated_account/2" do
|
||||
setup %{conn: conn} do
|
||||
%{conn: AccountAuth.fetch_current_scope_for_account(conn, [])}
|
||||
end
|
||||
|
||||
test "redirects if account is not authenticated", %{conn: conn} do
|
||||
conn = conn |> fetch_flash() |> AccountAuth.require_authenticated_account([])
|
||||
assert conn.halted
|
||||
|
||||
assert redirected_to(conn) == ~p"/accounts/log-in"
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
|
||||
"You must log in to access this page."
|
||||
end
|
||||
|
||||
test "stores the path to redirect to on GET", %{conn: conn} do
|
||||
halted_conn =
|
||||
%{conn | path_info: ["foo"], query_string: ""}
|
||||
|> fetch_flash()
|
||||
|> AccountAuth.require_authenticated_account([])
|
||||
|
||||
assert halted_conn.halted
|
||||
assert get_session(halted_conn, :account_return_to) == "/foo"
|
||||
|
||||
halted_conn =
|
||||
%{conn | path_info: ["foo"], query_string: "bar=baz"}
|
||||
|> fetch_flash()
|
||||
|> AccountAuth.require_authenticated_account([])
|
||||
|
||||
assert halted_conn.halted
|
||||
assert get_session(halted_conn, :account_return_to) == "/foo?bar=baz"
|
||||
|
||||
halted_conn =
|
||||
%{conn | path_info: ["foo"], query_string: "bar", method: "POST"}
|
||||
|> fetch_flash()
|
||||
|> AccountAuth.require_authenticated_account([])
|
||||
|
||||
assert halted_conn.halted
|
||||
refute get_session(halted_conn, :account_return_to)
|
||||
end
|
||||
|
||||
test "does not redirect if account is authenticated", %{conn: conn, account: account} do
|
||||
conn =
|
||||
conn
|
||||
|> assign(:current_scope, Scope.for_account(account))
|
||||
|> AccountAuth.require_authenticated_account([])
|
||||
|
||||
refute conn.halted
|
||||
refute conn.status
|
||||
end
|
||||
end
|
||||
|
||||
describe "disconnect_sessions/1" do
|
||||
test "broadcasts disconnect messages for each account id" do
|
||||
TarakanWeb.Endpoint.subscribe(Accounts.account_sessions_topic(11))
|
||||
TarakanWeb.Endpoint.subscribe(Accounts.account_sessions_topic(22))
|
||||
|
||||
AccountAuth.disconnect_sessions([11, %{account_id: 22}])
|
||||
|
||||
assert_receive %Phoenix.Socket.Broadcast{
|
||||
event: "disconnect",
|
||||
topic: "accounts_sessions:11"
|
||||
}
|
||||
|
||||
assert_receive %Phoenix.Socket.Broadcast{
|
||||
event: "disconnect",
|
||||
topic: "accounts_sessions:22"
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
38
test/tarakan_web/components/core_components_test.exs
Normal file
38
test/tarakan_web/components/core_components_test.exs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
defmodule TarakanWeb.CoreComponentsTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias TarakanWeb.CoreComponents
|
||||
|
||||
test "ordinary flash toasts auto-dismiss with kind-specific delays" do
|
||||
info =
|
||||
render_component(&CoreComponents.flash/1, %{
|
||||
kind: :info,
|
||||
flash: %{"info" => "Saved"}
|
||||
})
|
||||
|
||||
error =
|
||||
render_component(&CoreComponents.flash/1, %{
|
||||
kind: :error,
|
||||
flash: %{"error" => "Failed"}
|
||||
})
|
||||
|
||||
assert info =~ ~s(phx-hook="AutoDismiss")
|
||||
assert info =~ ~s(data-auto-dismiss-ms="5000")
|
||||
assert error =~ ~s(data-auto-dismiss-ms="8000")
|
||||
end
|
||||
|
||||
test "connection status alerts can opt out of auto-dismiss" do
|
||||
html =
|
||||
render_component(&CoreComponents.flash/1, %{
|
||||
id: "connection-error",
|
||||
kind: :error,
|
||||
flash: %{"error" => "Disconnected"},
|
||||
auto_dismiss: false
|
||||
})
|
||||
|
||||
refute html =~ ~s(phx-hook="AutoDismiss")
|
||||
refute html =~ "data-auto-dismiss-ms"
|
||||
end
|
||||
end
|
||||
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
|
||||
38
test/tarakan_web/finding_presentation_test.exs
Normal file
38
test/tarakan_web/finding_presentation_test.exs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
defmodule TarakanWeb.FindingPresentationTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias TarakanWeb.FindingPresentation
|
||||
|
||||
test "structure_description splits remediation and strips Verified prefix" do
|
||||
text =
|
||||
"Verified: static HTML is served with bad CSP. That is same-origin. Remediation: force subdomains."
|
||||
|
||||
%{lead: lead, sections: sections} = FindingPresentation.structure_description(text)
|
||||
|
||||
assert lead =~ "static HTML"
|
||||
refute lead =~ ~r/^Verified/
|
||||
assert {"Remediation", fix} = List.keyfind(sections, "Remediation", 0)
|
||||
assert fix =~ "subdomains"
|
||||
end
|
||||
|
||||
test "description_excerpt truncates lead" do
|
||||
long = String.duplicate("word ", 100)
|
||||
excerpt = FindingPresentation.description_excerpt(long, 40)
|
||||
assert String.length(excerpt) <= 41
|
||||
assert String.ends_with?(excerpt, "…")
|
||||
end
|
||||
|
||||
test "humanize_notes parses auto summary" do
|
||||
notes =
|
||||
"Review Format submission with 18 finding(s). Top issues: [high] Static HTML; [medium] DNS rebinding"
|
||||
|
||||
assert %{kind: :summary, count: 18, tops: tops} = FindingPresentation.humanize_notes(notes)
|
||||
assert "Static HTML" in tops
|
||||
assert Enum.any?(tops, &String.contains?(&1, "DNS"))
|
||||
end
|
||||
|
||||
test "how_made_label" do
|
||||
assert FindingPresentation.how_made_label("agent") == "Produced by an agent"
|
||||
assert FindingPresentation.how_made_label("hybrid") == "Agent draft, human edited"
|
||||
end
|
||||
end
|
||||
513
test/tarakan_web/git_http_test.exs
Normal file
513
test/tarakan_web/git_http_test.exs
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
defmodule TarakanWeb.GitHTTPTest do
|
||||
@moduledoc """
|
||||
End-to-end smart-HTTP tests driven by a real git client against a real
|
||||
Bandit listener serving the full endpoint.
|
||||
"""
|
||||
|
||||
use Tarakan.DataCase, async: false
|
||||
|
||||
import Tarakan.AccountsFixtures
|
||||
|
||||
alias Tarakan.Accounts
|
||||
alias Tarakan.HostedRepositories
|
||||
|
||||
@moduletag :git_client
|
||||
|
||||
setup do
|
||||
on_exit(fn ->
|
||||
File.rm_rf(Application.fetch_env!(:tarakan, Tarakan.HostedRepositories)[:root])
|
||||
end)
|
||||
|
||||
Tarakan.RepositoryCode.Cache.clear()
|
||||
|
||||
port = free_port()
|
||||
|
||||
start_supervised!(
|
||||
{Bandit, plug: TarakanWeb.Endpoint, scheme: :http, ip: {127, 0, 0, 1}, port: port}
|
||||
)
|
||||
|
||||
account = account_fixture()
|
||||
scope = Accounts.scope_for_account(account)
|
||||
{:ok, repository} = HostedRepositories.create(scope, %{"name" => "hosted"})
|
||||
|
||||
{:ok, token, _credential} =
|
||||
Accounts.ApiCredentials.create(account, %{
|
||||
"name" => "git",
|
||||
"scopes" => ["repo:read", "repo:write"]
|
||||
})
|
||||
|
||||
%{
|
||||
port: port,
|
||||
account: account,
|
||||
repository: repository,
|
||||
token: token,
|
||||
base_url: "http://127.0.0.1:#{port}"
|
||||
}
|
||||
end
|
||||
|
||||
defp free_port do
|
||||
{:ok, socket} = :gen_tcp.listen(0, ip: {127, 0, 0, 1})
|
||||
{:ok, port} = :inet.port(socket)
|
||||
:gen_tcp.close(socket)
|
||||
port
|
||||
end
|
||||
|
||||
defp git_env do
|
||||
[
|
||||
{"GIT_TERMINAL_PROMPT", "0"},
|
||||
{"GIT_CONFIG_GLOBAL", "/dev/null"},
|
||||
{"GIT_CONFIG_SYSTEM", "/dev/null"},
|
||||
{"GIT_AUTHOR_NAME", "t"},
|
||||
{"GIT_AUTHOR_EMAIL", "t@example.com"},
|
||||
{"GIT_COMMITTER_NAME", "t"},
|
||||
{"GIT_COMMITTER_EMAIL", "t@example.com"}
|
||||
]
|
||||
end
|
||||
|
||||
defp git(args, opts \\ []) do
|
||||
System.cmd("git", args, [stderr_to_stdout: true, env: git_env()] ++ opts)
|
||||
end
|
||||
|
||||
defp clone_url(%{base_url: base_url, account: account}, name),
|
||||
do: "#{base_url}/#{account.handle}/#{name}.git"
|
||||
|
||||
defp authed_url(%{token: token} = context, name) do
|
||||
%URI{URI.parse(clone_url(context, name)) | userinfo: "git:#{token}"} |> URI.to_string()
|
||||
end
|
||||
|
||||
defp seed_local_repository(tmp_dir) do
|
||||
work = Path.join(tmp_dir, "seed")
|
||||
File.mkdir_p!(work)
|
||||
{_out, 0} = git(["init", "--quiet", "-b", "main", work])
|
||||
File.write!(Path.join(work, "README.md"), "# Pushed\n")
|
||||
{_out, 0} = git(["-C", work, "add", "."])
|
||||
{_out, 0} = git(["-C", work, "commit", "--quiet", "-m", "seed"])
|
||||
work
|
||||
end
|
||||
|
||||
defp list_repository(repository) do
|
||||
repository
|
||||
|> Ecto.Changeset.change(listing_status: "listed")
|
||||
|> Repo.update!()
|
||||
end
|
||||
|
||||
describe "push over HTTP" do
|
||||
@tag :tmp_dir
|
||||
test "steward pushes with a repo:write credential", context do
|
||||
%{repository: repository, tmp_dir: tmp_dir} = context
|
||||
work = seed_local_repository(tmp_dir)
|
||||
|
||||
{output, status} =
|
||||
git(["-C", work, "push", "--quiet", authed_url(context, "hosted"), "main"])
|
||||
|
||||
assert status == 0, output
|
||||
|
||||
repository = Repo.reload!(repository)
|
||||
assert repository.default_branch == "main"
|
||||
assert %DateTime{} = repository.pushed_at
|
||||
assert repository.disk_size_bytes > 0
|
||||
|
||||
assert %{action: "repository_pushed"} =
|
||||
Repo.get_by(Tarakan.Audit.Event, action: "repository_pushed")
|
||||
end
|
||||
|
||||
@tag :tmp_dir
|
||||
test "push is denied without credentials, with read-only scope, and for non-stewards",
|
||||
context do
|
||||
%{tmp_dir: tmp_dir, account: account} = context
|
||||
work = seed_local_repository(tmp_dir)
|
||||
|
||||
# Anonymous: the server answers 401 so git asks for credentials, and
|
||||
# with prompts disabled the push dies without touching the repository.
|
||||
{:ok, {{_http, 401, _reason}, _headers, _body}} =
|
||||
:httpc.request(
|
||||
:get,
|
||||
{~c"#{clone_url(context, "hosted")}/info/refs?service=git-receive-pack", []},
|
||||
[autoredirect: false],
|
||||
[]
|
||||
)
|
||||
|
||||
{output, status} =
|
||||
git(["-C", work, "push", "--quiet", clone_url(context, "hosted"), "main"])
|
||||
|
||||
assert status != 0
|
||||
assert output =~ "Authentication" or output =~ "401" or output =~ "prompts disabled"
|
||||
|
||||
# Authenticated but read-only scope.
|
||||
{:ok, read_token, _credential} =
|
||||
Accounts.ApiCredentials.create(account, %{
|
||||
"name" => "read-only",
|
||||
"scopes" => ["repo:read"]
|
||||
})
|
||||
|
||||
read_url =
|
||||
%URI{URI.parse(clone_url(context, "hosted")) | userinfo: "git:#{read_token}"}
|
||||
|> URI.to_string()
|
||||
|
||||
{output, status} = git(["-C", work, "push", "--quiet", read_url, "main"])
|
||||
assert status != 0
|
||||
assert output =~ "404" or output =~ "not found"
|
||||
|
||||
# A different account with a valid repo:write credential but no
|
||||
# steward membership.
|
||||
other = account_fixture()
|
||||
|
||||
{:ok, other_token, _credential} =
|
||||
Accounts.ApiCredentials.create(other, %{
|
||||
"name" => "other",
|
||||
"scopes" => ["repo:write"]
|
||||
})
|
||||
|
||||
other_url =
|
||||
%URI{URI.parse(clone_url(context, "hosted")) | userinfo: "git:#{other_token}"}
|
||||
|> URI.to_string()
|
||||
|
||||
{output, status} = git(["-C", work, "push", "--quiet", other_url, "main"])
|
||||
assert status != 0
|
||||
assert output =~ "404" or output =~ "not found"
|
||||
|
||||
assert Repo.reload!(context.repository).pushed_at == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "clone over HTTP" do
|
||||
@tag :tmp_dir
|
||||
test "anonymous clone of a listed repository round-trips content", context do
|
||||
%{repository: repository, tmp_dir: tmp_dir} = context
|
||||
work = seed_local_repository(tmp_dir)
|
||||
|
||||
{_output, 0} =
|
||||
git(["-C", work, "push", "--quiet", authed_url(context, "hosted"), "main"])
|
||||
|
||||
list_repository(repository)
|
||||
|
||||
dest = Path.join(tmp_dir, "clone")
|
||||
{output, status} = git(["clone", "--quiet", clone_url(context, "hosted"), dest])
|
||||
assert status == 0, output
|
||||
assert File.read!(Path.join(dest, "README.md")) == "# Pushed\n"
|
||||
end
|
||||
|
||||
@tag :tmp_dir
|
||||
test "anonymous clone succeeds at creation and is refused after quarantine", context do
|
||||
%{repository: repository, tmp_dir: tmp_dir} = context
|
||||
work = seed_local_repository(tmp_dir)
|
||||
|
||||
{_output, 0} =
|
||||
git(["-C", work, "push", "--quiet", authed_url(context, "hosted"), "main"])
|
||||
|
||||
# Hosted repositories are listed at creation, so an anonymous clone
|
||||
# works without any moderation step.
|
||||
dest = Path.join(tmp_dir, "clone")
|
||||
{output, status} = git(["clone", "--quiet", clone_url(context, "hosted"), dest])
|
||||
assert status == 0, output
|
||||
assert File.read!(Path.join(dest, "README.md")) == "# Pushed\n"
|
||||
|
||||
# A moderator quarantine is the only thing that hides the repository.
|
||||
repository
|
||||
|> Tarakan.Repositories.Repository.listing_changeset(%{listing_status: "quarantined"})
|
||||
|> Repo.update!()
|
||||
|
||||
{:ok, {{_http, 401, _reason}, _headers, _body}} =
|
||||
:httpc.request(
|
||||
:get,
|
||||
{~c"#{clone_url(context, "hosted")}/info/refs?service=git-upload-pack", []},
|
||||
[autoredirect: false],
|
||||
[]
|
||||
)
|
||||
|
||||
quarantined_dest = Path.join(tmp_dir, "clone-quarantined")
|
||||
|
||||
{output, status} =
|
||||
git(["clone", "--quiet", clone_url(context, "hosted"), quarantined_dest])
|
||||
|
||||
assert status != 0
|
||||
assert output =~ "Authentication" or output =~ "401" or output =~ "prompts disabled"
|
||||
end
|
||||
|
||||
@tag :tmp_dir
|
||||
test "the owner clones a repository with a credential", context do
|
||||
%{tmp_dir: tmp_dir} = context
|
||||
work = seed_local_repository(tmp_dir)
|
||||
|
||||
{_output, 0} =
|
||||
git(["-C", work, "push", "--quiet", authed_url(context, "hosted"), "main"])
|
||||
|
||||
dest = Path.join(tmp_dir, "clone")
|
||||
{output, status} = git(["clone", "--quiet", authed_url(context, "hosted"), dest])
|
||||
assert status == 0, output
|
||||
assert File.read!(Path.join(dest, "README.md")) == "# Pushed\n"
|
||||
end
|
||||
|
||||
@tag :tmp_dir
|
||||
test "fetch after a new push exercises negotiation", context do
|
||||
%{repository: repository, tmp_dir: tmp_dir} = context
|
||||
work = seed_local_repository(tmp_dir)
|
||||
|
||||
{_output, 0} =
|
||||
git(["-C", work, "push", "--quiet", authed_url(context, "hosted"), "main"])
|
||||
|
||||
list_repository(repository)
|
||||
|
||||
dest = Path.join(tmp_dir, "clone")
|
||||
{_output, 0} = git(["clone", "--quiet", clone_url(context, "hosted"), dest])
|
||||
|
||||
File.write!(Path.join(work, "second.txt"), "more\n")
|
||||
{_out, 0} = git(["-C", work, "add", "."])
|
||||
{_out, 0} = git(["-C", work, "commit", "--quiet", "-m", "second"])
|
||||
|
||||
{_output, 0} =
|
||||
git(["-C", work, "push", "--quiet", authed_url(context, "hosted"), "main"])
|
||||
|
||||
{output, status} = git(["-C", dest, "pull", "--quiet", "origin", "main"])
|
||||
assert status == 0, output
|
||||
assert File.read!(Path.join(dest, "second.txt")) == "more\n"
|
||||
end
|
||||
|
||||
test "unknown repositories 404 without an existence oracle", context do
|
||||
%{base_url: base_url} = context
|
||||
|
||||
{:ok, {{_http, 404, _reason}, _headers, _body}} =
|
||||
:httpc.request(
|
||||
:get,
|
||||
{~c"#{base_url}/nobody/nothing.git/info/refs?service=git-upload-pack", []},
|
||||
[],
|
||||
[]
|
||||
)
|
||||
end
|
||||
|
||||
test "non-git routes still reach Phoenix", context do
|
||||
{:ok, {{_http, 200, _reason}, _headers, body}} =
|
||||
:httpc.request(:get, {~c"#{context.base_url}/", []}, [], [])
|
||||
|
||||
assert to_string(body) =~ "<html"
|
||||
end
|
||||
end
|
||||
|
||||
describe "protocol details" do
|
||||
@tag :tmp_dir
|
||||
test "info/refs advertisement uses the smart content type", context do
|
||||
%{repository: repository} = context
|
||||
list_repository(repository)
|
||||
|
||||
url =
|
||||
~c"#{clone_url(context, "hosted")}/info/refs?service=git-upload-pack"
|
||||
|
||||
{:ok, {{_http, 200, _reason}, headers, body}} = :httpc.request(:get, {url, []}, [], [])
|
||||
|
||||
content_type =
|
||||
headers
|
||||
|> Enum.find_value(fn
|
||||
{~c"content-type", value} -> to_string(value)
|
||||
_other -> nil
|
||||
end)
|
||||
|
||||
assert content_type == "application/x-git-upload-pack-advertisement"
|
||||
assert to_string(body) =~ "# service=git-upload-pack"
|
||||
assert String.starts_with?(to_string(body), "001e# service=git-upload-pack\n0000")
|
||||
end
|
||||
|
||||
@tag :tmp_dir
|
||||
test "a truncated RPC body cannot wedge the subprocess", context do
|
||||
%{repository: repository, tmp_dir: tmp_dir} = context
|
||||
work = seed_local_repository(tmp_dir)
|
||||
|
||||
{_output, 0} =
|
||||
git(["-C", work, "push", "--quiet", authed_url(context, "hosted"), "main"])
|
||||
|
||||
list_repository(repository)
|
||||
|
||||
# A syntactically incomplete upload-pack request: git waits for the
|
||||
# rest of the negotiation, and only the wall-clock deadline ends it.
|
||||
original = Application.get_env(:tarakan, TarakanWeb.GitHTTP.Service, [])
|
||||
|
||||
Application.put_env(
|
||||
:tarakan,
|
||||
TarakanWeb.GitHTTP.Service,
|
||||
Keyword.put(original, :upload_pack_timeout_ms, 300)
|
||||
)
|
||||
|
||||
on_exit(fn -> Application.put_env(:tarakan, TarakanWeb.GitHTTP.Service, original) end)
|
||||
|
||||
truncated = "00a4want "
|
||||
|
||||
{time_us, {:ok, {{_http, 200, _reason}, _headers, _body}}} =
|
||||
:timer.tc(fn ->
|
||||
:httpc.request(
|
||||
:post,
|
||||
{~c"#{clone_url(context, "hosted")}/git-upload-pack",
|
||||
[{~c"content-type", ~c"application/x-git-upload-pack-request"}],
|
||||
~c"application/x-git-upload-pack-request", truncated},
|
||||
[timeout: 5_000],
|
||||
[]
|
||||
)
|
||||
end)
|
||||
|
||||
# Bounded by the configured deadline, not the httpc timeout.
|
||||
assert div(time_us, 1000) < 4_000
|
||||
end
|
||||
|
||||
test "a gzip bomb is refused without inflating it", context do
|
||||
%{repository: repository} = context
|
||||
list_repository(repository)
|
||||
|
||||
original = Application.get_env(:tarakan, TarakanWeb.GitHTTP.Service, [])
|
||||
|
||||
# A small cap makes the limit trip deterministically, before git has a
|
||||
# chance to reject the garbage on its own and end the request first.
|
||||
Application.put_env(
|
||||
:tarakan,
|
||||
TarakanWeb.GitHTTP.Service,
|
||||
Keyword.put(original, :max_upload_pack_request_bytes, 64 * 1024)
|
||||
)
|
||||
|
||||
on_exit(fn -> Application.put_env(:tarakan, TarakanWeb.GitHTTP.Service, original) end)
|
||||
|
||||
# ~64MB of compressible input in well under a megabyte on the wire.
|
||||
zstream = :zlib.open()
|
||||
:ok = :zlib.deflateInit(zstream, 9, :deflated, 31, 8, :default)
|
||||
|
||||
bomb =
|
||||
zstream
|
||||
|> :zlib.deflate(:binary.copy("A", 64 * 1024 * 1024), :finish)
|
||||
|> IO.iodata_to_binary()
|
||||
|
||||
:ok = :zlib.deflateEnd(zstream)
|
||||
:ok = :zlib.close(zstream)
|
||||
|
||||
assert byte_size(bomb) < 1_000_000, "the bomb must be small on the wire to be a bomb"
|
||||
|
||||
memory_before = :erlang.memory(:total)
|
||||
|
||||
{:ok, {{_http, status, _reason}, _headers, _body}} =
|
||||
:httpc.request(
|
||||
:post,
|
||||
{~c"#{clone_url(context, "hosted")}/git-upload-pack",
|
||||
[
|
||||
{~c"content-type", ~c"application/x-git-upload-pack-request"},
|
||||
{~c"content-encoding", ~c"gzip"}
|
||||
], ~c"application/x-git-upload-pack-request", bomb},
|
||||
[timeout: 30_000],
|
||||
[]
|
||||
)
|
||||
|
||||
assert status == 413
|
||||
|
||||
# The cap is enforced during expansion, so the whole 64MB is never
|
||||
# materialized. Allow generous headroom for unrelated test allocation.
|
||||
growth = :erlang.memory(:total) - memory_before
|
||||
assert growth < 32 * 1024 * 1024, "inflated #{div(growth, 1024 * 1024)}MB before refusing"
|
||||
end
|
||||
|
||||
test "a well-formed gzip request body is still accepted", context do
|
||||
%{repository: repository} = context
|
||||
list_repository(repository)
|
||||
|
||||
zstream = :zlib.open()
|
||||
:ok = :zlib.deflateInit(zstream, 9, :deflated, 31, 8, :default)
|
||||
|
||||
# A truncated but legitimately compressed negotiation body: the point is
|
||||
# that gzip decoding still works, not that git likes the contents.
|
||||
body = zstream |> :zlib.deflate("0000", :finish) |> IO.iodata_to_binary()
|
||||
:ok = :zlib.deflateEnd(zstream)
|
||||
:ok = :zlib.close(zstream)
|
||||
|
||||
{:ok, {{_http, status, _reason}, _headers, _body}} =
|
||||
:httpc.request(
|
||||
:post,
|
||||
{~c"#{clone_url(context, "hosted")}/git-upload-pack",
|
||||
[
|
||||
{~c"content-type", ~c"application/x-git-upload-pack-request"},
|
||||
{~c"content-encoding", ~c"gzip"}
|
||||
], ~c"application/x-git-upload-pack-request", body},
|
||||
[timeout: 10_000],
|
||||
[]
|
||||
)
|
||||
|
||||
assert status == 200
|
||||
end
|
||||
|
||||
test "a corrupt gzip body is rejected as a bad request", context do
|
||||
%{repository: repository} = context
|
||||
list_repository(repository)
|
||||
|
||||
{:ok, {{_http, status, _reason}, _headers, _body}} =
|
||||
:httpc.request(
|
||||
:post,
|
||||
{~c"#{clone_url(context, "hosted")}/git-upload-pack",
|
||||
[
|
||||
{~c"content-type", ~c"application/x-git-upload-pack-request"},
|
||||
{~c"content-encoding", ~c"gzip"}
|
||||
], ~c"application/x-git-upload-pack-request", "not actually gzip at all"},
|
||||
[timeout: 10_000],
|
||||
[]
|
||||
)
|
||||
|
||||
assert status == 400
|
||||
end
|
||||
|
||||
test "dumb-protocol requests are refused", context do
|
||||
%{repository: repository} = context
|
||||
list_repository(repository)
|
||||
|
||||
{:ok, {{_http, 400, _reason}, _headers, _body}} =
|
||||
:httpc.request(
|
||||
:get,
|
||||
{~c"#{clone_url(context, "hosted")}/info/refs", []},
|
||||
[],
|
||||
[]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe "rate limits" do
|
||||
test "failed basic authentications are throttled to 429 per client IP", context do
|
||||
original = Application.get_env(:tarakan, TarakanWeb.GitHTTP, [])
|
||||
|
||||
Application.put_env(
|
||||
:tarakan,
|
||||
TarakanWeb.GitHTTP,
|
||||
Keyword.put(original, :auth_failure_rate_limit, {2, 60})
|
||||
)
|
||||
|
||||
on_exit(fn -> Application.put_env(:tarakan, TarakanWeb.GitHTTP, original) end)
|
||||
|
||||
authorization = ~c"Basic " ++ String.to_charlist(Base.encode64("git:bad-token"))
|
||||
url = ~c"#{clone_url(context, "hosted")}/info/refs?service=git-upload-pack"
|
||||
|
||||
request = fn ->
|
||||
:httpc.request(
|
||||
:get,
|
||||
{url, [{~c"authorization", authorization}]},
|
||||
[autoredirect: false],
|
||||
[]
|
||||
)
|
||||
end
|
||||
|
||||
{:ok, {{_http, 401, _reason}, _headers, _body}} = request.()
|
||||
{:ok, {{_http, 401, _reason}, _headers, _body}} = request.()
|
||||
{:ok, {{_http, 429, _reason}, _headers, _body}} = request.()
|
||||
end
|
||||
|
||||
test "info/refs answers 503 when the git subprocess cap is exhausted", context do
|
||||
%{repository: repository} = context
|
||||
list_repository(repository)
|
||||
|
||||
checked_out =
|
||||
Stream.repeatedly(fn -> Tarakan.Git.Concurrency.checkout() end)
|
||||
|> Enum.take_while(&(&1 == :ok))
|
||||
|
||||
on_exit(fn -> for _ <- checked_out, do: Tarakan.Git.Concurrency.checkin() end)
|
||||
|
||||
{:ok, {{_http, 503, _reason}, headers, body}} =
|
||||
:httpc.request(
|
||||
:get,
|
||||
{~c"#{clone_url(context, "hosted")}/info/refs?service=git-upload-pack", []},
|
||||
[autoredirect: false],
|
||||
[]
|
||||
)
|
||||
|
||||
assert Enum.find(headers, fn {name, _value} -> name == ~c"retry-after" end)
|
||||
assert to_string(body) =~ "busy"
|
||||
end
|
||||
end
|
||||
end
|
||||
125
test/tarakan_web/live/account_live/confirmation_test.exs
Normal file
125
test/tarakan_web/live/account_live/confirmation_test.exs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
defmodule TarakanWeb.AccountLive.ConfirmationTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Tarakan.AccountsFixtures
|
||||
|
||||
alias Tarakan.Accounts
|
||||
|
||||
setup do
|
||||
%{unconfirmed_account: unconfirmed_account_fixture(), confirmed_account: account_fixture()}
|
||||
end
|
||||
|
||||
describe "Magic-link login" do
|
||||
test "renders login page for a legacy unconfirmed account", %{
|
||||
conn: conn,
|
||||
unconfirmed_account: account
|
||||
} do
|
||||
token =
|
||||
extract_account_token(fn url ->
|
||||
Accounts.deliver_login_instructions(account, url)
|
||||
end)
|
||||
|
||||
{:ok, _lv, html} = live(conn, ~p"/accounts/log-in/#{token}")
|
||||
refute html =~ "Confirm"
|
||||
assert html =~ "Log in"
|
||||
end
|
||||
|
||||
test "renders login page for confirmed account", %{conn: conn, confirmed_account: account} do
|
||||
token =
|
||||
extract_account_token(fn url ->
|
||||
Accounts.deliver_login_instructions(account, url)
|
||||
end)
|
||||
|
||||
{:ok, _lv, html} = live(conn, ~p"/accounts/log-in/#{token}")
|
||||
assert html =~ "Stay logged in on this device"
|
||||
end
|
||||
|
||||
test "renders login page for already logged in account", %{
|
||||
conn: conn,
|
||||
confirmed_account: account
|
||||
} do
|
||||
conn = log_in_account(conn, account)
|
||||
|
||||
token =
|
||||
extract_account_token(fn url ->
|
||||
Accounts.deliver_login_instructions(account, url)
|
||||
end)
|
||||
|
||||
{:ok, _lv, html} = live(conn, ~p"/accounts/log-in/#{token}")
|
||||
assert html =~ "Log in"
|
||||
end
|
||||
|
||||
test "logs in a legacy unconfirmed account with the given token once", %{
|
||||
conn: conn,
|
||||
unconfirmed_account: account
|
||||
} do
|
||||
token =
|
||||
extract_account_token(fn url ->
|
||||
Accounts.deliver_login_instructions(account, url)
|
||||
end)
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/accounts/log-in/#{token}")
|
||||
|
||||
form = form(lv, "#login_form", %{"account" => %{"token" => token}})
|
||||
render_submit(form)
|
||||
|
||||
conn = follow_trigger_action(form, conn)
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!"
|
||||
|
||||
assert Accounts.get_account!(account.id).confirmed_at
|
||||
# we are logged in now
|
||||
assert get_session(conn, :account_token)
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
|
||||
# log out, new conn
|
||||
conn = build_conn()
|
||||
|
||||
{:ok, _lv, html} =
|
||||
live(conn, ~p"/accounts/log-in/#{token}")
|
||||
|> follow_redirect(conn, ~p"/accounts/log-in")
|
||||
|
||||
assert html =~ "Magic link is invalid or it has expired"
|
||||
end
|
||||
|
||||
test "logs confirmed account in without changing confirmed_at", %{
|
||||
conn: conn,
|
||||
confirmed_account: account
|
||||
} do
|
||||
token =
|
||||
extract_account_token(fn url ->
|
||||
Accounts.deliver_login_instructions(account, url)
|
||||
end)
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/accounts/log-in/#{token}")
|
||||
|
||||
form = form(lv, "#login_form", %{"account" => %{"token" => token}})
|
||||
render_submit(form)
|
||||
|
||||
conn = follow_trigger_action(form, conn)
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
|
||||
"Welcome back!"
|
||||
|
||||
assert Accounts.get_account!(account.id).confirmed_at == account.confirmed_at
|
||||
|
||||
# log out, new conn
|
||||
conn = build_conn()
|
||||
|
||||
{:ok, _lv, html} =
|
||||
live(conn, ~p"/accounts/log-in/#{token}")
|
||||
|> follow_redirect(conn, ~p"/accounts/log-in")
|
||||
|
||||
assert html =~ "Magic link is invalid or it has expired"
|
||||
end
|
||||
|
||||
test "raises error for invalid token", %{conn: conn} do
|
||||
{:ok, _lv, html} =
|
||||
live(conn, ~p"/accounts/log-in/invalid-token")
|
||||
|> follow_redirect(conn, ~p"/accounts/log-in")
|
||||
|
||||
assert html =~ "Magic link is invalid or it has expired"
|
||||
end
|
||||
end
|
||||
end
|
||||
134
test/tarakan_web/live/account_live/login_test.exs
Normal file
134
test/tarakan_web/live/account_live/login_test.exs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
defmodule TarakanWeb.AccountLive.LoginTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Tarakan.AccountsFixtures
|
||||
|
||||
describe "login page" do
|
||||
test "renders login page", %{conn: conn} do
|
||||
{:ok, _lv, html} = live(conn, ~p"/accounts/log-in")
|
||||
|
||||
assert html =~ "Log in"
|
||||
assert html =~ "Continue with GitHub"
|
||||
assert html =~ "Continue with GitLab"
|
||||
assert html =~ "Email me a login link"
|
||||
end
|
||||
|
||||
test "preserves a protected route stored in the session", %{conn: conn} do
|
||||
return_to = "/client/authorize/ABCD-EFGH"
|
||||
|
||||
{:ok, view, _html} =
|
||||
conn
|
||||
|> init_test_session(%{account_return_to: return_to})
|
||||
|> live(~p"/accounts/log-in")
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"#login_form_password input[name='account[return_to]'][value='#{return_to}']"
|
||||
)
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"#github-login-button[href='/auth/github?return_to=%2Fclient%2Fauthorize%2FABCD-EFGH']"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe "account login - magic link" do
|
||||
test "sends magic link email when account exists", %{conn: conn} do
|
||||
account = account_fixture()
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/accounts/log-in")
|
||||
|
||||
{:ok, _lv, html} =
|
||||
form(lv, "#login_form_magic", account: %{email: account.email})
|
||||
|> render_submit()
|
||||
|> follow_redirect(conn, ~p"/accounts/log-in")
|
||||
|
||||
assert html =~ "If your email is in our system"
|
||||
|
||||
assert Tarakan.Repo.get_by!(Tarakan.Accounts.AccountToken, account_id: account.id).context ==
|
||||
"login"
|
||||
end
|
||||
|
||||
test "does not disclose if account is registered", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/accounts/log-in")
|
||||
|
||||
{:ok, _lv, html} =
|
||||
form(lv, "#login_form_magic", account: %{email: "idonotexist@example.com"})
|
||||
|> render_submit()
|
||||
|> follow_redirect(conn, ~p"/accounts/log-in")
|
||||
|
||||
assert html =~ "If your email is in our system"
|
||||
end
|
||||
end
|
||||
|
||||
describe "account login - password" do
|
||||
test "redirects if account logs in with valid credentials", %{conn: conn} do
|
||||
account = account_fixture() |> set_password()
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/accounts/log-in")
|
||||
|
||||
form =
|
||||
form(lv, "#login_form_password",
|
||||
account: %{
|
||||
identifier: account.handle,
|
||||
password: valid_account_password(),
|
||||
remember_me: true
|
||||
}
|
||||
)
|
||||
|
||||
conn = submit_form(form, conn)
|
||||
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
end
|
||||
|
||||
test "redirects to login page with a flash error if credentials are invalid", %{
|
||||
conn: conn
|
||||
} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/accounts/log-in")
|
||||
|
||||
form =
|
||||
form(lv, "#login_form_password",
|
||||
account: %{identifier: "test@email.com", password: "123456"}
|
||||
)
|
||||
|
||||
render_submit(form)
|
||||
|
||||
conn = follow_trigger_action(form, conn)
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
|
||||
"Invalid handle, email, or password"
|
||||
|
||||
assert redirected_to(conn) == ~p"/accounts/log-in"
|
||||
end
|
||||
end
|
||||
|
||||
describe "login navigation" do
|
||||
test "github is the primary path for new users", %{conn: conn} do
|
||||
{:ok, _lv, html} = live(conn, ~p"/accounts/log-in")
|
||||
|
||||
assert html =~ "One click. No password required."
|
||||
assert html =~ ~s(id="github-login-button")
|
||||
assert html =~ "Continue with GitHub"
|
||||
end
|
||||
end
|
||||
|
||||
describe "re-authentication (sudo mode)" do
|
||||
setup %{conn: conn} do
|
||||
account = account_fixture()
|
||||
%{account: account, conn: log_in_account(conn, account)}
|
||||
end
|
||||
|
||||
test "shows login page with email filled in", %{conn: conn, account: account} do
|
||||
{:ok, _lv, html} = live(conn, ~p"/accounts/log-in")
|
||||
|
||||
assert html =~ "Confirm it"
|
||||
assert html =~ "connect another host"
|
||||
assert html =~ "Email me a login link"
|
||||
|
||||
assert html =~
|
||||
~s(<input type="email" name="account[email]" id="login_form_magic_email" value="#{account.email}")
|
||||
end
|
||||
end
|
||||
end
|
||||
109
test/tarakan_web/live/account_live/registration_test.exs
Normal file
109
test/tarakan_web/live/account_live/registration_test.exs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
defmodule TarakanWeb.AccountLive.RegistrationTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Tarakan.AccountsFixtures
|
||||
|
||||
describe "Registration page" do
|
||||
test "renders registration page", %{conn: conn} do
|
||||
{:ok, _lv, html} = live(conn, ~p"/accounts/register")
|
||||
|
||||
assert html =~ "Join Tarakan"
|
||||
assert html =~ "Log in"
|
||||
end
|
||||
|
||||
test "redirects if already logged in", %{conn: conn} do
|
||||
result =
|
||||
conn
|
||||
|> log_in_account(account_fixture())
|
||||
|> live(~p"/accounts/register")
|
||||
|> follow_redirect(conn, ~p"/")
|
||||
|
||||
assert {:ok, _conn} = result
|
||||
end
|
||||
|
||||
test "renders errors for invalid data", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/accounts/register")
|
||||
|
||||
result =
|
||||
lv
|
||||
|> element("#registration_form")
|
||||
|> render_change(account: %{"handle" => "valid-handle", "email" => "with spaces"})
|
||||
|
||||
assert result =~ "Join Tarakan"
|
||||
assert result =~ "must have the @ sign and no spaces"
|
||||
end
|
||||
end
|
||||
|
||||
describe "register account" do
|
||||
test "creates an unconfirmed account and logs in immediately", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/accounts/register")
|
||||
|
||||
email = unique_account_email()
|
||||
form = form(lv, "#registration_form", account: valid_account_attributes(email: email))
|
||||
|
||||
render_submit(form)
|
||||
|
||||
login_form = form(lv, "#registration_login_form")
|
||||
conn = follow_trigger_action(login_form, conn)
|
||||
|
||||
assert get_session(conn, :account_token)
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
|
||||
# The instant session does not confirm the email: only the emailed login
|
||||
# link proves mailbox control.
|
||||
assert %{confirmed_at: nil} = Tarakan.Accounts.get_account_by_email(email)
|
||||
end
|
||||
|
||||
test "does not reveal that an email is already registered", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/accounts/register")
|
||||
|
||||
account = account_fixture(%{email: "test@email.com"})
|
||||
|
||||
{:ok, _lv, html} =
|
||||
lv
|
||||
|> form("#registration_form",
|
||||
account: %{"handle" => unique_account_handle(), "email" => account.email}
|
||||
)
|
||||
|> render_submit()
|
||||
|> follow_redirect(conn, ~p"/accounts/log-in")
|
||||
|
||||
assert html =~ "If an account matches those details, a sign-in link will arrive shortly."
|
||||
refute html =~ "has already been taken"
|
||||
refute html =~ account.email
|
||||
end
|
||||
|
||||
test "does not reveal that a handle is already registered", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/accounts/register")
|
||||
|
||||
account = account_fixture()
|
||||
email = unique_account_email()
|
||||
|
||||
{:ok, _lv, html} =
|
||||
lv
|
||||
|> form("#registration_form",
|
||||
account: %{"handle" => account.handle, "email" => email}
|
||||
)
|
||||
|> render_submit()
|
||||
|> follow_redirect(conn, ~p"/accounts/log-in")
|
||||
|
||||
assert html =~ "If an account matches those details, a sign-in link will arrive shortly."
|
||||
refute html =~ "has already been taken"
|
||||
refute Tarakan.Accounts.get_account_by_email(email)
|
||||
end
|
||||
end
|
||||
|
||||
describe "registration navigation" do
|
||||
test "redirects to login page when the Log in button is clicked", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/accounts/register")
|
||||
|
||||
{:ok, _login_live, login_html} =
|
||||
lv
|
||||
|> element("main a", "Log in")
|
||||
|> render_click()
|
||||
|> follow_redirect(conn, ~p"/accounts/log-in")
|
||||
|
||||
assert login_html =~ "Log in"
|
||||
end
|
||||
end
|
||||
end
|
||||
434
test/tarakan_web/live/account_live/settings_test.exs
Normal file
434
test/tarakan_web/live/account_live/settings_test.exs
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
defmodule TarakanWeb.AccountLive.SettingsTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
alias Tarakan.Accounts
|
||||
import Phoenix.LiveViewTest
|
||||
import Tarakan.AccountsFixtures
|
||||
|
||||
describe "Settings page" do
|
||||
test "opens on the account panel", %{conn: conn} do
|
||||
{:ok, view, html} =
|
||||
conn
|
||||
|> log_in_account(account_fixture())
|
||||
|> live(~p"/accounts/settings")
|
||||
|
||||
assert html =~ "Save email"
|
||||
assert html =~ "Save password"
|
||||
assert html =~ "Connected code hosts"
|
||||
assert html =~ "GitHub"
|
||||
assert html =~ "GitLab"
|
||||
|
||||
assert has_element?(view, "#settings-tab-account[aria-current=page]")
|
||||
refute has_element?(view, "#api-reference")
|
||||
refute has_element?(view, "#ssh-key-form")
|
||||
end
|
||||
|
||||
test "moves between panels from the rail", %{conn: conn} do
|
||||
{:ok, view, _html} =
|
||||
conn
|
||||
|> log_in_account(account_fixture())
|
||||
|> live(~p"/accounts/settings")
|
||||
|
||||
html =
|
||||
view
|
||||
|> element("#settings-tab-credentials")
|
||||
|> render_click()
|
||||
|
||||
assert html =~ "Client credentials"
|
||||
assert_patch(view, ~p"/accounts/settings/credentials")
|
||||
assert has_element?(view, "#settings-tab-credentials[aria-current=page]")
|
||||
refute has_element?(view, "#email_form")
|
||||
|
||||
view |> element("#settings-tab-ssh") |> render_click()
|
||||
assert_patch(view, ~p"/accounts/settings/ssh-keys")
|
||||
assert has_element?(view, "#ssh-key-form")
|
||||
|
||||
view |> element("#settings-tab-agents") |> render_click()
|
||||
assert_patch(view, ~p"/accounts/settings/agents")
|
||||
assert has_element?(view, "#settings-model-scoreboard", "None yet")
|
||||
end
|
||||
|
||||
test "an unknown panel slug falls back to the account panel", %{conn: conn} do
|
||||
{:ok, view, _html} =
|
||||
conn
|
||||
|> log_in_account(account_fixture())
|
||||
|> live(~p"/accounts/settings/nonsense")
|
||||
|
||||
assert has_element?(view, "#email_form")
|
||||
assert has_element?(view, "#settings-tab-account[aria-current=page]")
|
||||
end
|
||||
|
||||
test "renders the API reference on the credentials panel", %{conn: conn} do
|
||||
{:ok, view, html} =
|
||||
conn
|
||||
|> log_in_account(account_fixture())
|
||||
|> live(~p"/accounts/settings/credentials")
|
||||
|
||||
assert has_element?(view, "#api-reference", "API reference")
|
||||
assert has_element?(view, "#api-reference-base-url", "/api")
|
||||
assert html =~ "/client-auth/exchange"
|
||||
assert html =~ "/repositories"
|
||||
assert html =~ "/jobs/:id/claim/renew"
|
||||
assert html =~ "/:host/:owner/:name/reports"
|
||||
end
|
||||
|
||||
test "marks an attached code-host identity as connected", %{conn: conn} do
|
||||
{:ok, view, _html} =
|
||||
conn
|
||||
|> log_in_account(github_account_fixture())
|
||||
|> live(~p"/accounts/settings")
|
||||
|
||||
assert has_element?(view, "#settings-github-identity", "Connected")
|
||||
assert has_element?(view, "#settings-gitlab-identity", "Connect")
|
||||
end
|
||||
|
||||
test "redirects if account is not logged in", %{conn: conn} do
|
||||
assert {:error, redirect} = live(conn, ~p"/accounts/settings")
|
||||
|
||||
assert {:redirect, %{to: path, flash: flash}} = redirect
|
||||
assert path == ~p"/accounts/log-in"
|
||||
assert %{"error" => "You must log in to access this page."} = flash
|
||||
end
|
||||
|
||||
test "generates and independently revokes a client credential", %{conn: conn} do
|
||||
account = account_fixture()
|
||||
|
||||
{:ok, view, html} =
|
||||
conn
|
||||
|> log_in_account(account)
|
||||
|> live(~p"/accounts/settings/credentials")
|
||||
|
||||
assert html =~ "Client credentials"
|
||||
refute has_element?(view, "#api-token-value")
|
||||
|
||||
view
|
||||
|> form("#api-credential-form", %{
|
||||
"credential" => %{
|
||||
"name" => "Task reader",
|
||||
"scopes" => ["tasks:read"]
|
||||
}
|
||||
})
|
||||
|> render_submit()
|
||||
|
||||
token =
|
||||
view
|
||||
|> element("#api-token-value")
|
||||
|> render()
|
||||
|> String.replace(~r/<[^>]+>/, "")
|
||||
|> String.trim()
|
||||
|
||||
assert {:ok, fetched} = Accounts.fetch_account_by_api_token(token)
|
||||
assert fetched.id == account.id
|
||||
|
||||
[credential] = Tarakan.Accounts.ApiCredentials.list(account)
|
||||
assert credential.name == "Task reader"
|
||||
assert credential.scopes == ["tasks:read"]
|
||||
assert has_element?(view, "#api-credential-#{credential.id}")
|
||||
|
||||
view
|
||||
|> element("#revoke-api-credential-#{credential.id}")
|
||||
|> render_click()
|
||||
|
||||
assert :error = Accounts.fetch_account_by_api_token(token)
|
||||
refute has_element?(view, "#revoke-api-credential-#{credential.id}")
|
||||
end
|
||||
|
||||
test "can bind a least-privilege credential to one repository", %{conn: conn} do
|
||||
account = github_account_fixture()
|
||||
repository = github_repository_fixture(account)
|
||||
|
||||
{:ok, view, _html} =
|
||||
conn
|
||||
|> log_in_account(account)
|
||||
|> live(~p"/accounts/settings/credentials")
|
||||
|
||||
view
|
||||
|> form("#api-credential-form", %{
|
||||
"credential" => %{
|
||||
"name" => "Codex reader",
|
||||
"repository" => "openai/codex",
|
||||
"scopes" => ["tasks:read"]
|
||||
}
|
||||
})
|
||||
|> render_submit()
|
||||
|
||||
token =
|
||||
view
|
||||
|> element("#api-token-value")
|
||||
|> render()
|
||||
|> String.replace(~r/<[^>]+>/, "")
|
||||
|> String.trim()
|
||||
|
||||
assert {:ok, ^account, credential} =
|
||||
Tarakan.Accounts.ApiCredentials.authenticate(token)
|
||||
|
||||
assert credential.repository_id == repository.id
|
||||
assert credential.scopes == ["tasks:read"]
|
||||
end
|
||||
|
||||
test "redirects if account is not in sudo mode", %{conn: conn} do
|
||||
stale_at = DateTime.add(DateTime.utc_now(:second), -9 * 60, :minute)
|
||||
|
||||
{:ok, conn} =
|
||||
conn
|
||||
|> log_in_account(account_fixture(), token_authenticated_at: stale_at)
|
||||
|> live(~p"/accounts/settings")
|
||||
|> follow_redirect(conn, ~p"/accounts/log-in?return_to=%2Faccounts%2Fsettings")
|
||||
|
||||
assert conn.resp_body =~ "sign-in older than 2 hours for sensitive settings"
|
||||
end
|
||||
|
||||
test "prompts unconfirmed accounts to confirm email instead of credential forms", %{
|
||||
conn: conn
|
||||
} do
|
||||
conn = log_in_account(conn, unconfirmed_account_fixture())
|
||||
|
||||
{:ok, view, html} = live(conn, ~p"/accounts/settings")
|
||||
|
||||
assert has_element?(view, "#email-confirmation-required")
|
||||
assert has_element?(view, "#settings-tab-account", "unconfirmed")
|
||||
assert html =~ "Confirm your email to unlock credentials"
|
||||
refute has_element?(view, "#password_form")
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/accounts/settings/credentials")
|
||||
refute has_element?(view, "#api-credential-form")
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/accounts/settings/ssh-keys")
|
||||
refute has_element?(view, "#ssh-key-form")
|
||||
end
|
||||
end
|
||||
|
||||
describe "SSH keys" do
|
||||
@describetag :tmp_dir
|
||||
|
||||
defp generate_public_key(tmp_dir) do
|
||||
path = Path.join(tmp_dir, "key-#{System.unique_integer([:positive])}")
|
||||
{_output, 0} = System.cmd("ssh-keygen", ["-t", "ed25519", "-f", path, "-N", "", "-q"])
|
||||
File.read!(path <> ".pub")
|
||||
end
|
||||
|
||||
test "adds and removes a key", %{conn: conn, tmp_dir: tmp_dir} do
|
||||
account = account_fixture()
|
||||
|
||||
{:ok, view, html} =
|
||||
conn
|
||||
|> log_in_account(account)
|
||||
|> live(~p"/accounts/settings/ssh-keys")
|
||||
|
||||
assert html =~ "SSH keys"
|
||||
|
||||
view
|
||||
|> form("#ssh-key-form",
|
||||
ssh_key: %{name: "laptop", public_key: generate_public_key(tmp_dir)}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
assert has_element?(view, "#ssh-key-list", "laptop")
|
||||
assert has_element?(view, "#ssh-key-list", "SHA256:")
|
||||
|
||||
[key] = Tarakan.Accounts.SshKeys.list_for_account(account)
|
||||
|
||||
view
|
||||
|> element("#delete-ssh-key-#{key.id}")
|
||||
|> render_click()
|
||||
|
||||
refute has_element?(view, "#ssh-key-list")
|
||||
assert Tarakan.Accounts.SshKeys.list_for_account(account) == []
|
||||
end
|
||||
|
||||
test "rejects an invalid key inline", %{conn: conn} do
|
||||
{:ok, view, _html} =
|
||||
conn
|
||||
|> log_in_account(account_fixture())
|
||||
|> live(~p"/accounts/settings/ssh-keys")
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("#ssh-key-form", ssh_key: %{name: "junk", public_key: "not a key"})
|
||||
|> render_submit()
|
||||
|
||||
assert html =~ "is not a supported OpenSSH public key"
|
||||
end
|
||||
end
|
||||
|
||||
describe "update email form" do
|
||||
setup %{conn: conn} do
|
||||
account = account_fixture()
|
||||
%{conn: log_in_account(conn, account), account: account}
|
||||
end
|
||||
|
||||
test "updates the account email", %{conn: conn, account: account} do
|
||||
new_email = unique_account_email()
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/accounts/settings")
|
||||
|
||||
result =
|
||||
lv
|
||||
|> form("#email_form", %{
|
||||
"account" => %{"email" => new_email}
|
||||
})
|
||||
|> render_submit()
|
||||
|
||||
assert result =~ "A link to confirm your email"
|
||||
assert Accounts.get_account_by_email(account.email)
|
||||
end
|
||||
|
||||
test "renders errors with invalid data (phx-change)", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/accounts/settings")
|
||||
|
||||
result =
|
||||
lv
|
||||
|> element("#email_form")
|
||||
|> render_change(%{
|
||||
"action" => "update_email",
|
||||
"account" => %{"email" => "with spaces"}
|
||||
})
|
||||
|
||||
assert result =~ "Save email"
|
||||
assert result =~ "must have the @ sign and no spaces"
|
||||
end
|
||||
|
||||
test "renders errors with invalid data (phx-submit)", %{conn: conn, account: account} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/accounts/settings")
|
||||
|
||||
result =
|
||||
lv
|
||||
|> form("#email_form", %{
|
||||
"account" => %{"email" => account.email}
|
||||
})
|
||||
|> render_submit()
|
||||
|
||||
assert result =~ "Save email"
|
||||
assert result =~ "did not change"
|
||||
end
|
||||
end
|
||||
|
||||
describe "update password form" do
|
||||
setup %{conn: conn} do
|
||||
account = account_fixture()
|
||||
%{conn: log_in_account(conn, account), account: account}
|
||||
end
|
||||
|
||||
test "updates the account password", %{conn: conn, account: account} do
|
||||
new_password = valid_account_password()
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/accounts/settings")
|
||||
|
||||
form =
|
||||
form(lv, "#password_form", %{
|
||||
"account" => %{
|
||||
"password" => new_password,
|
||||
"password_confirmation" => new_password
|
||||
}
|
||||
})
|
||||
|
||||
render_submit(form)
|
||||
|
||||
new_password_conn = follow_trigger_action(form, conn)
|
||||
|
||||
assert redirected_to(new_password_conn) == ~p"/accounts/settings"
|
||||
|
||||
assert get_session(new_password_conn, :account_token) != get_session(conn, :account_token)
|
||||
|
||||
assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~
|
||||
"Password updated successfully"
|
||||
|
||||
assert Accounts.get_account_by_email_and_password(account.email, new_password)
|
||||
end
|
||||
|
||||
test "renders errors with invalid data (phx-change)", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/accounts/settings")
|
||||
|
||||
result =
|
||||
lv
|
||||
|> element("#password_form")
|
||||
|> render_change(%{
|
||||
"account" => %{
|
||||
"password" => "too short",
|
||||
"password_confirmation" => "does not match"
|
||||
}
|
||||
})
|
||||
|
||||
assert result =~ "Save password"
|
||||
assert result =~ "should be at least 15 character(s)"
|
||||
assert result =~ "does not match password"
|
||||
end
|
||||
|
||||
test "renders errors with invalid data (phx-submit)", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/accounts/settings")
|
||||
|
||||
result =
|
||||
lv
|
||||
|> form("#password_form", %{
|
||||
"account" => %{
|
||||
"password" => "too short",
|
||||
"password_confirmation" => "does not match"
|
||||
}
|
||||
})
|
||||
|> render_submit()
|
||||
|
||||
assert result =~ "Save password"
|
||||
assert result =~ "should be at least 15 character(s)"
|
||||
assert result =~ "does not match password"
|
||||
end
|
||||
end
|
||||
|
||||
describe "confirm email" do
|
||||
setup %{conn: conn} do
|
||||
account = account_fixture()
|
||||
email = unique_account_email()
|
||||
|
||||
token =
|
||||
extract_account_token(fn url ->
|
||||
Accounts.deliver_account_update_email_instructions(
|
||||
%{account | email: email},
|
||||
account.email,
|
||||
url
|
||||
)
|
||||
end)
|
||||
|
||||
%{conn: log_in_account(conn, account), token: token, email: email, account: account}
|
||||
end
|
||||
|
||||
test "updates the account email once", %{
|
||||
conn: conn,
|
||||
account: account,
|
||||
token: token,
|
||||
email: email
|
||||
} do
|
||||
{:error, redirect} = live(conn, ~p"/accounts/settings/confirm-email/#{token}")
|
||||
|
||||
assert {:live_redirect, %{to: path, flash: flash}} = redirect
|
||||
assert path == ~p"/accounts/settings"
|
||||
assert %{"info" => message} = flash
|
||||
assert message == "Email changed successfully."
|
||||
refute Accounts.get_account_by_email(account.email)
|
||||
assert Accounts.get_account_by_email(email)
|
||||
|
||||
# use confirm token again
|
||||
{:error, redirect} = live(conn, ~p"/accounts/settings/confirm-email/#{token}")
|
||||
assert {:live_redirect, %{to: path, flash: flash}} = redirect
|
||||
assert path == ~p"/accounts/settings"
|
||||
assert %{"error" => message} = flash
|
||||
assert message == "Email change link is invalid or it has expired."
|
||||
end
|
||||
|
||||
test "does not update email with invalid token", %{conn: conn, account: account} do
|
||||
{:error, redirect} = live(conn, ~p"/accounts/settings/confirm-email/oops")
|
||||
assert {:live_redirect, %{to: path, flash: flash}} = redirect
|
||||
assert path == ~p"/accounts/settings"
|
||||
assert %{"error" => message} = flash
|
||||
assert message == "Email change link is invalid or it has expired."
|
||||
assert Accounts.get_account_by_email(account.email)
|
||||
end
|
||||
|
||||
test "redirects if account is not logged in", %{token: token} do
|
||||
conn = build_conn()
|
||||
{:error, redirect} = live(conn, ~p"/accounts/settings/confirm-email/#{token}")
|
||||
assert {:redirect, %{to: path, flash: flash}} = redirect
|
||||
assert path == ~p"/accounts/log-in"
|
||||
assert %{"error" => message} = flash
|
||||
assert message == "You must log in to access this page."
|
||||
end
|
||||
end
|
||||
end
|
||||
134
test/tarakan_web/live/admin_live_test.exs
Normal file
134
test/tarakan_web/live/admin_live_test.exs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
defmodule TarakanWeb.AdminLiveTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Tarakan.Accounts
|
||||
alias Tarakan.Accounts.Account
|
||||
alias Tarakan.Repo
|
||||
|
||||
setup %{conn: conn} do
|
||||
admin =
|
||||
account_fixture()
|
||||
|> Account.authorization_changeset(%{
|
||||
state: "active",
|
||||
platform_role: "admin",
|
||||
trust_tier: "reviewer"
|
||||
})
|
||||
|> Repo.update!()
|
||||
|
||||
%{admin: admin, conn: log_in_account(conn, admin)}
|
||||
end
|
||||
|
||||
test "admin sees account summary, search, and management links", %{conn: conn} do
|
||||
target = account_fixture(%{handle: "managed-account"})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin")
|
||||
|
||||
assert has_element?(view, "#admin-dashboard")
|
||||
assert has_element?(view, "#admin-summary-admins")
|
||||
assert has_element?(view, "#admin-account-filter")
|
||||
assert has_element?(view, "#admin-account-#{target.id}-manage")
|
||||
|
||||
view
|
||||
|> form("#admin-account-filter", filters: %{query: "managed-account"})
|
||||
|> render_change()
|
||||
|
||||
assert has_element?(view, "#admin-account-#{target.id}-manage")
|
||||
end
|
||||
|
||||
test "admin updates account standing, role, and trust tier", %{conn: conn} do
|
||||
target = account_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/accounts/#{target.id}")
|
||||
|
||||
assert has_element?(view, "#admin-account")
|
||||
assert has_element?(view, "#admin-authorization-form")
|
||||
|
||||
view
|
||||
|> form("#admin-authorization-form",
|
||||
authorization: %{
|
||||
state: "active",
|
||||
platform_role: "moderator",
|
||||
trust_tier: "reviewer"
|
||||
}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
updated = Accounts.get_account!(target.id)
|
||||
assert updated.state == "active"
|
||||
assert updated.platform_role == "moderator"
|
||||
assert updated.trust_tier == "reviewer"
|
||||
assert has_element?(view, "#admin-authorization-form")
|
||||
end
|
||||
|
||||
test "ordinary members cannot open platform administration", %{conn: _admin_conn} do
|
||||
member =
|
||||
account_fixture() |> Account.authorization_changeset(%{state: "active"}) |> Repo.update!()
|
||||
|
||||
conn = log_in_account(build_conn(), member)
|
||||
|
||||
assert {:error, {:redirect, %{to: path, flash: flash}}} = live(conn, ~p"/admin")
|
||||
assert path == ~p"/"
|
||||
assert flash["error"] == "Administrator access is required."
|
||||
end
|
||||
|
||||
test "last administrator cannot remove their own authority", %{conn: conn, admin: admin} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/accounts/#{admin.id}")
|
||||
|
||||
view
|
||||
|> form("#admin-authorization-form",
|
||||
authorization: %{
|
||||
state: "active",
|
||||
platform_role: "member",
|
||||
trust_tier: "reviewer"
|
||||
}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
assert Accounts.get_account!(admin.id).platform_role == "admin"
|
||||
assert has_element?(view, "#admin-authorization-form")
|
||||
end
|
||||
|
||||
test "filtering requires a still-valid sudo session", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin")
|
||||
|
||||
expire_sudo_mode(view)
|
||||
|
||||
assert {:error, {:live_redirect, %{to: "/accounts/log-in?return_to=%2Fadmin"}}} =
|
||||
view
|
||||
|> form("#admin-account-filter", filters: %{query: "anything"})
|
||||
|> render_change()
|
||||
end
|
||||
|
||||
test "saving requires a still-valid sudo session", %{conn: conn} do
|
||||
target = account_fixture()
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/accounts/#{target.id}")
|
||||
|
||||
expire_sudo_mode(view)
|
||||
|
||||
assert {:error,
|
||||
{:live_redirect, %{to: "/accounts/log-in?return_to=%2Fadmin%2Faccounts%2F" <> _}}} =
|
||||
view
|
||||
|> form("#admin-authorization-form",
|
||||
authorization: %{
|
||||
state: "active",
|
||||
platform_role: "moderator",
|
||||
trust_tier: "reviewer"
|
||||
}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
assert Accounts.get_account!(target.id).platform_role == target.platform_role
|
||||
end
|
||||
|
||||
# Simulates the two-hour sudo window expiring while the LiveView stays open.
|
||||
defp expire_sudo_mode(view) do
|
||||
stale = DateTime.add(DateTime.utc_now(), -3, :hour)
|
||||
|
||||
:sys.replace_state(view.pid, fn state ->
|
||||
account = %{state.socket.assigns.current_scope.account | authenticated_at: stale}
|
||||
put_in(state.socket.assigns.current_scope.account, account)
|
||||
end)
|
||||
end
|
||||
end
|
||||
34
test/tarakan_web/live/agents_live_test.exs
Normal file
34
test/tarakan_web/live/agents_live_test.exs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
defmodule TarakanWeb.AgentsLiveTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
test "install, publish Reports, pick up Jobs", %{conn: conn} do
|
||||
{:ok, view, html} = live(conn, ~p"/agents")
|
||||
|
||||
assert has_element?(view, "#agents-commands")
|
||||
assert has_element?(view, "#agents-dump-commands")
|
||||
assert has_element?(view, "#agents-pickup-commands")
|
||||
assert has_element?(view, "#agents-report-api")
|
||||
|
||||
assert html =~ "/install.sh | bash"
|
||||
assert html =~ "tarakan login"
|
||||
assert html =~ "tarakan worker --agent kimi"
|
||||
assert html =~ "tarakan --agent kimi --pickup"
|
||||
assert html =~ "POST "
|
||||
assert html =~ "/reports"
|
||||
assert html =~ "Report"
|
||||
assert html =~ "Check"
|
||||
assert html =~ "Job"
|
||||
refute html =~ "tarakan login --url"
|
||||
end
|
||||
|
||||
test "install.sh covers worker and pickup", %{conn: conn} do
|
||||
conn = get(conn, "/install.sh")
|
||||
assert conn.status == 200
|
||||
body = response(conn, 200)
|
||||
assert body =~ "tarakan login"
|
||||
assert body =~ "tarakan worker --agent kimi"
|
||||
assert body =~ "tarakan --agent kimi --pickup"
|
||||
end
|
||||
end
|
||||
79
test/tarakan_web/live/alert_live_test.exs
Normal file
79
test/tarakan_web/live/alert_live_test.exs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
defmodule TarakanWeb.AlertLiveTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Tarakan.Billing
|
||||
alias Tarakan.Repo
|
||||
|
||||
test "redirects anonymous visitors to login", %{conn: conn} do
|
||||
assert {:error, {:redirect, %{to: "/accounts/log-in"}}} = live(conn, ~p"/alerts")
|
||||
end
|
||||
|
||||
test "creates, edits, toggles, and deletes a watchlist", %{conn: conn} do
|
||||
account = account_fixture()
|
||||
{:ok, view, _html} = conn |> log_in_account(account) |> live(~p"/alerts")
|
||||
|
||||
assert has_element?(view, "#watchlists")
|
||||
assert has_element?(view, "#watchlist-form")
|
||||
|
||||
# Create
|
||||
view
|
||||
|> form("#watchlist-form", watchlist: %{name: "Deps", entries: "openai/codex\nacme/widget"})
|
||||
|> render_submit()
|
||||
|
||||
[watchlist] = Billing.list_watchlists(account)
|
||||
assert watchlist.name == "Deps"
|
||||
assert watchlist.entries == ["openai/codex", "acme/widget"]
|
||||
assert has_element?(view, "#watchlist-#{watchlist.id}")
|
||||
assert has_element?(view, "#watchlist-#{watchlist.id}", "openai/codex")
|
||||
|
||||
# Toggle notify off
|
||||
view |> element("#watchlist-notify-#{watchlist.id}") |> render_click()
|
||||
refute Repo.get!(Billing.Watchlist, watchlist.id).notify
|
||||
|
||||
# Edit
|
||||
view |> element("#watchlist-edit-#{watchlist.id}") |> render_click()
|
||||
|
||||
view
|
||||
|> form("#watchlist-form", watchlist: %{name: "Deps v2", entries: "openai/codex"})
|
||||
|> render_submit()
|
||||
|
||||
updated = Repo.get!(Billing.Watchlist, watchlist.id)
|
||||
assert updated.name == "Deps v2"
|
||||
assert updated.entries == ["openai/codex"]
|
||||
assert has_element?(view, "#watchlist-#{watchlist.id}", "Deps v2")
|
||||
|
||||
# Delete
|
||||
view |> element("#watchlist-delete-#{watchlist.id}") |> render_click()
|
||||
refute has_element?(view, "#watchlist-#{watchlist.id}")
|
||||
assert Billing.list_watchlists(account) == []
|
||||
end
|
||||
|
||||
test "invalid entries show an error and do not persist", %{conn: conn} do
|
||||
account = account_fixture()
|
||||
{:ok, view, _html} = conn |> log_in_account(account) |> live(~p"/alerts")
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("#watchlist-form", watchlist: %{name: "Bad", entries: "not a ref"})
|
||||
|> render_submit()
|
||||
|
||||
assert html =~ "owner/name"
|
||||
assert Billing.list_watchlists(account) == []
|
||||
end
|
||||
|
||||
test "watchlists are owner-scoped", %{conn: conn} do
|
||||
owner = account_fixture()
|
||||
|
||||
{:ok, _} =
|
||||
Billing.create_watchlist(Tarakan.Accounts.Scope.for_account(owner), %{
|
||||
"name" => "Private",
|
||||
"entries" => ["openai/codex"]
|
||||
})
|
||||
|
||||
{:ok, view, _html} = conn |> log_in_account(account_fixture()) |> live(~p"/alerts")
|
||||
|
||||
refute has_element?(view, "#watchlists", "Private")
|
||||
end
|
||||
end
|
||||
88
test/tarakan_web/live/billing_live_test.exs
Normal file
88
test/tarakan_web/live/billing_live_test.exs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
defmodule TarakanWeb.BillingLiveTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Tarakan.Billing.Subscription
|
||||
alias Tarakan.Repo
|
||||
|
||||
defp subscription_fixture(account, overrides) do
|
||||
attrs =
|
||||
Map.merge(
|
||||
%{
|
||||
account_id: account.id,
|
||||
plan: "enterprise",
|
||||
status: "active",
|
||||
stripe_customer_id: "cus_lv",
|
||||
stripe_subscription_id: "sub_lv_#{account.id}",
|
||||
current_period_end: DateTime.utc_now()
|
||||
},
|
||||
overrides
|
||||
)
|
||||
|
||||
%Subscription{}
|
||||
|> Subscription.changeset(attrs)
|
||||
|> Repo.insert!()
|
||||
end
|
||||
|
||||
test "redirects anonymous visitors to login", %{conn: conn} do
|
||||
assert {:error, {:redirect, %{to: "/accounts/log-in"}}} = live(conn, ~p"/accounts/billing")
|
||||
end
|
||||
|
||||
test "shows the unsubscribed state with the managed-disclosure button", %{conn: conn} do
|
||||
{:ok, view, _html} = conn |> log_in_account(account_fixture()) |> live(~p"/accounts/billing")
|
||||
|
||||
assert has_element?(view, "#billing-current")
|
||||
assert has_element?(view, "#billing-plan", "None")
|
||||
assert has_element?(view, "#billing-subscribe-enterprise")
|
||||
refute has_element?(view, "#billing-manage-button")
|
||||
end
|
||||
|
||||
test "shows the active plan, status, period end, and manage button", %{conn: conn} do
|
||||
account = account_fixture()
|
||||
subscription_fixture(account, %{})
|
||||
|
||||
{:ok, view, _html} = conn |> log_in_account(account) |> live(~p"/accounts/billing")
|
||||
|
||||
assert has_element?(view, "#billing-plan", "Managed disclosure")
|
||||
assert has_element?(view, "#billing-status", "Active")
|
||||
assert has_element?(view, "#billing-manage-button")
|
||||
refute has_element?(view, "#billing-subscribe-enterprise")
|
||||
end
|
||||
|
||||
test "renders past_due and canceled states", %{conn: conn} do
|
||||
past_due = account_fixture()
|
||||
subscription_fixture(past_due, %{status: "past_due"})
|
||||
|
||||
{:ok, view, _html} = conn |> log_in_account(past_due) |> live(~p"/accounts/billing")
|
||||
assert has_element?(view, "#billing-status", "Past due")
|
||||
|
||||
canceled = account_fixture()
|
||||
subscription_fixture(canceled, %{status: "canceled"})
|
||||
|
||||
{:ok, view, _html} = conn |> log_in_account(canceled) |> live(~p"/accounts/billing")
|
||||
assert has_element?(view, "#billing-status", "Canceled")
|
||||
assert has_element?(view, "#billing-subscribe-enterprise")
|
||||
end
|
||||
|
||||
test "subscribe redirects to Stripe checkout", %{conn: conn} do
|
||||
{:ok, view, _html} = conn |> log_in_account(account_fixture()) |> live(~p"/accounts/billing")
|
||||
|
||||
assert {:error, {:redirect, %{to: checkout_url}}} =
|
||||
view |> element("#billing-subscribe-enterprise") |> render_click()
|
||||
|
||||
assert checkout_url =~ "https://checkout.stripe.com/"
|
||||
end
|
||||
|
||||
test "manage redirects to the Stripe billing portal", %{conn: conn} do
|
||||
account = account_fixture()
|
||||
subscription_fixture(account, %{})
|
||||
|
||||
{:ok, view, _html} = conn |> log_in_account(account) |> live(~p"/accounts/billing")
|
||||
|
||||
assert {:error, {:redirect, %{to: portal_url}}} =
|
||||
view |> element("#billing-manage-button") |> render_click()
|
||||
|
||||
assert portal_url =~ "https://billing.stripe.com/"
|
||||
end
|
||||
end
|
||||
155
test/tarakan_web/live/bounty_live_test.exs
Normal file
155
test/tarakan_web/live/bounty_live_test.exs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
defmodule TarakanWeb.BountyLiveTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Tarakan.Accounts.Scope
|
||||
alias Tarakan.Market
|
||||
alias Tarakan.Market.Bounty
|
||||
alias Tarakan.Repo
|
||||
|
||||
defp open_bounty do
|
||||
repository = listed_github_repository_fixture()
|
||||
sponsor = active_account_fixture() |> fund_credits(1_000)
|
||||
|
||||
bounty = credits_bounty_fixture(Scope.for_account(sponsor), repository)
|
||||
{bounty, sponsor, repository}
|
||||
end
|
||||
|
||||
describe "index" do
|
||||
test "renders open bounties for anonymous visitors", %{conn: conn} do
|
||||
{bounty, sponsor, _repository} = open_bounty()
|
||||
|
||||
{:ok, view, html} = live(conn, ~p"/bounties")
|
||||
|
||||
assert html =~ "Contracts"
|
||||
assert has_element?(view, "#bounties")
|
||||
assert has_element?(view, "#bounty-#{bounty.public_id}")
|
||||
assert html =~ bounty.title
|
||||
assert html =~ "500 credits"
|
||||
assert html =~ "@#{sponsor.handle}"
|
||||
assert has_element?(view, "#bounty-filters")
|
||||
end
|
||||
|
||||
test "shows the empty state when nothing is open", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/bounties")
|
||||
|
||||
assert has_element?(view, "#bounties-empty")
|
||||
end
|
||||
end
|
||||
|
||||
describe "new" do
|
||||
test "redirects anonymous visitors to login", %{conn: conn} do
|
||||
assert {:error, {:redirect, %{to: "/accounts/log-in"}}} = live(conn, ~p"/bounties/new")
|
||||
end
|
||||
|
||||
test "creates a credits bounty and lands on the poster", %{conn: conn} do
|
||||
repository = listed_github_repository_fixture()
|
||||
sponsor = active_account_fixture() |> fund_credits(1_000)
|
||||
conn = log_in_account(conn, sponsor)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/bounties/new")
|
||||
|
||||
assert has_element?(view, "#bounty-form")
|
||||
assert has_element?(view, "#bounty-credit-balance")
|
||||
|
||||
# The target is picked from search, not typed.
|
||||
view
|
||||
|> form("#bounty-form", %{"bounty" => %{"repo_query" => repository.name}})
|
||||
|> render_change()
|
||||
|
||||
assert view
|
||||
|> element("#bounty-repo-results button", "#{repository.owner}/#{repository.name}")
|
||||
|> render_click()
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"#bounty-selected-repository",
|
||||
"#{repository.host}/#{repository.owner}/#{repository.name}"
|
||||
)
|
||||
|
||||
{:ok, _show_view, html} =
|
||||
view
|
||||
|> form("#bounty-form", %{
|
||||
"bounty" => %{
|
||||
"target_type" => "repository",
|
||||
"title" => "Harden the webhook verifier",
|
||||
"description" => "Audit the signature verification path and ship a fix with tests.",
|
||||
"funding" => "credits",
|
||||
"amount" => "300"
|
||||
}
|
||||
})
|
||||
|> render_submit()
|
||||
|> follow_redirect(conn)
|
||||
|
||||
assert html =~ "Harden the webhook verifier"
|
||||
assert html =~ "300 credits"
|
||||
|
||||
bounty = Repo.one!(Bounty)
|
||||
assert bounty.status == "open"
|
||||
assert bounty.credit_amount == 300
|
||||
assert Tarakan.Credits.balance(sponsor) == 700
|
||||
end
|
||||
end
|
||||
|
||||
describe "show" do
|
||||
test "anonymous visitors see the poster and a login prompt, not a claim button", %{
|
||||
conn: conn
|
||||
} do
|
||||
{bounty, _sponsor, _repository} = open_bounty()
|
||||
|
||||
{:ok, view, html} = live(conn, ~p"/bounties/#{bounty.public_id}")
|
||||
|
||||
assert html =~ bounty.title
|
||||
assert has_element?(view, "#bounty-status")
|
||||
refute has_element?(view, "#bounty-claim-button")
|
||||
assert has_element?(view, "#bounty-login-to-claim")
|
||||
end
|
||||
|
||||
test "the sponsor sees cancel controls but no claim button", %{conn: conn} do
|
||||
{bounty, sponsor, _repository} = open_bounty()
|
||||
conn = log_in_account(conn, sponsor)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/bounties/#{bounty.public_id}")
|
||||
|
||||
refute has_element?(view, "#bounty-claim-button")
|
||||
assert has_element?(view, "#bounty-cancel-button")
|
||||
end
|
||||
|
||||
test "an eligible hunter sees and uses the claim button", %{conn: conn} do
|
||||
{bounty, _sponsor, _repository} = open_bounty()
|
||||
hunter = active_account_fixture()
|
||||
conn = log_in_account(conn, hunter)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/bounties/#{bounty.public_id}")
|
||||
|
||||
assert has_element?(view, "#bounty-claim-button")
|
||||
|
||||
html = view |> element("#bounty-claim-button") |> render_click()
|
||||
|
||||
assert html =~ "claimed"
|
||||
assert has_element?(view, "#bounty-claims")
|
||||
|
||||
bounty = Repo.get!(Bounty, bounty.id)
|
||||
assert bounty.status == "claimed"
|
||||
|
||||
claim = Repo.one!(Tarakan.Market.BountyClaim)
|
||||
assert claim.account_id == hunter.id
|
||||
assert claim.review_task_id
|
||||
end
|
||||
end
|
||||
|
||||
describe "wanted banners" do
|
||||
test "the repository security page shows the banner for its open bounty", %{conn: conn} do
|
||||
{bounty, _sponsor, repository} = open_bounty()
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, TarakanWeb.RepositoryPaths.repository_security_path(repository))
|
||||
|
||||
assert has_element?(view, "#wanted-banner")
|
||||
|
||||
bounty = Market.get_bounty!(bounty.public_id)
|
||||
assert bounty.status == "open"
|
||||
end
|
||||
end
|
||||
end
|
||||
79
test/tarakan_web/live/client_authorization_live_test.exs
Normal file
79
test/tarakan_web/live/client_authorization_live_test.exs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
defmodule TarakanWeb.ClientAuthorizationLiveTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Tarakan.Accounts.ClientAuthorizations
|
||||
|
||||
setup :register_and_log_in_account
|
||||
|
||||
test "approve/deny with no loaded authorization flashes instead of crashing", %{conn: conn} do
|
||||
# An unknown/expired user code leaves @authorization nil and hides the
|
||||
# buttons - but a queued or crafted event must not reach the context with
|
||||
# nil and raise a FunctionClauseError.
|
||||
{:ok, view, html} = live(conn, ~p"/client/authorize/UNKNOWNXY")
|
||||
assert html =~ "invalid or expired"
|
||||
|
||||
assert render_click(view, "approve") =~ "expired or was already used"
|
||||
assert render_click(view, "deny") =~ "expired or was already used"
|
||||
end
|
||||
|
||||
describe "consent screen" do
|
||||
test "attributes the client-supplied name instead of asserting it", %{conn: conn} do
|
||||
{:ok, _device_code, user_code, _authorization} =
|
||||
ClientAuthorizations.start(%{"client_name" => "Totally Official Thing"})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/client/authorize/#{user_code}")
|
||||
|
||||
# The name is presented as the request's own claim, next to a warning,
|
||||
# rather than as a statement this page vouches for.
|
||||
assert html =~ "Calls itself"
|
||||
assert html =~ "Totally Official Thing"
|
||||
assert html =~ "Nobody at Tarakan will"
|
||||
end
|
||||
|
||||
test "a name carrying control or bidi characters is cleaned before display", %{conn: conn} do
|
||||
hostile = "Tarakan" <> "\u202e" <> "lortnoC" <> "\u200b" <> " Client"
|
||||
|
||||
{:ok, _device_code, user_code, authorization} =
|
||||
ClientAuthorizations.start(%{"client_name" => hostile})
|
||||
|
||||
refute authorization.client_name =~ "\u202e"
|
||||
refute authorization.client_name =~ "\u200b"
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/client/authorize/#{user_code}")
|
||||
refute html =~ "\u202e"
|
||||
end
|
||||
|
||||
test "a name of only unusable characters falls back to the default", %{conn: _conn} do
|
||||
{:ok, _device_code, _user_code, authorization} =
|
||||
ClientAuthorizations.start(%{"client_name" => "\u200b\u202e"})
|
||||
|
||||
assert authorization.client_name == "Tarakan Client"
|
||||
end
|
||||
|
||||
test "a name with markup-shaped characters is refused outright" do
|
||||
assert {:error, changeset} =
|
||||
ClientAuthorizations.start(%{"client_name" => "Tarakan <script> Client"})
|
||||
|
||||
assert Keyword.has_key?(changeset.errors, :client_name)
|
||||
end
|
||||
end
|
||||
|
||||
describe "user code lookups" do
|
||||
test "wrong guesses are budgeted per account", %{conn: conn} do
|
||||
# Well past the per-account allowance; the page must stay uninformative
|
||||
# rather than answering "exists / does not exist" indefinitely.
|
||||
for index <- 1..35 do
|
||||
{:ok, _view, html} = live(conn, ~p"/client/authorize/#{"AAAA000" <> to_string(index)}")
|
||||
assert html =~ "invalid or expired"
|
||||
end
|
||||
|
||||
# A real code now reads the same as a wrong one: throttled and missing
|
||||
# are deliberately indistinguishable.
|
||||
{:ok, _device_code, user_code, _authorization} = ClientAuthorizations.start()
|
||||
{:ok, _view, html} = live(conn, ~p"/client/authorize/#{user_code}")
|
||||
assert html =~ "invalid or expired"
|
||||
end
|
||||
end
|
||||
end
|
||||
80
test/tarakan_web/live/community_live_test.exs
Normal file
80
test/tarakan_web/live/community_live_test.exs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
defmodule TarakanWeb.CommunityLiveTest do
|
||||
use TarakanWeb.ConnCase
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
test "the homepage shows the shoutbox and gates posting", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/")
|
||||
|
||||
assert has_element?(view, "#registry-shoutbox")
|
||||
refute has_element?(view, "#registry-presence")
|
||||
assert has_element?(view, "#shoutbox-empty")
|
||||
refute has_element?(view, "#shoutbox-form")
|
||||
assert has_element?(view, ~s(#registry-shoutbox a[href="/accounts/log-in"]))
|
||||
end
|
||||
|
||||
test "a signed-in contributor posts a live escaped shout", %{conn: conn} do
|
||||
account = account_fixture()
|
||||
{:ok, view, _html} = live(log_in_account(conn, account), ~p"/")
|
||||
{:ok, observer, _html} = live(Phoenix.ConnTest.build_conn(), ~p"/")
|
||||
|
||||
assert has_element?(view, "#shoutbox-form")
|
||||
assert has_element?(view, ~s(#shoutbox-form input[type="text"]))
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("#shoutbox-form", shout: %{body: "Reviewing <script>alert(1)</script> auth."})
|
||||
|> render_submit()
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"#shoutbox-messages article",
|
||||
"Reviewing <script>alert(1)</script> auth."
|
||||
)
|
||||
|
||||
assert has_element?(view, "#shoutbox-body-1[phx-mounted]")
|
||||
|
||||
assert has_element?(
|
||||
observer,
|
||||
"#shoutbox-messages article",
|
||||
"Reviewing <script>alert(1)</script> auth."
|
||||
)
|
||||
|
||||
assert html =~ "<script>"
|
||||
refute html =~ "<script>alert(1)</script>"
|
||||
end
|
||||
|
||||
test "moderators can remove a shout from the homepage", %{conn: conn} do
|
||||
author = account_fixture()
|
||||
|
||||
{:ok, shout} =
|
||||
Tarakan.Community.create_shout(Tarakan.Accounts.Scope.for_account(author), %{
|
||||
"body" => "Off-topic shout"
|
||||
})
|
||||
|
||||
moderator = moderator_account_fixture()
|
||||
{:ok, view, _html} = live(log_in_account(conn, moderator), ~p"/")
|
||||
|
||||
view
|
||||
|> element("#shout-#{shout.id} button", "Remove")
|
||||
|> render_click()
|
||||
|
||||
assert has_element?(view, "#shout-#{shout.id}", "Removed by moderation")
|
||||
refute has_element?(view, "#shout-#{shout.id} button", "Remove")
|
||||
end
|
||||
|
||||
test "the compact composer rejects blank messages without an inline error", %{conn: conn} do
|
||||
{:ok, view, _html} = live(log_in_account(conn, account_fixture()), ~p"/")
|
||||
|
||||
assert has_element?(view, "#shoutbox-form input[required]")
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("#shoutbox-form", shout: %{body: " "})
|
||||
|> render_submit()
|
||||
|
||||
assert html =~ "Write a message before sending."
|
||||
refute has_element?(view, "#shoutbox-body-0-error")
|
||||
refute html =~ "be blank"
|
||||
end
|
||||
end
|
||||
160
test/tarakan_web/live/explore_live_test.exs
Normal file
160
test/tarakan_web/live/explore_live_test.exs
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
defmodule TarakanWeb.ExploreLiveTest do
|
||||
use TarakanWeb.ConnCase
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Tarakan.Activity
|
||||
|
||||
test "renders the wire: registrations and unverified public reviews", %{conn: conn} do
|
||||
submitter = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
scan = scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/explore")
|
||||
|
||||
assert has_element?(view, "#activity-wire")
|
||||
assert has_element?(view, "#wire-reg-#{repository.id}", "openai/codex")
|
||||
assert has_element?(view, "#wire-scan-#{scan.id}", "@#{submitter.handle}")
|
||||
assert has_element?(view, "#wire-scan-#{scan.id}", "1 finding")
|
||||
end
|
||||
|
||||
test "filters the wire by kind", %{conn: conn} do
|
||||
submitter = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
scan = scan_fixture(repository, submitter)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/explore")
|
||||
|
||||
view |> element("#explore-filter button", "Registrations") |> render_click()
|
||||
|
||||
assert has_element?(view, "#wire-reg-#{repository.id}")
|
||||
refute has_element?(view, "#wire-scan-#{scan.id}")
|
||||
|
||||
view |> element("#explore-filter button", "Reports") |> render_click()
|
||||
|
||||
assert has_element?(view, "#wire-scan-#{scan.id}")
|
||||
refute has_element?(view, "#wire-reg-#{repository.id}")
|
||||
end
|
||||
|
||||
test "new activity lands on the wire without a reload", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/explore")
|
||||
|
||||
assert has_element?(view, "#activity-wire-empty")
|
||||
|
||||
submitter = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
Activity.broadcast_registration(repository)
|
||||
|
||||
assert has_element?(view, "#wire-reg-#{repository.id}", "openai/codex")
|
||||
end
|
||||
|
||||
test "verdicts on the wire show who ruled and how", %{conn: conn} do
|
||||
submitter = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
scan = scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
reviewer = reviewer_account_fixture()
|
||||
confirmation_fixture(scan, reviewer)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/explore")
|
||||
|
||||
assert render(view) =~ "@#{reviewer.handle}"
|
||||
assert render(view) =~ "confirmed a finding on"
|
||||
end
|
||||
|
||||
test "handles link to contributor profiles", %{conn: conn} do
|
||||
submitter = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
scan = scan_fixture(repository, submitter)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/explore")
|
||||
|
||||
assert view
|
||||
|> element("#wire-scan-#{scan.id} a[href='/#{submitter.handle}']")
|
||||
|> render() =~ "@#{submitter.handle}"
|
||||
end
|
||||
|
||||
test "comments join the wire live and on reload", %{conn: conn} do
|
||||
submitter = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
scan = scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
[finding] = scan.findings
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/explore")
|
||||
|
||||
commenter = account_fixture()
|
||||
scope = Tarakan.Accounts.Scope.for_account(commenter)
|
||||
|
||||
{:ok, comment} =
|
||||
Tarakan.Discussion.create_comment(scope, %{finding | scan: scan}, %{
|
||||
"body" => "Reproduced on a clean checkout."
|
||||
})
|
||||
|
||||
assert has_element?(view, "#wire-comment-#{comment.id}", "@#{commenter.handle}")
|
||||
assert has_element?(view, "#wire-comment-#{comment.id}", "commented on")
|
||||
|
||||
{:ok, reloaded, _html} = live(conn, ~p"/explore")
|
||||
assert has_element?(reloaded, "#wire-comment-#{comment.id}")
|
||||
|
||||
reloaded |> element("#explore-filter button", "Comments") |> render_click()
|
||||
assert has_element?(reloaded, "#wire-comment-#{comment.id}")
|
||||
refute has_element?(reloaded, "#wire-scan-#{scan.id}")
|
||||
end
|
||||
|
||||
test "search narrows the wire by handle or repository", %{conn: conn} do
|
||||
submitter = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
scan = scan_fixture(repository, submitter)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/explore")
|
||||
|
||||
view
|
||||
|> form("#explore-search-form", %{"q" => submitter.handle})
|
||||
|> render_change()
|
||||
|
||||
assert has_element?(view, "#wire-scan-#{scan.id}")
|
||||
refute has_element?(view, "#wire-reg-#{repository.id}")
|
||||
|
||||
view
|
||||
|> form("#explore-search-form", %{"q" => "no-such-thing"})
|
||||
|> render_change()
|
||||
|
||||
assert has_element?(view, "#activity-wire-empty")
|
||||
end
|
||||
|
||||
test "shows watcher presence", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/explore")
|
||||
|
||||
assert has_element?(view, "#explore-watchers")
|
||||
assert view |> element("#explore-watchers") |> render() =~ "watching"
|
||||
end
|
||||
|
||||
test "hot findings rail ranks upvoted findings and refreshes on votes", %{conn: conn} do
|
||||
submitter = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
scan = scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
[finding] = scan.findings
|
||||
canonical = finding.canonical_finding
|
||||
|
||||
# Hot rail excludes pure single-run unconfirmed noise; second detection qualifies.
|
||||
_second =
|
||||
scan_fixture(repository, account_fixture(), %{
|
||||
"findings_json" => findings_json_fixture(1),
|
||||
"commit_sha" => scan.commit_sha
|
||||
})
|
||||
|
||||
canonical = Tarakan.Repo.get!(Tarakan.Scans.CanonicalFinding, canonical.id)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/explore")
|
||||
|
||||
assert has_element?(view, "#hot-findings", "No findings upvoted this week.")
|
||||
|
||||
voter = Tarakan.Accounts.Scope.for_account(account_fixture())
|
||||
{:ok, _summary} = Tarakan.Reputation.cast_vote(voter, "canonical_finding", canonical.id, 1)
|
||||
|
||||
assert has_element?(view, "#hot-finding-#{canonical.id}", "+1")
|
||||
|
||||
assert view
|
||||
|> element("#hot-finding-#{canonical.id}")
|
||||
|> render() =~ ~s(href="/findings/)
|
||||
end
|
||||
end
|
||||
112
test/tarakan_web/live/finding_discussion_test.exs
Normal file
112
test/tarakan_web/live/finding_discussion_test.exs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
defmodule TarakanWeb.FindingDiscussionTest do
|
||||
use TarakanWeb.ConnCase
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Tarakan.Accounts.Scope
|
||||
alias Tarakan.Discussion
|
||||
|
||||
setup do
|
||||
submitter = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
scan = scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
[finding] = scan.findings
|
||||
%{finding: %{finding | scan: scan}, repository: repository}
|
||||
end
|
||||
|
||||
test "anonymous visitors see the discussion but are prompted to sign in", %{
|
||||
conn: conn,
|
||||
finding: finding
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/findings/#{finding.public_id}")
|
||||
|
||||
assert has_element?(view, "#finding-discussion")
|
||||
assert has_element?(view, "#finding-comment-login")
|
||||
assert has_element?(view, "#finding-no-comments")
|
||||
refute has_element?(view, "#finding-comment-form")
|
||||
end
|
||||
|
||||
test "a signed-in account posts a comment", %{conn: conn, finding: finding} do
|
||||
conn = log_in_account(conn, account_fixture())
|
||||
{:ok, view, _html} = live(conn, ~p"/findings/#{finding.public_id}")
|
||||
|
||||
view
|
||||
|> form("#finding-comment-form", %{"body" => "Reproduced against the pinned commit."})
|
||||
|> render_submit()
|
||||
|
||||
assert has_element?(view, "#finding-comment-count", "1 comment")
|
||||
assert render(view) =~ "Reproduced against the pinned commit."
|
||||
refute has_element?(view, "#finding-no-comments")
|
||||
end
|
||||
|
||||
test "a reply nests under its parent", %{conn: conn, finding: finding} do
|
||||
author = account_fixture()
|
||||
|
||||
{:ok, parent} =
|
||||
Discussion.create_comment(Scope.for_account(author), finding, %{"body" => "Parent comment."})
|
||||
|
||||
conn = log_in_account(conn, account_fixture())
|
||||
{:ok, view, _html} = live(conn, ~p"/findings/#{finding.public_id}")
|
||||
|
||||
view |> element("#comment-#{parent.id} button", "Reply") |> render_click()
|
||||
|
||||
view
|
||||
|> form("#reply-form-#{parent.id}", %{"body" => "A threaded reply."})
|
||||
|> render_submit()
|
||||
|
||||
assert has_element?(view, "#comment-#{parent.id} #comment-#{last_comment_id()}")
|
||||
assert has_element?(view, "#finding-comment-count", "2 comments")
|
||||
end
|
||||
|
||||
test "a moderator can remove a comment and it shows as a placeholder", %{
|
||||
conn: conn,
|
||||
finding: finding
|
||||
} do
|
||||
author = account_fixture()
|
||||
|
||||
{:ok, comment} =
|
||||
Discussion.create_comment(Scope.for_account(author), finding, %{"body" => "Spammy comment."})
|
||||
|
||||
conn = log_in_account(conn, moderator_account_fixture())
|
||||
{:ok, view, _html} = live(conn, ~p"/findings/#{finding.public_id}")
|
||||
|
||||
assert has_element?(view, "#comment-#{comment.id} button", "Remove")
|
||||
|
||||
view
|
||||
|> element("#comment-#{comment.id} button", "Remove")
|
||||
|> render_click()
|
||||
|
||||
assert has_element?(view, "#comment-#{comment.id}", "Removed by moderation")
|
||||
end
|
||||
|
||||
test "restricted findings expose no discussion to anonymous visitors", %{
|
||||
conn: conn,
|
||||
repository: repository
|
||||
} do
|
||||
submitter = github_account_fixture(%{handle: "restricted-author"})
|
||||
scan = scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
[finding] = scan.findings
|
||||
|
||||
{:ok, _restricted} =
|
||||
Tarakan.Scans.update_visibility(
|
||||
Scope.for_account(moderator_account_fixture()),
|
||||
scan,
|
||||
"restricted",
|
||||
%{
|
||||
"moderation_reason" => "takedown_review",
|
||||
"moderation_notes" =>
|
||||
"Restricted in this test to confirm discussion follows finding disclosure."
|
||||
}
|
||||
)
|
||||
|
||||
assert_error_sent 404, fn -> get(conn, ~p"/findings/#{finding.public_id}") end
|
||||
end
|
||||
|
||||
defp last_comment_id do
|
||||
import Ecto.Query
|
||||
|
||||
Tarakan.Repo.one(
|
||||
from c in Tarakan.Discussion.Comment, order_by: [desc: c.id], limit: 1, select: c.id
|
||||
)
|
||||
end
|
||||
end
|
||||
420
test/tarakan_web/live/finding_live_test.exs
Normal file
420
test/tarakan_web/live/finding_live_test.exs
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
defmodule TarakanWeb.FindingLiveTest do
|
||||
use TarakanWeb.ConnCase
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
setup do
|
||||
submitter = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
%{repository: repository, submitter: submitter}
|
||||
end
|
||||
|
||||
test "renders a publicly disclosed finding", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
submitter: submitter
|
||||
} do
|
||||
scan =
|
||||
repository
|
||||
|> scan_fixture(submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
|> publish_scan("public")
|
||||
|
||||
[finding] = scan.findings
|
||||
|
||||
{:ok, view, html} = live(conn, ~p"/findings/#{finding.public_id}")
|
||||
|
||||
assert has_element?(view, "#finding-title", finding.title)
|
||||
assert has_element?(view, "#finding-severity", "high")
|
||||
assert has_element?(view, "#finding-description", "String-built SQL")
|
||||
assert has_element?(view, "#finding-canonical-memory", "detected in 1 run")
|
||||
assert has_element?(view, "#finding-source-link[href='/findings/#{finding.public_id}/code']")
|
||||
assert has_element?(view, "#finding-verified-badge")
|
||||
assert has_element?(view, "#finding-disclosure-badge")
|
||||
assert has_element?(view, "#finding-copy-link")
|
||||
assert has_element?(view, "#finding-cite", "/findings/#{finding.public_id}")
|
||||
assert has_element?(view, "#finding-verdict-counts", "2 confirmed · 0 disputed")
|
||||
assert html =~ scan.commit_sha
|
||||
assert html =~ "Public"
|
||||
assert has_element?(view, "#finding-record-link[href='/github.com/openai/codex/security']")
|
||||
end
|
||||
|
||||
test "shows the raw model report exactly as submitted", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
submitter: submitter
|
||||
} do
|
||||
scan = scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
[finding] = scan.findings
|
||||
|
||||
assert scan.raw_document == findings_json_fixture(1)
|
||||
|
||||
{:ok, view, html} = live(conn, ~p"/findings/#{finding.public_id}")
|
||||
|
||||
assert has_element?(view, "#finding-raw-report")
|
||||
assert html =~ "tarakan_scan_format"
|
||||
end
|
||||
|
||||
test "a qualified contributor records a verdict from the finding page", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
submitter: submitter
|
||||
} do
|
||||
scan = scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
[finding] = scan.findings
|
||||
|
||||
reviewer =
|
||||
account_fixture()
|
||||
|> Tarakan.Accounts.Account.authorization_changeset(%{
|
||||
state: "active",
|
||||
platform_role: "member",
|
||||
trust_tier: "reviewer"
|
||||
})
|
||||
|> Tarakan.Repo.update!()
|
||||
|
||||
conn = log_in_account(conn, reviewer)
|
||||
{:ok, view, _html} = live(conn, ~p"/findings/#{finding.public_id}")
|
||||
|
||||
assert has_element?(view, "#finding-verdict-counts", "0 confirmed · 0 disputed")
|
||||
|
||||
view
|
||||
|> form("#finding-verdict-form", %{
|
||||
"notes" => "Reproduced against the pinned commit with the documented payload."
|
||||
})
|
||||
|> render_submit(%{"verdict" => "confirmed"})
|
||||
|
||||
assert has_element?(view, "#finding-verdict-counts", "1 confirmed · 0 disputed")
|
||||
assert has_element?(view, "#finding-checks", "@#{reviewer.handle}")
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"#finding-checks",
|
||||
"Reproduced against the pinned commit with the documented payload."
|
||||
)
|
||||
|
||||
assert has_element?(view, "#finding-checks", "counts toward quorum")
|
||||
refute has_element?(view, "#finding-verdict-form")
|
||||
|
||||
{:ok, public_view, _html} =
|
||||
live(Phoenix.ConnTest.build_conn(), ~p"/findings/#{finding.public_id}")
|
||||
|
||||
assert has_element?(public_view, "#finding-checks", "@#{reviewer.handle}")
|
||||
assert has_element?(public_view, "#finding-checks", "documented payload")
|
||||
end
|
||||
|
||||
test "the submitter cannot verify their own finding", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
submitter: submitter
|
||||
} do
|
||||
scan = scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
[finding] = scan.findings
|
||||
|
||||
conn = log_in_account(conn, submitter)
|
||||
{:ok, view, _html} = live(conn, ~p"/findings/#{finding.public_id}")
|
||||
|
||||
refute has_element?(view, "#finding-verdict-form")
|
||||
end
|
||||
|
||||
test "the dead render carries the meta description and canonical URL", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
submitter: submitter
|
||||
} do
|
||||
scan =
|
||||
repository
|
||||
|> scan_fixture(submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
|> publish_scan("public")
|
||||
|
||||
[finding] = scan.findings
|
||||
|
||||
html = conn |> get(~p"/findings/#{finding.public_id}") |> html_response(200)
|
||||
|
||||
assert html =~ ~s(rel="canonical")
|
||||
assert html =~ "/findings/#{finding.public_id}"
|
||||
assert html =~ "High in openai/codex"
|
||||
assert get_resp_header(get(conn, ~p"/findings/#{finding.public_id}"), "x-robots-tag") == []
|
||||
end
|
||||
|
||||
test "fresh submissions resolve for anonymous visitors immediately", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
submitter: submitter
|
||||
} do
|
||||
scan = scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
[finding] = scan.findings
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/findings/#{finding.public_id}")
|
||||
|
||||
assert has_element?(view, "#finding-title", finding.title)
|
||||
refute has_element?(view, "#finding-verified-badge")
|
||||
end
|
||||
|
||||
test "restricted findings do not resolve for anonymous visitors", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
submitter: submitter
|
||||
} do
|
||||
scan = scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
[finding] = scan.findings
|
||||
|
||||
{:ok, _restricted} =
|
||||
Tarakan.Scans.update_visibility(
|
||||
Tarakan.Accounts.Scope.for_account(moderator_account_fixture()),
|
||||
scan,
|
||||
"restricted",
|
||||
%{
|
||||
"moderation_reason" => "takedown_review",
|
||||
"moderation_notes" =>
|
||||
"Deliberately restricted in this test to exercise the takedown boundary."
|
||||
}
|
||||
)
|
||||
|
||||
assert_error_sent 404, fn -> get(conn, ~p"/findings/#{finding.public_id}") end
|
||||
end
|
||||
|
||||
test "public-summary findings do not resolve for anonymous visitors", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
submitter: submitter
|
||||
} do
|
||||
scan = scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
[finding] = scan.findings
|
||||
publish_scan(scan, "public_summary")
|
||||
|
||||
assert_error_sent 404, fn -> get(conn, ~p"/findings/#{finding.public_id}") end
|
||||
end
|
||||
|
||||
test "the submitter can still open their own restricted finding", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
submitter: submitter
|
||||
} do
|
||||
scan = scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
[finding] = scan.findings
|
||||
|
||||
{:ok, _restricted} =
|
||||
Tarakan.Scans.update_visibility(
|
||||
Tarakan.Accounts.Scope.for_account(moderator_account_fixture()),
|
||||
scan,
|
||||
"restricted",
|
||||
%{
|
||||
"moderation_reason" => "takedown_review",
|
||||
"moderation_notes" =>
|
||||
"Deliberately restricted in this test to exercise submitter access."
|
||||
}
|
||||
)
|
||||
|
||||
conn = log_in_account(conn, submitter)
|
||||
{:ok, view, _html} = live(conn, ~p"/findings/#{finding.public_id}")
|
||||
|
||||
assert has_element?(view, "#finding-title", finding.title)
|
||||
end
|
||||
|
||||
test "the lifecycle timeline lists first-seen and check events", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
submitter: submitter
|
||||
} do
|
||||
scan = scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
[finding] = scan.findings
|
||||
|
||||
reviewer = reviewer_account_fixture()
|
||||
conn = log_in_account(conn, reviewer)
|
||||
{:ok, view, _html} = live(conn, ~p"/findings/#{finding.public_id}")
|
||||
|
||||
assert has_element?(view, "#finding-timeline-first-seen", "First seen")
|
||||
refute has_element?(view, "li[id^='finding-timeline-check-']")
|
||||
refute has_element?(view, "#finding-timeline-verified")
|
||||
refute has_element?(view, "#finding-timeline-vendor-notified")
|
||||
|
||||
view
|
||||
|> form("#finding-verdict-form", %{
|
||||
"notes" => "Reproduced against the pinned commit with the documented payload."
|
||||
})
|
||||
|> render_submit(%{"verdict" => "confirmed"})
|
||||
|
||||
assert has_element?(view, "#finding-timeline li[id^='finding-timeline-check-']")
|
||||
assert has_element?(view, "#finding-timeline", "@#{reviewer.handle}")
|
||||
assert has_element?(view, "#finding-timeline", "confirmed")
|
||||
end
|
||||
|
||||
test "the lifecycle timeline shows verified and vendor events when set", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
submitter: submitter
|
||||
} do
|
||||
scan =
|
||||
repository
|
||||
|> scan_fixture(submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
|> publish_scan("public")
|
||||
|
||||
[finding] = scan.findings
|
||||
|
||||
conn = log_in_account(conn, reviewer_account_fixture())
|
||||
{:ok, view, _html} = live(conn, ~p"/findings/#{finding.public_id}")
|
||||
|
||||
assert has_element?(view, "#finding-timeline-first-seen")
|
||||
assert has_element?(view, "#finding-timeline li[id^='finding-timeline-check-']")
|
||||
assert has_element?(view, "#finding-timeline-verified", "Verified")
|
||||
refute has_element?(view, "#finding-timeline-vendor-notified")
|
||||
|
||||
view
|
||||
|> form("#finding-vendor-form", %{
|
||||
"vendor_notification" => %{"notified_on" => "2026-01-15"}
|
||||
})
|
||||
|> render_submit()
|
||||
|
||||
assert has_element?(view, "#finding-timeline-vendor-notified", "Vendor notified")
|
||||
|
||||
canonical =
|
||||
Tarakan.Repo.get_by!(Tarakan.Scans.CanonicalFinding, repository_id: repository.id)
|
||||
|
||||
assert canonical.vendor_notified_at == ~U[2026-01-15 00:00:00.000000Z]
|
||||
|
||||
view
|
||||
|> form("#finding-vendor-form", %{"vendor_notification" => %{"notified_on" => ""}})
|
||||
|> render_submit()
|
||||
|
||||
refute has_element?(view, "#finding-timeline-vendor-notified")
|
||||
|
||||
assert Tarakan.Repo.get!(Tarakan.Scans.CanonicalFinding, canonical.id).vendor_notified_at ==
|
||||
nil
|
||||
end
|
||||
|
||||
test "CWE and CVE badges link out and disclosure metadata renders", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
submitter: submitter
|
||||
} do
|
||||
scan =
|
||||
scan_fixture(repository, submitter, %{
|
||||
"findings_json" => findings_json_with_disclosure_fixture()
|
||||
})
|
||||
|
||||
[finding] = scan.findings
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/findings/#{finding.public_id}")
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"#finding-cwe-badge[href='https://cwe.mitre.org/data/definitions/89.html']",
|
||||
"CWE-89"
|
||||
)
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"#finding-cve-badge[href='https://nvd.nist.gov/vuln/detail/CVE-2026-12345']",
|
||||
"CVE-2026-12345"
|
||||
)
|
||||
|
||||
assert has_element?(view, "#finding-reproduction", "Run the payload")
|
||||
assert has_element?(view, "#finding-affected-versions", ">= 1.0, < 2.0")
|
||||
end
|
||||
|
||||
test "CWE and CVE badges and disclosure metadata are absent when unset", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
submitter: submitter
|
||||
} do
|
||||
scan = scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
[finding] = scan.findings
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/findings/#{finding.public_id}")
|
||||
|
||||
refute has_element?(view, "#finding-cwe-badge")
|
||||
refute has_element?(view, "#finding-cve-badge")
|
||||
refute has_element?(view, "#finding-reproduction")
|
||||
refute has_element?(view, "#finding-affected-versions")
|
||||
end
|
||||
|
||||
test "the vendor notification form is hidden from ordinary members", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
submitter: submitter
|
||||
} do
|
||||
scan = scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
[finding] = scan.findings
|
||||
|
||||
{:ok, anonymous_view, _html} = live(conn, ~p"/findings/#{finding.public_id}")
|
||||
refute has_element?(anonymous_view, "#finding-vendor-form")
|
||||
|
||||
conn = log_in_account(conn, submitter)
|
||||
{:ok, view, _html} = live(conn, ~p"/findings/#{finding.public_id}")
|
||||
refute has_element?(view, "#finding-vendor-form")
|
||||
end
|
||||
|
||||
test "an unauthorized vendor notification event is rejected", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
submitter: submitter
|
||||
} do
|
||||
scan = scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
[finding] = scan.findings
|
||||
|
||||
conn = log_in_account(conn, submitter)
|
||||
{:ok, view, _html} = live(conn, ~p"/findings/#{finding.public_id}")
|
||||
|
||||
render_hook(view, "record_vendor_notification", %{
|
||||
"vendor_notification" => %{"notified_on" => "2026-01-15"}
|
||||
})
|
||||
|
||||
assert has_element?(view, "#flash-group", "not authorized")
|
||||
|
||||
canonical =
|
||||
Tarakan.Repo.get_by!(Tarakan.Scans.CanonicalFinding, repository_id: repository.id)
|
||||
|
||||
assert canonical.vendor_notified_at == nil
|
||||
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
|
||||
110
test/tarakan_web/live/finding_vote_test.exs
Normal file
110
test/tarakan_web/live/finding_vote_test.exs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
defmodule TarakanWeb.FindingVoteTest do
|
||||
use TarakanWeb.ConnCase
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Tarakan.Accounts.Scope
|
||||
alias Tarakan.Discussion
|
||||
|
||||
setup do
|
||||
submitter = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
scan = scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
[finding] = scan.findings
|
||||
%{finding: %{finding | scan: scan}, submitter: submitter}
|
||||
end
|
||||
|
||||
test "a signed-in visitor upvotes a finding and the score updates", %{
|
||||
conn: conn,
|
||||
finding: finding
|
||||
} do
|
||||
conn = log_in_account(conn, account_fixture())
|
||||
{:ok, view, _html} = live(conn, ~p"/findings/#{finding.public_id}")
|
||||
canonical_id = finding.canonical_finding.id
|
||||
|
||||
view
|
||||
|> element(~s(#vote-canonical_finding-#{canonical_id} button[phx-value-vote="1"]))
|
||||
|> render_click()
|
||||
|
||||
assert has_element?(view, "#vote-canonical_finding-#{canonical_id}", "1")
|
||||
end
|
||||
|
||||
test "the submitter cannot vote on their own finding", %{
|
||||
conn: conn,
|
||||
finding: finding,
|
||||
submitter: submitter
|
||||
} do
|
||||
conn = log_in_account(conn, submitter)
|
||||
{:ok, view, _html} = live(conn, ~p"/findings/#{finding.public_id}")
|
||||
canonical_id = finding.canonical_finding.id
|
||||
|
||||
html =
|
||||
view
|
||||
|> element(~s(#vote-canonical_finding-#{canonical_id} button[phx-value-vote="1"]))
|
||||
|> render_click()
|
||||
|
||||
assert html =~ "cannot vote on your own"
|
||||
assert has_element?(view, "#vote-canonical_finding-#{canonical_id}", "0")
|
||||
end
|
||||
|
||||
test "anonymous vote clicks are gated to login", %{conn: conn, finding: finding} do
|
||||
{:ok, view, _html} = live(conn, ~p"/findings/#{finding.public_id}")
|
||||
# Anonymous controls are disabled, so the buttons carry the disabled attr.
|
||||
assert has_element?(
|
||||
view,
|
||||
~s(#vote-canonical_finding-#{finding.canonical_finding.id} button[disabled])
|
||||
)
|
||||
end
|
||||
|
||||
test "a comment can be voted on", %{conn: conn, finding: finding} do
|
||||
{:ok, comment} =
|
||||
Discussion.create_comment(Scope.for_account(account_fixture()), finding, %{
|
||||
"body" => "This looks exploitable."
|
||||
})
|
||||
|
||||
conn = log_in_account(conn, account_fixture())
|
||||
{:ok, view, _html} = live(conn, ~p"/findings/#{finding.public_id}")
|
||||
|
||||
view
|
||||
|> element(~s(#vote-comment-#{comment.id} button[phx-value-vote="1"]))
|
||||
|> render_click()
|
||||
|
||||
assert has_element?(view, "#vote-comment-#{comment.id}", "1")
|
||||
end
|
||||
|
||||
test "malformed vote parameters are rejected without crashing", %{
|
||||
conn: conn,
|
||||
finding: finding
|
||||
} do
|
||||
conn = log_in_account(conn, account_fixture())
|
||||
{:ok, view, _html} = live(conn, ~p"/findings/#{finding.public_id}")
|
||||
|
||||
html =
|
||||
render_click(view, "vote", %{
|
||||
"type" => "canonical_finding",
|
||||
"id" => "1 OR 1=1",
|
||||
"vote" => "1"
|
||||
})
|
||||
|
||||
assert html =~ "could not be recorded"
|
||||
|
||||
html =
|
||||
render_click(view, "vote", %{
|
||||
"type" => "canonical_finding",
|
||||
"id" => "#{finding.canonical_finding.id}",
|
||||
"vote" => "1; DROP"
|
||||
})
|
||||
|
||||
assert html =~ "could not be recorded"
|
||||
assert has_element?(view, "#vote-canonical_finding-#{finding.canonical_finding.id}", "0")
|
||||
end
|
||||
|
||||
test "only the canonical finding on the security page carries a vote control", %{
|
||||
finding: finding
|
||||
} do
|
||||
conn = Phoenix.ConnTest.build_conn()
|
||||
{:ok, view, _html} = live(conn, ~p"/github.com/openai/codex/security")
|
||||
assert has_element?(view, "#vote-canonical_finding-#{finding.canonical_finding.id}")
|
||||
refute has_element?(view, "#vote-finding-#{finding.id}")
|
||||
end
|
||||
end
|
||||
180
test/tarakan_web/live/infestation_live_test.exs
Normal file
180
test/tarakan_web/live/infestation_live_test.exs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
defmodule TarakanWeb.InfestationLiveTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Tarakan.FindingMemory
|
||||
|
||||
test "index and show render multi-repo patterns", %{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 = "Live infestation #{System.unique_integer([:positive])}"
|
||||
|
||||
findings = fn path ->
|
||||
Jason.encode!(%{
|
||||
"tarakan_scan_format" => 1,
|
||||
"findings" => [
|
||||
%{
|
||||
"file" => path,
|
||||
"severity" => "critical",
|
||||
"title" => title,
|
||||
"description" => "Infestation liveview fixture."
|
||||
}
|
||||
]
|
||||
})
|
||||
end
|
||||
|
||||
scan_fixture(repo_a, submitter, %{"findings_json" => findings.("a.ex")})
|
||||
scan_fixture(repo_b, other, %{"findings_json" => findings.("b.ex")})
|
||||
# repo_a is hit twice by the same pattern at different paths, so the
|
||||
# per-repo rollup and the per-finding ledger must disagree on row count.
|
||||
scan_fixture(repo_a, submitter, %{"findings_json" => findings.("a2.ex")})
|
||||
|
||||
pattern = FindingMemory.pattern_key(title)
|
||||
|
||||
{:ok, index, html} = live(conn, ~p"/infestations")
|
||||
assert html =~ "Infestations"
|
||||
assert has_element?(index, "#infestations-constellation")
|
||||
assert has_element?(index, "#infestation-#{pattern}")
|
||||
|
||||
{:ok, show, show_html} = live(conn, ~p"/infestations/#{pattern}")
|
||||
assert show_html =~ title
|
||||
assert has_element?(show, "#infestation-repo-count", "2")
|
||||
assert has_element?(show, "#infestation-graph")
|
||||
assert has_element?(show, "#infestation-instances")
|
||||
|
||||
# The repo table is a rollup: one row per repository, carrying the counts
|
||||
# that distinguish it from the instance ledger below.
|
||||
graph_rows = show |> element("#infestation-graph tbody") |> render()
|
||||
assert graph_rows =~ "#{repo_a.owner}/#{repo_a.name}"
|
||||
assert graph_rows =~ "#{repo_b.owner}/#{repo_b.name}"
|
||||
|
||||
row_a = show |> element("#infestation-graph-node-#{repo_a.id}") |> render() |> squish()
|
||||
assert row_a =~ ">2</td>", "repo_a is hit twice and must report 2, not a blank cell"
|
||||
|
||||
row_b = show |> element("#infestation-graph-node-#{repo_b.id}") |> render() |> squish()
|
||||
assert row_b =~ ">1</td>", "repo_b is hit once and must report 1"
|
||||
|
||||
# 2 repository rows, 3 instance rows: the two views are different grains.
|
||||
ledger = show |> element("#infestation-ledger") |> render()
|
||||
assert length(Regex.scan(~r/<li[^>]+id="ledger_instances-/, ledger)) == 3
|
||||
end
|
||||
|
||||
describe "fix propagation" do
|
||||
setup %{conn: conn} do
|
||||
submitter = github_account_fixture()
|
||||
other = github_account_fixture()
|
||||
|
||||
source = listed_github_repository_fixture(submitter)
|
||||
{:ok, target} = Tarakan.Repositories.register_github_repository("acme/widget", other)
|
||||
target = listed_repository_fixture(target)
|
||||
|
||||
title = "Propagatable finding #{System.unique_integer([:positive])}"
|
||||
|
||||
findings =
|
||||
Jason.encode!(%{
|
||||
"tarakan_scan_format" => 1,
|
||||
"findings" => [
|
||||
%{
|
||||
"file" => "lib/handler.ex",
|
||||
"severity" => "critical",
|
||||
"title" => title,
|
||||
"description" => "Shared across both repositories."
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
source_scan = scan_fixture(source, submitter, %{"findings_json" => findings})
|
||||
scan_fixture(target, other, %{"findings_json" => findings})
|
||||
|
||||
# Settle the source instance as fixed so a template can be captured.
|
||||
[occurrence] = source_scan.findings
|
||||
|
||||
source_finding =
|
||||
Tarakan.Repo.get!(Tarakan.Scans.CanonicalFinding, occurrence.canonical_finding_id)
|
||||
|> Ecto.Changeset.change(%{
|
||||
status: "fixed",
|
||||
fixed_at: DateTime.utc_now(),
|
||||
fixed_commit_sha: source_scan.commit_sha
|
||||
})
|
||||
|> Tarakan.Repo.update!()
|
||||
|
||||
%Tarakan.Scans.FindingCheck{}
|
||||
|> Tarakan.Scans.FindingCheck.changeset(%{
|
||||
commit_sha: source_scan.commit_sha,
|
||||
verdict: "fixed",
|
||||
provenance: "human",
|
||||
notes: "Escaped the interpolated value and added a regression test."
|
||||
})
|
||||
|> Ecto.Changeset.put_change(:canonical_finding_id, source_finding.id)
|
||||
|> Ecto.Changeset.put_change(:account_id, submitter.id)
|
||||
|> Tarakan.Repo.insert!()
|
||||
|
||||
moderator = moderator_account_fixture()
|
||||
|
||||
{:ok, template} =
|
||||
Tarakan.Fixes.capture_fix(
|
||||
Tarakan.Accounts.Scope.for_account(moderator),
|
||||
source_finding
|
||||
)
|
||||
|
||||
%{
|
||||
conn: log_in_account(conn, moderator),
|
||||
pattern: FindingMemory.pattern_key(title),
|
||||
source: source,
|
||||
target: target,
|
||||
template: template
|
||||
}
|
||||
end
|
||||
|
||||
test "shows the captured fix and carries it to the remaining repository", %{
|
||||
conn: conn,
|
||||
pattern: pattern,
|
||||
source: source,
|
||||
target: target,
|
||||
template: template
|
||||
} do
|
||||
{:ok, view, html} = live(conn, ~p"/infestations/#{pattern}")
|
||||
|
||||
assert html =~ "Fixed on"
|
||||
assert html =~ "#{source.owner}/#{source.name}"
|
||||
assert html =~ "Escaped the interpolated value"
|
||||
assert has_element?(view, "#infestation-propagate-fix", "Propagate to 1")
|
||||
|
||||
html = view |> element("#infestation-propagate-fix") |> render_click()
|
||||
|
||||
assert html =~ "Opened 1 fix job"
|
||||
assert html =~ "carried to 1 repo"
|
||||
|
||||
# A real, public job now exists on the other repository, carrying the fix.
|
||||
# Scan submission auto-opens a verification job too, so reach the fix job
|
||||
# through the propagation rather than by repository alone.
|
||||
assert [propagation] = Tarakan.Fixes.list_propagations(template)
|
||||
assert propagation.repository_id == target.id
|
||||
|
||||
task = propagation.review_task
|
||||
assert task.status == "open"
|
||||
assert task.visibility == "public"
|
||||
assert task.description =~ "Escaped the interpolated value"
|
||||
end
|
||||
|
||||
test "anonymous visitors see the fix but cannot propagate it", %{pattern: pattern} do
|
||||
{:ok, view, html} = live(build_conn(), ~p"/infestations/#{pattern}")
|
||||
|
||||
assert html =~ "Fixed on"
|
||||
refute has_element?(view, "#infestation-propagate-fix")
|
||||
end
|
||||
end
|
||||
|
||||
# Collapse the whitespace HEEx leaves around interpolated cell values so
|
||||
# assertions can match on the value itself.
|
||||
defp squish(html) do
|
||||
html
|
||||
|> String.replace(~r/>\s+/, ">")
|
||||
|> String.replace(~r/\s+</, "<")
|
||||
end
|
||||
end
|
||||
59
test/tarakan_web/live/jobs_live_test.exs
Normal file
59
test/tarakan_web/live/jobs_live_test.exs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
defmodule TarakanWeb.JobsLiveTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Tarakan.WorkFixtures
|
||||
|
||||
test "renders the public jobs queue for anonymous visitors", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/jobs")
|
||||
|
||||
assert html =~ "Jobs"
|
||||
assert html =~ "tarakan login"
|
||||
assert html =~ "tarakan --agent kimi --pickup"
|
||||
assert html =~ "tarakan worker --agent kimi"
|
||||
end
|
||||
|
||||
test "lists open public jobs", %{conn: conn} do
|
||||
submitter = account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
|
||||
task =
|
||||
review_task_fixture(repository, submitter, %{
|
||||
"title" => "Review auth middleware",
|
||||
"visibility" => "public"
|
||||
})
|
||||
|
||||
{:ok, view, html} = live(conn, ~p"/jobs")
|
||||
|
||||
assert has_element?(view, "#job-#{task.id}")
|
||||
assert html =~ "Review auth middleware"
|
||||
end
|
||||
|
||||
test "check jobs surface before ordinary open jobs", %{conn: conn} do
|
||||
submitter = account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
|
||||
ordinary =
|
||||
review_task_fixture(repository, submitter, %{
|
||||
"title" => "Ordinary code review job",
|
||||
"kind" => "code_review",
|
||||
"visibility" => "public"
|
||||
})
|
||||
|
||||
# Publishing a Report with findings auto-opens a verify_findings Job.
|
||||
_scan =
|
||||
scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
|
||||
check =
|
||||
Tarakan.Work.list_open_public_tasks(20)
|
||||
|> Enum.find(&(&1.kind == "verify_findings" and &1.repository_id == repository.id))
|
||||
|
||||
assert check
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/jobs")
|
||||
|
||||
check_pos = :binary.match(html, "job-#{check.id}") |> elem(0)
|
||||
ordinary_pos = :binary.match(html, "job-#{ordinary.id}") |> elem(0)
|
||||
assert check_pos < ordinary_pos
|
||||
end
|
||||
end
|
||||
111
test/tarakan_web/live/leaderboard_live_test.exs
Normal file
111
test/tarakan_web/live/leaderboard_live_test.exs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
defmodule TarakanWeb.LeaderboardLiveTest do
|
||||
use TarakanWeb.ConnCase
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
defp verified_contributor do
|
||||
submitter = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
scan = scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
confirmation_fixture(scan, reviewer_account_fixture())
|
||||
confirmation_fixture(scan, reviewer_account_fixture())
|
||||
{submitter, repository}
|
||||
end
|
||||
|
||||
test "renders ranked contributors linking to their profiles", %{conn: conn} do
|
||||
{submitter, _repository} = verified_contributor()
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/leaderboard")
|
||||
|
||||
assert has_element?(view, "#leaderboard-rank-1")
|
||||
assert has_element?(view, ~s(#leaderboard a[href="/#{submitter.handle}"]))
|
||||
# The distinct verified canonical finding (+30) puts the submitter on top;
|
||||
# the report occurrence itself is not a second reputation event.
|
||||
assert has_element?(view, "#leaderboard-rank-1", submitter.handle)
|
||||
assert has_element?(view, "#leaderboard-rank-1", "30")
|
||||
end
|
||||
|
||||
test "the leaderboard can be re-sorted", %{conn: conn} do
|
||||
verified_contributor()
|
||||
{:ok, view, _html} = live(conn, ~p"/leaderboard")
|
||||
|
||||
html = view |> element("#leaderboard-sort button[phx-value-by='findings']") |> render_click()
|
||||
assert_patch(view, ~p"/leaderboard?sort=findings")
|
||||
assert html =~ ~s(aria-pressed="true")
|
||||
end
|
||||
|
||||
test "filter controls render and patching filters the entries", %{conn: conn} do
|
||||
{submitter, _repository} = verified_contributor()
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/leaderboard")
|
||||
|
||||
assert has_element?(view, "#leaderboard-severity")
|
||||
assert has_element?(view, "#leaderboard-window")
|
||||
assert has_element?(view, "#leaderboard-rank-1")
|
||||
|
||||
# The fixture's findings are "high"; filtering to critical empties the board.
|
||||
view |> element("#leaderboard-severity-form") |> render_change(%{"severity" => "critical"})
|
||||
assert_patch(view, ~p"/leaderboard?severity=critical")
|
||||
assert has_element?(view, "#leaderboard-empty")
|
||||
refute has_element?(view, "#leaderboard-rank-1")
|
||||
|
||||
# Clearing the severity brings the contributor back; the window tabs patch too.
|
||||
view |> element("#leaderboard-severity-form") |> render_change(%{"severity" => ""})
|
||||
assert has_element?(view, "#leaderboard-rank-1", submitter.handle)
|
||||
|
||||
view |> element("#leaderboard-window button[phx-value-window='7']") |> render_click()
|
||||
assert_patch(view, ~p"/leaderboard?window=7")
|
||||
assert has_element?(view, "#leaderboard-rank-1", submitter.handle)
|
||||
end
|
||||
|
||||
test "rows surface slashed stakes", %{conn: conn} do
|
||||
{submitter, repository} = verified_contributor()
|
||||
|
||||
# A second report (a distinct finding) is refuted by a dispute quorum.
|
||||
refuted =
|
||||
scan_fixture(repository, submitter, %{
|
||||
"findings_json" =>
|
||||
Jason.encode!(%{
|
||||
"tarakan_scan_format" => 1,
|
||||
"findings" => [
|
||||
%{
|
||||
"file" => "lib/example/other.ex",
|
||||
"severity" => "high",
|
||||
"title" => "Refuted finding #{System.unique_integer([:positive])}",
|
||||
"description" => "Slashed stake fixture."
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
confirmation_fixture(refuted, reviewer_account_fixture(), "disputed")
|
||||
confirmation_fixture(refuted, reviewer_account_fixture(), "disputed")
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/leaderboard")
|
||||
assert has_element?(view, "#leaderboard-rank-1", "1 slashed")
|
||||
end
|
||||
|
||||
test "an empty record shows the empty state", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/leaderboard")
|
||||
assert has_element?(view, "#leaderboard-empty")
|
||||
end
|
||||
|
||||
test "rows show earned badge chips after the handle", %{conn: conn} do
|
||||
{submitter, _repository} = verified_contributor()
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/leaderboard")
|
||||
|
||||
assert has_element?(view, "#leaderboard-rank-1-badge-first_blood", "First blood")
|
||||
refute has_element?(view, "#leaderboard-rank-1-badge-century")
|
||||
|
||||
# The reviewer accounts checked the report but earned no badges, so their
|
||||
# rows (if ranked) carry no chips.
|
||||
html = render(view)
|
||||
assert html =~ submitter.handle
|
||||
end
|
||||
|
||||
test "the header links to the leaderboard", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/")
|
||||
assert has_element?(view, ~s(a[href="/leaderboard"]))
|
||||
end
|
||||
end
|
||||
72
test/tarakan_web/live/model_analytics_live_test.exs
Normal file
72
test/tarakan_web/live/model_analytics_live_test.exs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
defmodule TarakanWeb.ModelAnalyticsLiveTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Tarakan.Repo
|
||||
alias Tarakan.Scans.CanonicalFinding
|
||||
|
||||
defp confirm_findings(scan) do
|
||||
for occurrence <- scan.findings do
|
||||
CanonicalFinding
|
||||
|> Repo.get!(occurrence.canonical_finding_id)
|
||||
|> Ecto.Changeset.change(status: "verified")
|
||||
|> Repo.update!()
|
||||
end
|
||||
end
|
||||
|
||||
test "says so plainly when nothing has been reported", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/models")
|
||||
|
||||
assert html =~ "No model has reported enough findings yet"
|
||||
end
|
||||
|
||||
# The scoreboard hides models below five reported findings, so both models
|
||||
# need a real body of work before they show up at all.
|
||||
defp unrelated_findings_json(count) do
|
||||
findings =
|
||||
for index <- 1..count do
|
||||
%{
|
||||
"file" => "lib/unrelated/module_#{index}.ex",
|
||||
"line_start" => index * 7,
|
||||
"line_end" => index * 7 + 2,
|
||||
"severity" => "low",
|
||||
"title" => "Unrelated observation (#{index})",
|
||||
"description" => "Something else entirely."
|
||||
}
|
||||
end
|
||||
|
||||
Jason.encode!(%{"tarakan_scan_format" => 1, "findings" => findings})
|
||||
end
|
||||
|
||||
test "scores models and lists the selected model's blind spots", %{conn: conn} do
|
||||
account = github_account_fixture()
|
||||
repository = github_repository_fixture(account)
|
||||
|
||||
found =
|
||||
scan_fixture(repository, account, %{
|
||||
"model" => "sharp-model",
|
||||
"findings_json" => findings_json_fixture(5)
|
||||
})
|
||||
|
||||
confirm_findings(found)
|
||||
|
||||
scan_fixture(repository, account, %{
|
||||
"model" => "blunt-model",
|
||||
"findings_json" => unrelated_findings_json(5)
|
||||
})
|
||||
|
||||
{:ok, view, html} = live(conn, ~p"/models")
|
||||
|
||||
assert html =~ "sharp-model"
|
||||
assert html =~ "blunt-model"
|
||||
|
||||
html =
|
||||
view
|
||||
|> element("#model-row-blunt-model")
|
||||
|> render_click()
|
||||
|
||||
assert html =~ "Blind spots"
|
||||
assert html =~ "Unsanitized input reaches interpolated query"
|
||||
end
|
||||
end
|
||||
207
test/tarakan_web/live/moderation_live_test.exs
Normal file
207
test/tarakan_web/live/moderation_live_test.exs
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
defmodule TarakanWeb.ModerationLiveTest do
|
||||
use TarakanWeb.ConnCase
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Tarakan.Accounts
|
||||
alias Tarakan.Accounts.Scope
|
||||
alias Tarakan.Moderation
|
||||
|
||||
test "reporting requires an authenticated account", %{conn: conn} do
|
||||
assert {:error, {:redirect, %{to: "/accounts/log-in"}}} =
|
||||
live(conn, ~p"/moderation/report")
|
||||
end
|
||||
|
||||
test "a participant submits a restricted report and can inspect its case", %{conn: conn} do
|
||||
reporter = account_fixture()
|
||||
subject = account_fixture()
|
||||
conn = log_in_account(conn, reporter)
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, ~p"/moderation/report?subject_type=account&subject_id=#{subject.id}")
|
||||
|
||||
assert has_element?(view, "#moderation-report-form")
|
||||
assert has_element?(view, "#header-report-content")
|
||||
refute has_element?(view, "#header-moderation-queue")
|
||||
|
||||
view
|
||||
|> form("#moderation-report-form",
|
||||
report: %{
|
||||
subject_type: "account",
|
||||
subject_id: subject.id,
|
||||
reason: "harassment",
|
||||
description: "This account is posting targeted harassment in public review evidence."
|
||||
}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
{path, flash} = assert_redirect(view)
|
||||
assert path =~ ~r{^/moderation/cases/\d+$}
|
||||
assert flash["info"]
|
||||
|
||||
{:ok, case_view, _html} = live(conn, path)
|
||||
|
||||
assert has_element?(case_view, "#moderation-case")
|
||||
assert has_element?(case_view, "#moderation-case-status", "Open")
|
||||
refute has_element?(case_view, "#moderation-case-actions")
|
||||
refute has_element?(case_view, "#moderation-case-assignment")
|
||||
end
|
||||
|
||||
test "an ordinary account cannot open the moderator queue", %{conn: conn} do
|
||||
conn = log_in_account(conn, account_fixture())
|
||||
|
||||
assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/moderation/queue")
|
||||
end
|
||||
|
||||
test "a moderator assigns and resolves a queued report", %{conn: conn} do
|
||||
case_record = report_account!(account_fixture(), account_fixture())
|
||||
moderator = moderator_account_fixture()
|
||||
conn = log_in_account(conn, moderator)
|
||||
|
||||
{:ok, queue_view, _html} = live(conn, ~p"/moderation/queue")
|
||||
|
||||
assert has_element?(queue_view, "#header-moderation-queue")
|
||||
assert has_element?(queue_view, "#moderation-case-count", "1 open")
|
||||
assert has_element?(queue_view, "#assign-moderation-case-#{case_record.id}")
|
||||
|
||||
queue_view
|
||||
|> element("#assign-moderation-case-#{case_record.id}")
|
||||
|> render_click()
|
||||
|
||||
assert has_element?(queue_view, "#cases-#{case_record.id}", "In review")
|
||||
|
||||
{:ok, case_view, _html} = live(conn, ~p"/moderation/cases/#{case_record.id}")
|
||||
assert has_element?(case_view, "#moderation-case-decision-form")
|
||||
|
||||
case_view
|
||||
|> form("#moderation-case-decision-form",
|
||||
resolution: %{
|
||||
reason: "The evidence was independently reviewed and confirms a policy violation."
|
||||
}
|
||||
)
|
||||
|> render_submit(%{"disposition" => "resolved"})
|
||||
|
||||
assert has_element?(case_view, "#moderation-case-status", "Resolved")
|
||||
assert has_element?(case_view, "#moderation-case-resolution")
|
||||
refute has_element?(case_view, "#moderation-case-decision-form")
|
||||
end
|
||||
|
||||
test "the subject can appeal without receiving participant identity data", %{conn: conn} do
|
||||
reporter = account_fixture()
|
||||
subject = account_fixture()
|
||||
resolver = moderator_account_fixture()
|
||||
|
||||
case_record =
|
||||
reporter
|
||||
|> report_account!(subject)
|
||||
|> resolve_case!(resolver, "resolved")
|
||||
|
||||
conn = log_in_account(conn, subject)
|
||||
{:ok, view, _html} = live(conn, ~p"/moderation/cases/#{case_record.id}")
|
||||
|
||||
assert has_element?(view, "#moderation-appeal-form")
|
||||
refute has_element?(view, "#moderation-case-actions")
|
||||
|
||||
view
|
||||
|> form("#moderation-appeal-form",
|
||||
appeal: %{
|
||||
reason: "The decision cites evidence from a different account and should be reconsidered."
|
||||
}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
assert has_element?(view, "#moderation-case-appeals")
|
||||
assert has_element?(view, "#moderation-case-appeals", "Open")
|
||||
refute has_element?(view, "#moderation-appeal-form")
|
||||
refute has_element?(view, "#moderation-case-actions")
|
||||
end
|
||||
|
||||
test "an independent moderator decides an appeal", %{conn: conn} do
|
||||
reporter = account_fixture()
|
||||
subject = account_fixture()
|
||||
resolver = moderator_account_fixture()
|
||||
appeal_moderator = moderator_account_fixture()
|
||||
|
||||
case_record =
|
||||
reporter
|
||||
|> report_account!(subject)
|
||||
|> resolve_case!(resolver, "resolved")
|
||||
|
||||
{:ok, appeal} =
|
||||
Moderation.appeal(Accounts.scope_for_account(subject), case_record, %{
|
||||
"reason" =>
|
||||
"The original decision relied on unrelated evidence and needs independent review."
|
||||
})
|
||||
|
||||
conn = log_in_account(conn, appeal_moderator)
|
||||
{:ok, view, _html} = live(conn, ~p"/moderation/cases/#{case_record.id}")
|
||||
|
||||
assert has_element?(view, "#moderation-appeal-decision-form-#{appeal.id}")
|
||||
|
||||
view
|
||||
|> form("#moderation-appeal-decision-form-#{appeal.id}",
|
||||
appeal_decision: %{
|
||||
appeal_id: appeal.id,
|
||||
reason: "Independent review confirms the original evidence was unrelated."
|
||||
}
|
||||
)
|
||||
|> render_submit(%{"decision" => "upheld"})
|
||||
|
||||
assert has_element?(view, "#moderation-case-status", "Overturned")
|
||||
assert has_element?(view, "#moderation-appeal-#{appeal.id}", "Upheld")
|
||||
refute has_element?(view, "#moderation-appeal-decision-form-#{appeal.id}")
|
||||
end
|
||||
|
||||
test "an expired sudo session redirects resolution to re-authentication", %{conn: conn} do
|
||||
case_record = report_account!(account_fixture(), account_fixture())
|
||||
moderator = moderator_account_fixture()
|
||||
|
||||
{:ok, assigned} = Moderation.assign(Scope.for_account(moderator), case_record)
|
||||
|
||||
# Outside the two-hour sudo window.
|
||||
stale_at = DateTime.add(DateTime.utc_now(), -3, :hour)
|
||||
conn = log_in_account(conn, moderator, token_authenticated_at: stale_at)
|
||||
{:ok, case_view, _html} = live(conn, ~p"/moderation/cases/#{assigned.id}")
|
||||
|
||||
assert has_element?(case_view, "#moderation-case-decision-form")
|
||||
|
||||
assert {:error,
|
||||
{:live_redirect, %{to: "/accounts/log-in?return_to=%2Fmoderation%2Fcases%2F" <> _}}} =
|
||||
case_view
|
||||
|> form("#moderation-case-decision-form",
|
||||
resolution: %{
|
||||
reason: "The evidence was independently reviewed and confirms a violation."
|
||||
}
|
||||
)
|
||||
|> render_submit(%{"disposition" => "resolved"})
|
||||
|
||||
{:ok, unresolved} = Moderation.get_case(Scope.for_account(moderator), assigned.id)
|
||||
assert unresolved.status == "in_review"
|
||||
end
|
||||
|
||||
defp report_account!(reporter, subject) do
|
||||
{:ok, case_record} =
|
||||
Moderation.report(Scope.for_account(reporter), %{
|
||||
"subject_type" => "account",
|
||||
"subject_id" => subject.id,
|
||||
"reason" => "fabricated_evidence",
|
||||
"description" => "The submitted evidence appears fabricated and needs independent review."
|
||||
})
|
||||
|
||||
case_record
|
||||
end
|
||||
|
||||
defp resolve_case!(case_record, moderator, disposition) do
|
||||
{:ok, assigned} = Moderation.assign(Scope.for_account(moderator), case_record)
|
||||
|
||||
{:ok, decided} =
|
||||
Moderation.resolve(
|
||||
Scope.for_account(moderator),
|
||||
assigned,
|
||||
disposition,
|
||||
"The evidence was independently reviewed and supports this moderation decision."
|
||||
)
|
||||
|
||||
decided
|
||||
end
|
||||
end
|
||||
98
test/tarakan_web/live/page_live_test.exs
Normal file
98
test/tarakan_web/live/page_live_test.exs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
defmodule TarakanWeb.PageLiveTest do
|
||||
use TarakanWeb.ConnCase
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
test "the disclosure policy page renders its sections", %{conn: conn} do
|
||||
html = conn |> get(~p"/policies/disclosure") |> html_response(200)
|
||||
|
||||
assert html =~ "Disclosure policy"
|
||||
assert html =~ "Public by default"
|
||||
assert html =~ "Visibility levels"
|
||||
assert html =~ "public_summary"
|
||||
assert html =~ "restricted"
|
||||
assert html =~ "Verification quorum"
|
||||
assert html =~ "Vendor notification"
|
||||
assert html =~ "security.txt"
|
||||
end
|
||||
|
||||
test "the content policy page renders its sections", %{conn: conn} do
|
||||
html = conn |> get(~p"/policies/content") |> html_response(200)
|
||||
|
||||
assert html =~ "Content policy"
|
||||
assert html =~ "Allowed content"
|
||||
assert html =~ "Prohibited content"
|
||||
assert html =~ "Secrets and credentials"
|
||||
assert html =~ "Takedowns and appeals"
|
||||
end
|
||||
|
||||
test "the pricing page renders the free record, bounties, and managed disclosure",
|
||||
%{conn: conn} do
|
||||
html = conn |> get(~p"/pricing") |> html_response(200)
|
||||
|
||||
assert html =~ ~s(id="pricing-free")
|
||||
assert html =~ ~s(id="pricing-contracts")
|
||||
assert html =~ ~s(id="pricing-managed")
|
||||
assert html =~ "Tarakan keeps 10%"
|
||||
assert html =~ "mailto:"
|
||||
|
||||
# No paid tiers, and the user-facing word is "contract", never "bounty".
|
||||
refute html =~ ~s(id="pricing-team")
|
||||
refute html =~ "Vaccine packs"
|
||||
refute html =~ "bounty"
|
||||
refute html =~ "Bounties"
|
||||
end
|
||||
|
||||
test "the pricing checkout POST redirects anonymous visitors to login", %{conn: conn} do
|
||||
conn = post(conn, ~p"/billing/checkout", %{"plan" => "team"})
|
||||
|
||||
assert redirected_to(conn) =~ "/accounts/log-in"
|
||||
end
|
||||
|
||||
test "the managed disclosure page renders its sections", %{conn: conn} do
|
||||
html = conn |> get(~p"/services/disclosure") |> html_response(200)
|
||||
|
||||
assert html =~ "Managed disclosure"
|
||||
assert html =~ "What it is"
|
||||
assert html =~ "Handling, not suppression"
|
||||
assert html =~ "Intake"
|
||||
assert html =~ ~s(id="managed-disclosure-contact")
|
||||
assert html =~ "mailto:"
|
||||
end
|
||||
|
||||
test "the pricing checkout POST redirects to Stripe for members", %{conn: conn} do
|
||||
conn =
|
||||
conn
|
||||
|> log_in_account(account_fixture())
|
||||
|> post(~p"/billing/checkout", %{"plan" => "enterprise"})
|
||||
|
||||
assert redirected_to(conn) =~ "https://checkout.stripe.com/"
|
||||
end
|
||||
|
||||
# These were plain controller pages, the only routes in the shell without a
|
||||
# LiveSocket. That made navigating to them a full reload and left them out of
|
||||
# anything driven by live navigation.
|
||||
test "the static pages are LiveViews", %{conn: conn} do
|
||||
for path <- ["/pricing", "/policies/disclosure", "/policies/content", "/services/disclosure"] do
|
||||
assert {:ok, _view, _html} = live(conn, path)
|
||||
end
|
||||
end
|
||||
|
||||
test "pricing marks its own nav entry active", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/pricing")
|
||||
|
||||
assert html =~ ~r/href="\/pricing"[^>]*aria-current="page"/
|
||||
end
|
||||
|
||||
test "nav links live-navigate between a LiveView and a policy page", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/leaderboard")
|
||||
|
||||
assert {:ok, _pricing, html} =
|
||||
view
|
||||
|> element(~s(nav[aria-label="Primary"] a[href="/pricing"]))
|
||||
|> render_click()
|
||||
|> follow_redirect(conn, ~p"/pricing")
|
||||
|
||||
assert html =~ "Pricing"
|
||||
end
|
||||
end
|
||||
101
test/tarakan_web/live/profile_live_test.exs
Normal file
101
test/tarakan_web/live/profile_live_test.exs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
defmodule TarakanWeb.ProfileLiveTest do
|
||||
use TarakanWeb.ConnCase
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
test "renders a contributor profile with public contribution counts", %{conn: conn} do
|
||||
submitter = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
scan = scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(2)})
|
||||
confirmation_fixture(scan, reviewer_account_fixture())
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/#{submitter.handle}")
|
||||
|
||||
assert has_element?(view, "#profile-handle", submitter.handle)
|
||||
assert has_element?(view, "#profile-review-count", "1")
|
||||
assert has_element?(view, "#profile-finding-count", "2")
|
||||
assert has_element?(view, "#profile-repo-count", "1")
|
||||
assert has_element?(view, "#profile-reviews", "reviewed")
|
||||
assert has_element?(view, "#profile-repositories", repository.name)
|
||||
assert has_element?(view, "#profile-findings")
|
||||
end
|
||||
|
||||
test "a moderator profile shows the role badge", %{conn: conn} do
|
||||
moderator = moderator_account_fixture()
|
||||
{:ok, view, _html} = live(conn, ~p"/#{moderator.handle}")
|
||||
assert has_element?(view, "#profile-role", "Moderator")
|
||||
end
|
||||
|
||||
test "an unknown handle returns not found", %{conn: conn} do
|
||||
assert_error_sent 404, fn -> get(conn, ~p"/#{"ghost-nobody"}") end
|
||||
end
|
||||
|
||||
test "a submitter handle on the security page links to the profile", %{conn: conn} do
|
||||
submitter = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/github.com/openai/codex/security")
|
||||
assert has_element?(view, ~s(a[href="/#{submitter.handle}"]))
|
||||
end
|
||||
|
||||
test "finding rows link to the finding page", %{conn: conn} do
|
||||
submitter = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
scan = scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
finding = hd(scan.findings || Tarakan.Repo.preload(scan, :findings).findings)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/#{submitter.handle}")
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
~s(#profile-finding-#{finding.public_id} a[href="/findings/#{finding.public_id}"])
|
||||
)
|
||||
end
|
||||
|
||||
test "renders the credits tile and the recent ledger", %{conn: conn} do
|
||||
account = account_fixture()
|
||||
|
||||
{:ok, _entry} =
|
||||
Tarakan.Credits.credit(account, :mint_finding_verified, {"canonical_finding", 1})
|
||||
|
||||
{:ok, _entry} = Tarakan.Credits.debit(account, :spend_priority_boost, nil, 20)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/#{account.handle}")
|
||||
|
||||
assert has_element?(view, "#profile-credit-balance", "30")
|
||||
assert has_element?(view, "#profile-credits", "mint finding verified")
|
||||
assert has_element?(view, "#profile-credits", "+50")
|
||||
assert has_element?(view, "#profile-credits", "-20")
|
||||
assert has_element?(view, "#profile-credits", "balance 30")
|
||||
end
|
||||
|
||||
test "renders an empty credits ledger", %{conn: conn} do
|
||||
account = account_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/#{account.handle}")
|
||||
|
||||
assert has_element?(view, "#profile-credit-balance", "0")
|
||||
assert has_element?(view, "#profile-no-credits")
|
||||
end
|
||||
|
||||
test "the badge strip renders earned badges only", %{conn: conn} do
|
||||
submitter = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(submitter)
|
||||
scan_fixture(repository, submitter, %{"findings_json" => findings_json_fixture(1)})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/#{submitter.handle}")
|
||||
|
||||
assert has_element?(view, "#profile-badges")
|
||||
assert has_element?(view, "#profile-badge-first_blood", "First blood")
|
||||
refute has_element?(view, "#profile-badge-century")
|
||||
refute has_element?(view, "#profile-badge-sharpshooter")
|
||||
end
|
||||
|
||||
test "a profile without earned badges renders no badge strip", %{conn: conn} do
|
||||
account = account_fixture()
|
||||
{:ok, view, _html} = live(conn, ~p"/#{account.handle}")
|
||||
|
||||
refute has_element?(view, "#profile-badges")
|
||||
end
|
||||
end
|
||||
411
test/tarakan_web/live/repository_code_live_test.exs
Normal file
411
test/tarakan_web/live/repository_code_live_test.exs
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
defmodule TarakanWeb.RepositoryCodeLiveTest do
|
||||
use TarakanWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Tarakan.Accounts.Scope
|
||||
alias Tarakan.RepositoryCode.Cache
|
||||
alias Tarakan.Scans
|
||||
|
||||
@default_commit_sha String.duplicate("7", 40)
|
||||
# develop branch tip in the GitHub stub (must remain a real branch tip).
|
||||
@develop_commit_sha String.duplicate("8", 40)
|
||||
# A fetchable commit that is not the tip of any published branch (and not a
|
||||
# special-cased stub SHA such as all-c truncated trees).
|
||||
@historical_commit_sha String.duplicate("b", 40)
|
||||
|
||||
setup do
|
||||
Cache.clear()
|
||||
repository = listed_github_repository_fixture()
|
||||
%{repository: repository}
|
||||
end
|
||||
|
||||
test "repository roots open the code browser by default", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/github.com/openai/codex")
|
||||
render_async(view, 1_000)
|
||||
|
||||
assert has_element?(view, "#repository-navigation")
|
||||
assert has_element?(view, "#repository-code-tab[aria-current='page']", "Code")
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"#repository-overview-tab[href='/github.com/openai/codex/security']"
|
||||
)
|
||||
|
||||
assert has_element?(view, "#code-tree")
|
||||
end
|
||||
|
||||
test "the legacy code entry route still opens the default tree", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/github.com/openai/codex/code")
|
||||
render_async(view, 1_000)
|
||||
|
||||
assert has_element?(view, "#code-tree")
|
||||
assert has_element?(view, "#code-commit-sha", String.slice(@default_commit_sha, 0, 7))
|
||||
end
|
||||
|
||||
test "browses directories at the freshly resolved default commit", %{conn: conn} do
|
||||
{:ok, view, _html} =
|
||||
live(conn, ~p"/github.com/openai/codex/code/#{@default_commit_sha}")
|
||||
|
||||
render_async(view, 1_000)
|
||||
|
||||
assert has_element?(view, "#code-tree")
|
||||
assert has_element?(view, "#code-tree-entries [id^='code-entry-']", "lib")
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"#code-tree-entries a[href='/github.com/openai/codex/code/#{@default_commit_sha}/README.md']"
|
||||
)
|
||||
end
|
||||
|
||||
test "round-trips encoded spaces, percent signs, fragments, and Unicode filenames", %{
|
||||
conn: conn
|
||||
} do
|
||||
root = "/github.com/openai/codex/code/#{@default_commit_sha}"
|
||||
{:ok, tree_view, _html} = live(conn, root)
|
||||
render_async(tree_view, 1_000)
|
||||
|
||||
assert has_element?(
|
||||
tree_view,
|
||||
"a[href='#{root}/space%20%23%20100%25.txt']",
|
||||
"space # 100%.txt"
|
||||
)
|
||||
|
||||
assert has_element?(tree_view, "a[href='#{root}/%CE%BB.ex']", "λ.ex")
|
||||
|
||||
{:ok, encoded_view, _html} = live(conn, root <> "/space%20%23%20100%25.txt")
|
||||
render_async(encoded_view, 1_000)
|
||||
assert has_element?(encoded_view, "#code-file", "space # 100%.txt")
|
||||
assert has_element?(encoded_view, "#L1 code", "# Codex")
|
||||
|
||||
{:ok, unicode_view, _html} = live(conn, root <> "/%CE%BB.ex")
|
||||
render_async(unicode_view, 1_000)
|
||||
assert has_element?(unicode_view, "#code-file", "λ.ex")
|
||||
assert has_element?(unicode_view, "#L1 code", "defmodule Codex")
|
||||
end
|
||||
|
||||
test "folder and file rows show findings for the pinned commit", %{
|
||||
conn: conn,
|
||||
repository: repository
|
||||
} do
|
||||
scan = finding_scan_fixture(repository, account_fixture(), @default_commit_sha)
|
||||
_scan = publish_full_scan(repository, scan)
|
||||
|
||||
{:ok, root_view, _html} = live(conn, ~p"/github.com/openai/codex")
|
||||
render_async(root_view, 1_000)
|
||||
|
||||
assert has_element?(root_view, "#code-entry-bGli-findings", "1 finding")
|
||||
assert has_element?(root_view, "#code-reference", "1 finding")
|
||||
|
||||
{:ok, lib_view, _html} =
|
||||
live(conn, ~p"/github.com/openai/codex/code/#{@default_commit_sha}/lib")
|
||||
|
||||
render_async(lib_view, 1_000)
|
||||
|
||||
assert has_element?(lib_view, "#code-entry-bGliL2NvZGV4LmV4-findings", "1 finding")
|
||||
end
|
||||
|
||||
test "public summaries do not leak file paths through code-row badges", %{
|
||||
conn: conn,
|
||||
repository: repository
|
||||
} do
|
||||
scan = finding_scan_fixture(repository, account_fixture(), @default_commit_sha)
|
||||
scan = confirmation_fixture(scan, reviewer_account_fixture())
|
||||
scan = confirmation_fixture(scan, reviewer_account_fixture())
|
||||
moderator_scope = Scope.for_account(moderator_account_fixture())
|
||||
|
||||
{:ok, accepted} = Scans.accept_scan(moderator_scope, scan, moderation_attributes())
|
||||
|
||||
{:ok, _summary} =
|
||||
Scans.update_visibility(
|
||||
moderator_scope,
|
||||
accepted,
|
||||
"public_summary",
|
||||
moderation_attributes()
|
||||
)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/github.com/openai/codex")
|
||||
render_async(view, 1_000)
|
||||
html = render(view)
|
||||
|
||||
refute has_element?(view, "[id$='-findings']")
|
||||
refute html =~ "lib/codex.ex"
|
||||
end
|
||||
|
||||
test "renders escaped plain-text lines with stable anchors and selection", %{conn: conn} do
|
||||
{:ok, view, _html} =
|
||||
live(
|
||||
conn,
|
||||
~p"/github.com/openai/codex/code/#{@default_commit_sha}/README.md?lines=1"
|
||||
)
|
||||
|
||||
render_async(view, 1_000)
|
||||
|
||||
assert has_element?(view, "#code-file")
|
||||
assert has_element?(view, "#L1.bg-panel")
|
||||
assert has_element?(view, "#L1 code", "# Codex")
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"#L1 a[href='/github.com/openai/codex/code/#{@default_commit_sha}/README.md?lines=1#L1']"
|
||||
)
|
||||
end
|
||||
|
||||
test "rejects an out-of-range single-line selection", %{conn: conn} do
|
||||
{:ok, view, _html} =
|
||||
live(
|
||||
conn,
|
||||
~p"/github.com/openai/codex/code/#{@default_commit_sha}/README.md?lines=1000001"
|
||||
)
|
||||
|
||||
render_async(view, 1_000)
|
||||
|
||||
assert has_element?(view, "#invalid-line-selection")
|
||||
refute has_element?(view, "#code-lines .bg-panel")
|
||||
end
|
||||
|
||||
test "renders hostile source as escaped text rather than executable markup", %{conn: conn} do
|
||||
{:ok, view, _html} =
|
||||
live(
|
||||
conn,
|
||||
~p"/github.com/openai/codex/code/#{@default_commit_sha}/unsafe.html"
|
||||
)
|
||||
|
||||
render_async(view, 1_000)
|
||||
html = render(view)
|
||||
|
||||
assert has_element?(view, "#L1 code", "</code><script id=\"source-xss\">alert(1)</script>")
|
||||
refute has_element?(view, "script#source-xss")
|
||||
assert html =~ "</code><script id="source-xss">"
|
||||
end
|
||||
|
||||
test "rejects guessed historical SHAs on the generic browser", %{conn: conn} do
|
||||
paths = [
|
||||
~p"/github.com/openai/codex/code/#{@historical_commit_sha}",
|
||||
~p"/github.com/openai/codex/code/#{@historical_commit_sha}/README.md"
|
||||
]
|
||||
|
||||
for path <- paths do
|
||||
{:ok, view, _html} = live(conn, path)
|
||||
render_async(view, 1_000)
|
||||
|
||||
assert has_element?(view, "#code-browser-error")
|
||||
assert has_element?(view, "#code-browser-error", "Source not found")
|
||||
refute has_element?(view, "#code-tree")
|
||||
refute has_element?(view, "#code-file")
|
||||
end
|
||||
end
|
||||
|
||||
test "branch switch opens another published branch tip", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/github.com/openai/codex/code/#{@default_commit_sha}")
|
||||
render_async(view, 1_000)
|
||||
|
||||
assert has_element?(view, "#code-branch-select")
|
||||
assert has_element?(view, "#code-tree")
|
||||
|
||||
view
|
||||
|> form("#code-branch-form", %{branch: "develop"})
|
||||
|> render_change()
|
||||
|
||||
path = ~p"/github.com/openai/codex/code/#{@develop_commit_sha}"
|
||||
assert_redirect(view, path)
|
||||
|
||||
{:ok, switched, _html} = live(conn, path)
|
||||
render_async(switched, 1_000)
|
||||
|
||||
assert has_element?(switched, "#code-tree")
|
||||
assert has_element?(switched, "#code-commit-sha", String.slice(@develop_commit_sha, 0, 7))
|
||||
assert has_element?(switched, ~s(#code-branch-select option[value="develop"][selected]))
|
||||
end
|
||||
|
||||
test "deep link to a non-default branch tip works", %{conn: conn} do
|
||||
{:ok, view, _html} =
|
||||
live(conn, ~p"/github.com/openai/codex/code/#{@develop_commit_sha}/README.md")
|
||||
|
||||
render_async(view, 1_000)
|
||||
|
||||
assert has_element?(view, "#code-file")
|
||||
assert has_element?(view, "#code-commit-sha", String.slice(@develop_commit_sha, 0, 7))
|
||||
assert has_element?(view, ~s(#code-branch-select option[value="develop"][selected]))
|
||||
end
|
||||
|
||||
test "rejects encoded traversal and backslash paths at the route boundary", %{conn: conn} do
|
||||
unsafe_paths = [
|
||||
"/github.com/openai/codex/code/#{@default_commit_sha}/lib/%2E%2E/README.md",
|
||||
"/github.com/openai/codex/code/#{@default_commit_sha}/lib/%5C..%5Csecret"
|
||||
]
|
||||
|
||||
for path <- unsafe_paths do
|
||||
{:ok, view, _html} = live(conn, path)
|
||||
|
||||
assert has_element?(view, "#code-browser-error", "Invalid path")
|
||||
refute has_element?(view, "#code-file")
|
||||
end
|
||||
end
|
||||
|
||||
test "an authorized opaque finding route opens historical source without leaking path in links",
|
||||
%{
|
||||
conn: conn,
|
||||
repository: repository
|
||||
} do
|
||||
submitter = account_fixture()
|
||||
scan = finding_scan_fixture(repository, submitter, @historical_commit_sha)
|
||||
finding = hd(scan.findings)
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(log_in_account(conn, submitter), ~p"/findings/#{finding.public_id}/code")
|
||||
|
||||
render_async(view, 1_000)
|
||||
|
||||
assert has_element?(view, "#finding-code-context")
|
||||
assert has_element?(view, "#code-file")
|
||||
assert has_element?(view, "#L2.bg-panel")
|
||||
refute has_element?(view, "#source-breadcrumbs a")
|
||||
assert has_element?(view, "#L2 a[href='/findings/#{finding.public_id}/code#L2']")
|
||||
end
|
||||
|
||||
test "the opaque route does not reveal a moderator-restricted finding to anonymous visitors",
|
||||
%{
|
||||
conn: conn,
|
||||
repository: repository
|
||||
} do
|
||||
scan = finding_scan_fixture(repository, account_fixture(), @historical_commit_sha)
|
||||
finding = hd(scan.findings)
|
||||
|
||||
moderator_scope = Scope.for_account(moderator_account_fixture())
|
||||
|
||||
{:ok, _restricted} =
|
||||
Scans.update_visibility(moderator_scope, scan, "restricted", moderation_attributes())
|
||||
|
||||
assert_raise Ecto.NoResultsError, fn ->
|
||||
live(conn, ~p"/findings/#{finding.public_id}/code")
|
||||
end
|
||||
end
|
||||
|
||||
test "marks a reported line range that does not exist at the pinned commit", %{
|
||||
conn: conn,
|
||||
repository: repository
|
||||
} do
|
||||
submitter = account_fixture()
|
||||
scan = finding_scan_fixture(repository, submitter, @historical_commit_sha, 99)
|
||||
finding = hd(scan.findings)
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(log_in_account(conn, submitter), ~p"/findings/#{finding.public_id}/code")
|
||||
|
||||
render_async(view, 1_000)
|
||||
|
||||
assert has_element?(view, "#finding-line-outside-file")
|
||||
refute has_element?(view, "#code-lines .bg-panel")
|
||||
end
|
||||
|
||||
test "finding source routes reject enumerable integer identifiers", %{conn: conn} do
|
||||
assert_raise Ecto.NoResultsError, fn ->
|
||||
live(conn, "/findings/1/code")
|
||||
end
|
||||
end
|
||||
|
||||
test "a connected finding view is evicted when a moderator restricts the scan", %{
|
||||
conn: conn,
|
||||
repository: repository
|
||||
} do
|
||||
scan = finding_scan_fixture(repository, account_fixture(), @historical_commit_sha)
|
||||
scan = publish_full_scan(repository, scan)
|
||||
finding = hd(scan.findings)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/findings/#{finding.public_id}/code")
|
||||
render_async(view, 1_000)
|
||||
assert has_element?(view, "#code-file")
|
||||
|
||||
moderator_scope = Scope.for_account(moderator_account_fixture())
|
||||
|
||||
assert {:ok, _restricted} =
|
||||
Scans.update_visibility(moderator_scope, scan, "restricted", moderation_attributes())
|
||||
|
||||
assert_redirect(view, ~p"/github.com/openai/codex")
|
||||
end
|
||||
|
||||
test "the default code page is uncached but remains indexable", %{conn: conn} do
|
||||
conn = get(conn, ~p"/github.com/openai/codex")
|
||||
|
||||
assert html_response(conn, 200)
|
||||
assert get_resp_header(conn, "cache-control") == ["no-store"]
|
||||
assert get_resp_header(conn, "x-robots-tag") == []
|
||||
end
|
||||
|
||||
test "commit-pinned code routes prohibit caches and search indexing", %{conn: conn} do
|
||||
conn = get(conn, ~p"/github.com/openai/codex/code/#{@default_commit_sha}")
|
||||
|
||||
assert html_response(conn, 200)
|
||||
assert get_resp_header(conn, "cache-control") == ["no-store"]
|
||||
assert get_resp_header(conn, "x-robots-tag") == ["noindex, nofollow"]
|
||||
end
|
||||
|
||||
test "opaque finding routes prohibit caches and search indexing", %{
|
||||
conn: conn,
|
||||
repository: repository
|
||||
} do
|
||||
scan = finding_scan_fixture(repository, account_fixture(), @historical_commit_sha)
|
||||
finding = repository |> publish_full_scan(scan) |> Map.fetch!(:findings) |> hd()
|
||||
|
||||
conn = get(conn, ~p"/findings/#{finding.public_id}/code")
|
||||
|
||||
assert html_response(conn, 200)
|
||||
assert get_resp_header(conn, "cache-control") == ["no-store"]
|
||||
assert get_resp_header(conn, "x-robots-tag") == ["noindex, nofollow"]
|
||||
end
|
||||
|
||||
defp finding_scan_fixture(repository, submitter, commit_sha, line_start \\ 2) do
|
||||
document =
|
||||
Jason.encode!(%{
|
||||
"tarakan_scan_format" => 1,
|
||||
"findings" => [
|
||||
%{
|
||||
"file" => "lib/codex.ex",
|
||||
"line_start" => line_start,
|
||||
"line_end" => line_start,
|
||||
"severity" => "high",
|
||||
"title" => "Authorization is not checked",
|
||||
"description" => "A request reaches a privileged operation without a policy check."
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
scan_fixture(repository, submitter, %{
|
||||
"commit_sha" => commit_sha,
|
||||
"findings_json" => document
|
||||
})
|
||||
end
|
||||
|
||||
defp publish_full_scan(repository, scan) do
|
||||
repository
|
||||
|> Tarakan.Repositories.Repository.participation_changeset(%{
|
||||
participation_mode: "maintainer_verified"
|
||||
})
|
||||
|> Tarakan.Repo.update!()
|
||||
|
||||
scan = confirmation_fixture(scan, reviewer_account_fixture())
|
||||
scan = confirmation_fixture(scan, reviewer_account_fixture())
|
||||
moderator_scope = Scope.for_account(moderator_account_fixture())
|
||||
|
||||
{:ok, accepted} = Scans.accept_scan(moderator_scope, scan, moderation_attributes())
|
||||
|
||||
{:ok, disclosed} =
|
||||
Scans.update_visibility(
|
||||
moderator_scope,
|
||||
accepted,
|
||||
"public",
|
||||
moderation_attributes(%{"sensitive_data_reviewed" => "true"})
|
||||
)
|
||||
|
||||
disclosed
|
||||
end
|
||||
|
||||
defp moderation_attributes(overrides \\ %{}) do
|
||||
Enum.into(overrides, %{
|
||||
"moderation_reason" => "evidence_reviewed",
|
||||
"moderation_notes" =>
|
||||
"Independent evidence and disclosure boundaries were reviewed for this exact commit."
|
||||
})
|
||||
end
|
||||
end
|
||||
273
test/tarakan_web/live/repository_commits_live_test.exs
Normal file
273
test/tarakan_web/live/repository_commits_live_test.exs
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
defmodule TarakanWeb.RepositoryCommitsLiveTest do
|
||||
use TarakanWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Tarakan.Accounts
|
||||
alias Tarakan.HostedRepositories
|
||||
alias Tarakan.HostedRepositories.Storage
|
||||
|
||||
@moduletag :tmp_dir
|
||||
|
||||
setup %{tmp_dir: tmp_dir} do
|
||||
on_exit(fn ->
|
||||
File.rm_rf(Application.fetch_env!(:tarakan, Tarakan.HostedRepositories)[:root])
|
||||
end)
|
||||
|
||||
Tarakan.RepositoryCode.Cache.clear()
|
||||
|
||||
account = account_fixture()
|
||||
scope = Accounts.scope_for_account(account)
|
||||
{:ok, repository} = HostedRepositories.create(scope, %{"name" => "history"})
|
||||
|
||||
%{repository: repository, tmp_dir: tmp_dir}
|
||||
end
|
||||
|
||||
defp push_commit(repository, tmp_dir, dir_name, message) do
|
||||
work = Path.join(tmp_dir, dir_name)
|
||||
|
||||
{_output, 0} =
|
||||
System.cmd("git", ["clone", "--quiet", Storage.dir(repository), work],
|
||||
stderr_to_stdout: true
|
||||
)
|
||||
|
||||
File.write!(Path.join(work, "README.md"), "# #{message}\n")
|
||||
|
||||
env = [
|
||||
{"GIT_AUTHOR_NAME", "t"},
|
||||
{"GIT_AUTHOR_EMAIL", "t@example.com"},
|
||||
{"GIT_COMMITTER_NAME", "t"},
|
||||
{"GIT_COMMITTER_EMAIL", "t@example.com"}
|
||||
]
|
||||
|
||||
{_output, 0} = System.cmd("git", ["-C", work, "add", "."], env: env)
|
||||
{_output, 0} = System.cmd("git", ["-C", work, "commit", "--quiet", "-m", message], env: env)
|
||||
|
||||
{_output, 0} =
|
||||
System.cmd("git", ["-C", work, "push", "--quiet", "origin", "HEAD"],
|
||||
env: env,
|
||||
stderr_to_stdout: true
|
||||
)
|
||||
|
||||
{sha, 0} = System.cmd("git", ["-C", work, "rev-parse", "HEAD"])
|
||||
String.trim(sha)
|
||||
end
|
||||
|
||||
defp push_merge_commit(repository, tmp_dir, dir_name, branch, message) do
|
||||
work = Path.join(tmp_dir, dir_name)
|
||||
|
||||
{_output, 0} =
|
||||
System.cmd("git", ["clone", "--quiet", Storage.dir(repository), work],
|
||||
stderr_to_stdout: true
|
||||
)
|
||||
|
||||
env = [
|
||||
{"GIT_AUTHOR_NAME", "t"},
|
||||
{"GIT_AUTHOR_EMAIL", "t@example.com"},
|
||||
{"GIT_COMMITTER_NAME", "t"},
|
||||
{"GIT_COMMITTER_EMAIL", "t@example.com"}
|
||||
]
|
||||
|
||||
{main, 0} = System.cmd("git", ["-C", work, "rev-parse", "--abbrev-ref", "HEAD"])
|
||||
main = String.trim(main)
|
||||
|
||||
{_output, 0} = System.cmd("git", ["-C", work, "checkout", "--quiet", "-b", branch], env: env)
|
||||
File.write!(Path.join(work, "side.txt"), "side\n")
|
||||
{_output, 0} = System.cmd("git", ["-C", work, "add", "."], env: env)
|
||||
|
||||
{_output, 0} =
|
||||
System.cmd("git", ["-C", work, "commit", "--quiet", "-m", "side change"], env: env)
|
||||
|
||||
{_output, 0} = System.cmd("git", ["-C", work, "checkout", "--quiet", main], env: env)
|
||||
File.write!(Path.join(work, "main.txt"), "main\n")
|
||||
{_output, 0} = System.cmd("git", ["-C", work, "add", "."], env: env)
|
||||
|
||||
{_output, 0} =
|
||||
System.cmd("git", ["-C", work, "commit", "--quiet", "-m", "main change"], env: env)
|
||||
|
||||
{_output, 0} =
|
||||
System.cmd("git", ["-C", work, "merge", "--quiet", "--no-ff", "-m", message, branch],
|
||||
env: env,
|
||||
stderr_to_stdout: true
|
||||
)
|
||||
|
||||
{_output, 0} =
|
||||
System.cmd("git", ["-C", work, "push", "--quiet", "origin", "HEAD"],
|
||||
env: env,
|
||||
stderr_to_stdout: true
|
||||
)
|
||||
|
||||
{sha, 0} = System.cmd("git", ["-C", work, "rev-parse", "HEAD"])
|
||||
String.trim(sha)
|
||||
end
|
||||
|
||||
defp push_branch_commit(repository, tmp_dir, dir_name, branch, message) do
|
||||
work = Path.join(tmp_dir, dir_name)
|
||||
|
||||
{_output, 0} =
|
||||
System.cmd("git", ["clone", "--quiet", Storage.dir(repository), work],
|
||||
stderr_to_stdout: true
|
||||
)
|
||||
|
||||
File.write!(Path.join(work, "FEATURE.md"), "# #{message}\n")
|
||||
|
||||
env = [
|
||||
{"GIT_AUTHOR_NAME", "t"},
|
||||
{"GIT_AUTHOR_EMAIL", "t@example.com"},
|
||||
{"GIT_COMMITTER_NAME", "t"},
|
||||
{"GIT_COMMITTER_EMAIL", "t@example.com"}
|
||||
]
|
||||
|
||||
{_output, 0} = System.cmd("git", ["-C", work, "checkout", "--quiet", "-b", branch], env: env)
|
||||
{_output, 0} = System.cmd("git", ["-C", work, "add", "."], env: env)
|
||||
{_output, 0} = System.cmd("git", ["-C", work, "commit", "--quiet", "-m", message], env: env)
|
||||
|
||||
{_output, 0} =
|
||||
System.cmd("git", ["-C", work, "push", "--quiet", "origin", branch],
|
||||
env: env,
|
||||
stderr_to_stdout: true
|
||||
)
|
||||
|
||||
{sha, 0} = System.cmd("git", ["-C", work, "rev-parse", "HEAD"])
|
||||
String.trim(sha)
|
||||
end
|
||||
|
||||
test "lists the default branch history and links each commit to its detail page", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
tmp_dir: tmp_dir
|
||||
} do
|
||||
first_sha = push_commit(repository, tmp_dir, "work-first", "initial")
|
||||
tip = push_commit(repository, tmp_dir, "work-second", "second")
|
||||
|
||||
{:ok, view, _html} = live(conn, "/#{repository.owner}/#{repository.name}/commits")
|
||||
render_async(view, 1_000)
|
||||
|
||||
assert has_element?(view, "#repository-commits-tab[aria-current='page']")
|
||||
|
||||
# The active tab is still a link: from a commit detail page it points at the
|
||||
# commit list, which is the obvious way back and used to be unclickable.
|
||||
assert has_element?(view, "a#repository-commits-tab[aria-current='page'][href]")
|
||||
assert has_element?(view, "#commits-list a", "second")
|
||||
assert has_element?(view, "#commits-list a", "initial")
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"#commits-list a[href='/#{repository.owner}/#{repository.name}/commits/#{tip}']"
|
||||
)
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"#commits-list a[href='/#{repository.owner}/#{repository.name}/commits/#{first_sha}']"
|
||||
)
|
||||
|
||||
refute has_element?(view, "#commits-load-more")
|
||||
end
|
||||
|
||||
test "the commit detail page shows the full message, patch, and code link", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
tmp_dir: tmp_dir
|
||||
} do
|
||||
_first_sha = push_commit(repository, tmp_dir, "work-first", "initial")
|
||||
tip = push_commit(repository, tmp_dir, "work-second", "second")
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/#{repository.owner}/#{repository.name}/commits/#{tip}")
|
||||
|
||||
render_async(view, 1_000)
|
||||
|
||||
assert has_element?(view, "#commit-subject", "second")
|
||||
assert has_element?(view, "#commit-detail-sha", String.slice(tip, 0, 7))
|
||||
assert has_element?(view, "#commit-patch", "+# second")
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"#commit-browse-code[href='/#{repository.owner}/#{repository.name}/code/#{tip}']"
|
||||
)
|
||||
|
||||
# The code link opens the browser pinned at this exact commit.
|
||||
{:ok, code_view, _html} = live(conn, "/#{repository.owner}/#{repository.name}/code/#{tip}")
|
||||
render_async(code_view, 1_000)
|
||||
assert has_element?(code_view, "#code-tree")
|
||||
end
|
||||
|
||||
test "merge commits render with an empty patch state", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
tmp_dir: tmp_dir
|
||||
} do
|
||||
_main_sha = push_commit(repository, tmp_dir, "work-first", "initial")
|
||||
merge_sha = push_merge_commit(repository, tmp_dir, "work-merge", "side", "merge side")
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/#{repository.owner}/#{repository.name}/commits/#{merge_sha}")
|
||||
|
||||
render_async(view, 1_000)
|
||||
|
||||
assert has_element?(view, "#commit-subject", "merge side")
|
||||
assert has_element?(view, "#commit-patch-empty", "No textual changes")
|
||||
refute has_element?(view, "#commits-error")
|
||||
end
|
||||
|
||||
test "the commit detail page rejects unreachable commits", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
tmp_dir: tmp_dir
|
||||
} do
|
||||
_sha = push_commit(repository, tmp_dir, "work-first", "initial")
|
||||
unknown = String.duplicate("d", 40)
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/#{repository.owner}/#{repository.name}/commits/#{unknown}")
|
||||
|
||||
render_async(view, 1_000)
|
||||
|
||||
assert has_element?(view, "#commits-error", "Commit not found")
|
||||
refute has_element?(view, "#commit-detail")
|
||||
end
|
||||
|
||||
test "the branch picker switches the listed history", %{
|
||||
conn: conn,
|
||||
repository: repository,
|
||||
tmp_dir: tmp_dir
|
||||
} do
|
||||
_main_sha = push_commit(repository, tmp_dir, "work-first", "initial")
|
||||
|
||||
feature_sha =
|
||||
push_branch_commit(repository, tmp_dir, "work-feature", "feature", "feature work")
|
||||
|
||||
{:ok, view, _html} = live(conn, "/#{repository.owner}/#{repository.name}/commits")
|
||||
render_async(view, 1_000)
|
||||
|
||||
assert has_element?(view, "#commits-branch-select")
|
||||
refute has_element?(view, "#commits-list a", "feature work")
|
||||
|
||||
view
|
||||
|> form("#commits-branch-form", %{branch: "feature"})
|
||||
|> render_change()
|
||||
|
||||
render_async(view, 1_000)
|
||||
|
||||
assert has_element?(view, "#commits-list a", "feature work")
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"#commits-list a[href='/#{repository.owner}/#{repository.name}/commits/#{feature_sha}']"
|
||||
)
|
||||
end
|
||||
|
||||
test "an empty repository shows an empty state", %{conn: conn, repository: repository} do
|
||||
{:ok, view, _html} = live(conn, "/#{repository.owner}/#{repository.name}/commits")
|
||||
render_async(view, 1_000)
|
||||
|
||||
assert has_element?(view, "#commits-error", "No commits yet")
|
||||
refute has_element?(view, "#commits-list")
|
||||
end
|
||||
|
||||
test "unknown repositories 404", %{conn: conn} do
|
||||
assert_raise Ecto.NoResultsError, fn ->
|
||||
live(conn, "/nobody/here/commits")
|
||||
end
|
||||
end
|
||||
end
|
||||
1008
test/tarakan_web/live/repository_live_test.exs
Normal file
1008
test/tarakan_web/live/repository_live_test.exs
Normal file
File diff suppressed because it is too large
Load diff
111
test/tarakan_web/live/repository_new_live_test.exs
Normal file
111
test/tarakan_web/live/repository_new_live_test.exs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
defmodule TarakanWeb.RepositoryNewLiveTest do
|
||||
use TarakanWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Tarakan.HostedRepositories
|
||||
alias Tarakan.Repositories
|
||||
|
||||
setup :register_and_log_in_account
|
||||
|
||||
setup do
|
||||
on_exit(fn ->
|
||||
File.rm_rf(Application.fetch_env!(:tarakan, Tarakan.HostedRepositories)[:root])
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "requires authentication" do
|
||||
conn = Phoenix.ConnTest.build_conn()
|
||||
assert {:error, {:redirect, %{to: path}}} = live(conn, ~p"/repositories/new")
|
||||
assert path =~ "/accounts/log-in"
|
||||
end
|
||||
|
||||
test "creates a hosted repository and navigates to it", %{conn: conn, account: account} do
|
||||
{:ok, view, _html} = live(conn, ~p"/repositories/new")
|
||||
|
||||
assert has_element?(view, "#new-repository-form")
|
||||
|
||||
view
|
||||
|> form("#new-repository-form", repository: %{name: "from-the-ui"})
|
||||
|> render_submit()
|
||||
|
||||
assert_redirect(view, "/#{account.handle}/from-the-ui")
|
||||
assert HostedRepositories.resolve(account.handle, "from-the-ui")
|
||||
|
||||
# the bare GitHub-style path must dispatch through the hosted scope
|
||||
{:ok, repo_view, _html} = live(conn, "/#{account.handle}/from-the-ui")
|
||||
assert has_element?(repo_view, "#repository-name")
|
||||
end
|
||||
|
||||
test "shows validation errors inline", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/repositories/new")
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("#new-repository-form", repository: %{name: "bad name"})
|
||||
|> render_submit()
|
||||
|
||||
assert html =~ "may only contain"
|
||||
end
|
||||
|
||||
describe "remote registration" do
|
||||
setup %{conn: conn} do
|
||||
%{conn: log_in_account(conn, github_account_fixture())}
|
||||
end
|
||||
|
||||
test "registers a repository and opens its public record", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/repositories/new")
|
||||
|
||||
view
|
||||
|> form("#register-remote-form", repository: %{url: "github.com/OpenAI/Codex"})
|
||||
|> render_submit()
|
||||
|
||||
assert_redirect(view, ~p"/github.com/openai/codex")
|
||||
assert Repositories.get_github_repository("openai", "codex")
|
||||
|
||||
{:ok, record_view, _html} = live(conn, ~p"/github.com/openai/codex/security")
|
||||
|
||||
assert has_element?(record_view, "#repository-name")
|
||||
assert has_element?(record_view, "#repository-source-link")
|
||||
assert has_element?(record_view, "#github-metadata")
|
||||
assert has_element?(record_view, "#repository-submitter")
|
||||
assert has_element?(record_view, "#scans-empty")
|
||||
end
|
||||
|
||||
test "shows a useful error for an invalid repository", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/repositories/new")
|
||||
|
||||
view
|
||||
|> form("#register-remote-form",
|
||||
repository: %{url: "https://example.com/not/github"}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
assert has_element?(view, "#repository_url-error")
|
||||
end
|
||||
|
||||
test "rejects a valid-looking repository that GitHub cannot find", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/repositories/new")
|
||||
|
||||
view
|
||||
|> form("#register-remote-form", repository: %{url: "missing/repository"})
|
||||
|> render_submit()
|
||||
|
||||
assert has_element?(view, "#repository_url-error")
|
||||
refute Repositories.get_github_repository("missing", "repository")
|
||||
end
|
||||
|
||||
test "shows the same safe error for a private repository", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/repositories/new")
|
||||
|
||||
view
|
||||
|> form("#register-remote-form", repository: %{url: "private/repository"})
|
||||
|> render_submit()
|
||||
|
||||
assert has_element?(view, "#repository_url-error")
|
||||
refute Repositories.get_github_repository("private", "repository")
|
||||
end
|
||||
end
|
||||
end
|
||||
367
test/tarakan_web/live/review_task_live_test.exs
Normal file
367
test/tarakan_web/live/review_task_live_test.exs
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
defmodule TarakanWeb.ReviewTaskLiveTest do
|
||||
use TarakanWeb.ConnCase
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
setup %{conn: conn} do
|
||||
creator = github_account_fixture()
|
||||
repository = listed_github_repository_fixture(creator)
|
||||
task = review_task_fixture(repository, creator)
|
||||
|
||||
%{conn: conn, creator: creator, repository: repository, task: task}
|
||||
end
|
||||
|
||||
test "the public can inspect a task but cannot claim anonymously", %{conn: conn, task: task} do
|
||||
{:ok, view, _html} = live(conn, ~p"/jobs/#{task.id}")
|
||||
|
||||
assert has_element?(view, "#review-task-title")
|
||||
assert has_element?(view, "#review-task-status", "Open")
|
||||
refute has_element?(view, "#claim-review-task-button")
|
||||
|
||||
html = render_hook(view, "claim", %{})
|
||||
assert html =~ "not authorized"
|
||||
|
||||
html = render_hook(view, "review", %{"action" => "invented", "decision" => %{}})
|
||||
assert html =~ "review action is invalid"
|
||||
|
||||
assert {:error, {:live_redirect, %{to: "/accounts/log-in?return_to=%2Fjobs%2F" <> _}}} =
|
||||
render_hook(view, "publish", %{"decision" => %{}})
|
||||
end
|
||||
|
||||
test "the task creator may claim and perform their own job", %{
|
||||
conn: conn,
|
||||
creator: creator,
|
||||
task: task
|
||||
} do
|
||||
conn = log_in_account(conn, creator)
|
||||
{:ok, view, _html} = live(conn, ~p"/jobs/#{task.id}")
|
||||
|
||||
assert has_element?(view, "#claim-review-task-button")
|
||||
refute has_element?(view, "#review-task-own-notice")
|
||||
|
||||
view |> element("#claim-review-task-button") |> render_click()
|
||||
assert has_element?(view, "#review-task-status", "Claimed")
|
||||
assert has_element?(view, "#review-task-completion-form")
|
||||
refute has_element?(view, "#review-task-agent-path")
|
||||
end
|
||||
|
||||
test "agent-required jobs show the CLI path instead of free-text completion", %{
|
||||
conn: conn,
|
||||
creator: creator,
|
||||
repository: repository
|
||||
} do
|
||||
task =
|
||||
review_task_fixture(repository, creator, %{
|
||||
"kind" => "code_review",
|
||||
"capability" => "agent",
|
||||
"title" => "Agent code review of auth boundaries"
|
||||
})
|
||||
|
||||
worker = account_fixture()
|
||||
conn = log_in_account(conn, worker)
|
||||
{:ok, view, _html} = live(conn, ~p"/jobs/#{task.id}")
|
||||
|
||||
assert has_element?(view, "#review-task-title")
|
||||
assert has_element?(view, "#review-task", "Agent required")
|
||||
|
||||
view |> element("#claim-review-task-button") |> render_click()
|
||||
|
||||
assert has_element?(view, "#review-task-status", "Claimed")
|
||||
assert has_element?(view, "#review-task-agent-path")
|
||||
refute has_element?(view, "#review-task-completion-form")
|
||||
assert render(view) =~ "tarakan report"
|
||||
assert render(view) =~ "--job #{task.id}"
|
||||
end
|
||||
|
||||
test "agent check jobs show only the check command and their target findings", %{
|
||||
conn: conn,
|
||||
creator: creator,
|
||||
repository: repository
|
||||
} do
|
||||
report =
|
||||
scan_fixture(repository, account_fixture(), %{"findings_json" => findings_json_fixture()})
|
||||
|
||||
task =
|
||||
review_task_fixture(repository, creator, %{
|
||||
"kind" => "verify_findings",
|
||||
"capability" => "agent",
|
||||
"target_review_id" => report.id,
|
||||
"title" => "Independently check the report"
|
||||
})
|
||||
|
||||
worker = reviewer_account_fixture()
|
||||
|
||||
{:ok, view, _html} =
|
||||
conn
|
||||
|> log_in_account(worker)
|
||||
|> live(~p"/jobs/#{task.id}")
|
||||
|
||||
assert has_element?(view, "#review-task-target-report", "Target report ##{report.id}")
|
||||
assert has_element?(view, "#review-task-target-report", "Unsanitized input")
|
||||
|
||||
view |> element("#claim-review-task-button") |> render_click()
|
||||
|
||||
assert has_element?(view, "#review-task-agent-path")
|
||||
assert render(view) =~ "tarakan worker --agent grok --once --jobs-only"
|
||||
refute render(view) =~ "tarakan report"
|
||||
end
|
||||
|
||||
test "a hybrid claimant can verify a report from the web", %{
|
||||
conn: conn,
|
||||
creator: creator,
|
||||
repository: repository
|
||||
} do
|
||||
report =
|
||||
scan_fixture(repository, account_fixture(), %{"findings_json" => findings_json_fixture()})
|
||||
|
||||
task =
|
||||
review_task_fixture(repository, creator, %{
|
||||
"kind" => "verify_findings",
|
||||
"capability" => "hybrid",
|
||||
"target_review_id" => report.id,
|
||||
"title" => "Reproduce the reported data flow"
|
||||
})
|
||||
|
||||
worker = reviewer_account_fixture()
|
||||
|
||||
{:ok, view, _html} =
|
||||
conn
|
||||
|> log_in_account(worker)
|
||||
|> live(~p"/jobs/#{task.id}")
|
||||
|
||||
view |> element("#claim-review-task-button") |> render_click()
|
||||
|
||||
assert has_element?(view, "#review-task-verification-form")
|
||||
refute has_element?(view, "#review-task-completion-form")
|
||||
|
||||
view
|
||||
|> form("#review-task-verification-form",
|
||||
verification: %{
|
||||
provenance: "hybrid",
|
||||
verdict: "confirmed",
|
||||
notes: "Reproduced every reported path against the exact pinned commit.",
|
||||
evidence: "Ran the negative-path request and observed the authorization bypass."
|
||||
}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
assert has_element?(view, "#review-task-status", "Submitted")
|
||||
assert has_element?(view, "#review-task-target-confirmations", "authorization bypass")
|
||||
assert Tarakan.Work.get_task!(task.id).linked_review_id == report.id
|
||||
end
|
||||
|
||||
test "agent fix jobs show the autonomous patch workflow", %{
|
||||
conn: conn,
|
||||
creator: creator,
|
||||
repository: repository
|
||||
} do
|
||||
task =
|
||||
review_task_fixture(repository, creator, %{
|
||||
"kind" => "write_fix",
|
||||
"capability" => "agent",
|
||||
"title" => "Patch the authorization bypass"
|
||||
})
|
||||
|
||||
worker = account_fixture()
|
||||
|
||||
{:ok, view, _html} =
|
||||
conn
|
||||
|> log_in_account(worker)
|
||||
|> live(~p"/jobs/#{task.id}")
|
||||
|
||||
view |> element("#claim-review-task-button") |> render_click()
|
||||
|
||||
assert has_element?(view, "#review-task-agent-path", "reviewable unified diff")
|
||||
assert render(view) =~ "tarakan worker --agent grok --once --jobs-only"
|
||||
refute render(view) =~ "tarakan report"
|
||||
end
|
||||
|
||||
test "a proposed fix is rendered as a patch artifact", %{
|
||||
conn: conn,
|
||||
creator: creator,
|
||||
repository: repository
|
||||
} do
|
||||
task =
|
||||
review_task_fixture(repository, creator, %{
|
||||
"kind" => "write_fix",
|
||||
"capability" => "human",
|
||||
"title" => "Patch the authorization bypass"
|
||||
})
|
||||
|
||||
worker = account_fixture()
|
||||
|
||||
{:ok, view, _html} =
|
||||
conn
|
||||
|> log_in_account(worker)
|
||||
|> live(~p"/jobs/#{task.id}")
|
||||
|
||||
view |> element("#claim-review-task-button") |> render_click()
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"#review-task-completion-form",
|
||||
"Proposed unified diff and test plan"
|
||||
)
|
||||
|
||||
view
|
||||
|> form("#review-task-completion-form",
|
||||
contribution: %{
|
||||
provenance: "human",
|
||||
summary: "Require ownership before the state transition.",
|
||||
evidence:
|
||||
"diff --git a/lib/action.ex b/lib/action.ex\n--- a/lib/action.ex\n+++ b/lib/action.ex\n@@ -1 +1,2 @@\n run()\n+authorize()\n\nTest: mix test"
|
||||
}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
assert has_element?(view, "#review-task-fix-badge", "Patch proposal")
|
||||
assert has_element?(view, "#review-task-contribution", "Proposed fix")
|
||||
assert has_element?(view, "#review-task-contribution", "diff --git")
|
||||
refute has_element?(view, "#review-task-historical-badge")
|
||||
end
|
||||
|
||||
test "another contributor claims and submits the task for review", %{conn: conn, task: task} do
|
||||
worker = account_fixture()
|
||||
conn = log_in_account(conn, worker)
|
||||
{:ok, view, _html} = live(conn, ~p"/jobs/#{task.id}")
|
||||
|
||||
view |> element("#claim-review-task-button") |> render_click()
|
||||
|
||||
assert has_element?(view, "#review-task-status", "Claimed")
|
||||
assert has_element?(view, "#review-task-completion-form")
|
||||
refute has_element?(view, "#review-task-agent-path")
|
||||
|
||||
view
|
||||
|> form("#review-task-completion-form",
|
||||
contribution: %{
|
||||
provenance: "human",
|
||||
summary: "The boundary is enforced consistently.",
|
||||
evidence: "Reproduced the negative path with the repository test suite."
|
||||
}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
assert has_element?(view, "#review-task-status", "Submitted")
|
||||
assert has_element?(view, "#review-task-review-pending")
|
||||
assert has_element?(view, "#review-task-contribution", "Human")
|
||||
assert has_element?(view, "#review-task-contribution", "boundary is enforced")
|
||||
refute has_element?(view, "#review-task-completion-form")
|
||||
end
|
||||
|
||||
test "a qualified independent reviewer accepts submitted evidence", %{
|
||||
conn: conn,
|
||||
task: task
|
||||
} do
|
||||
worker = account_fixture()
|
||||
{:ok, task} = Tarakan.Work.claim_task(task, worker)
|
||||
|
||||
{:ok, task} =
|
||||
Tarakan.Work.submit_task(task, worker, %{
|
||||
"provenance" => "human",
|
||||
"summary" => "The boundary is enforced consistently.",
|
||||
"evidence" => "Reproduced the negative path with the repository test suite."
|
||||
})
|
||||
|
||||
reviewer = moderator_account_fixture()
|
||||
conn = log_in_account(conn, reviewer)
|
||||
{:ok, view, _html} = live(conn, ~p"/jobs/#{task.id}")
|
||||
|
||||
assert has_element?(view, "#review-task-decision-form")
|
||||
|
||||
view
|
||||
|> form("#review-task-decision-form",
|
||||
decision: %{
|
||||
reason: "The submitted behavior was reproduced independently.",
|
||||
evidence: "Checked out the pinned SHA and ran the documented negative-path tests."
|
||||
}
|
||||
)
|
||||
|> render_submit(%{"action" => "accept"})
|
||||
|
||||
assert has_element?(view, "#review-task-status", "Accepted")
|
||||
assert has_element?(view, "#review-task-visibility", "Full evidence public")
|
||||
assert has_element?(view, "#review-task-contribution")
|
||||
assert has_element?(view, "#review-task-disclosure-form")
|
||||
refute has_element?(view, "#review-task-decision-form")
|
||||
assert Tarakan.Work.get_visible_task(task.id)
|
||||
|
||||
{:ok, accepted_view, accepted_html} =
|
||||
live(Phoenix.ConnTest.build_conn(), ~p"/jobs/#{task.id}")
|
||||
|
||||
assert has_element?(accepted_view, "#review-task-status", "Accepted")
|
||||
assert accepted_html =~ "Reproduced the negative path"
|
||||
|
||||
html =
|
||||
view
|
||||
|> form("#review-task-disclosure-form", disclosure: %{reason: "short"})
|
||||
|> render_submit(%{"visibility" => "public_summary"})
|
||||
|
||||
assert html =~ "should be at least 10 character"
|
||||
assert Tarakan.Work.get_task!(task.id).visibility == "public"
|
||||
|
||||
view
|
||||
|> form("#review-task-disclosure-form",
|
||||
disclosure: %{
|
||||
reason: "The redacted result is safe and useful to publish without raw evidence."
|
||||
}
|
||||
)
|
||||
|> render_submit(%{"visibility" => "public_summary"})
|
||||
|
||||
assert has_element?(view, "#review-task-visibility", "Public summary")
|
||||
|
||||
{:ok, public_view, public_html} =
|
||||
live(Phoenix.ConnTest.build_conn(), ~p"/jobs/#{task.id}")
|
||||
|
||||
assert has_element?(public_view, "#review-task-status", "Accepted")
|
||||
assert public_html =~ "boundary is enforced"
|
||||
refute public_html =~ "Reproduced the negative path"
|
||||
refute public_html =~ "documented negative-path tests"
|
||||
end
|
||||
|
||||
test "disclosure requires recent authentication", %{conn: conn, task: task} do
|
||||
worker = account_fixture()
|
||||
{:ok, task} = Tarakan.Work.claim_task(task, worker)
|
||||
|
||||
{:ok, task} =
|
||||
Tarakan.Work.submit_task(task, worker, %{
|
||||
"provenance" => "human",
|
||||
"summary" => "The boundary is enforced consistently.",
|
||||
"evidence" => "Reproduced the negative path with the repository test suite."
|
||||
})
|
||||
|
||||
reviewer = moderator_account_fixture()
|
||||
|
||||
{:ok, task} =
|
||||
Tarakan.Work.accept_task(task, reviewer, %{
|
||||
"reason" => "The submitted behavior was reproduced independently.",
|
||||
"evidence" => "Checked out the pinned SHA and ran the documented negative-path tests."
|
||||
})
|
||||
|
||||
# Outside the two-hour sudo window.
|
||||
stale_at = DateTime.add(DateTime.utc_now(), -3, :hour)
|
||||
conn = log_in_account(conn, reviewer, token_authenticated_at: stale_at)
|
||||
{:ok, view, _html} = live(conn, ~p"/jobs/#{task.id}")
|
||||
|
||||
assert {:error, {:live_redirect, %{to: "/accounts/log-in?return_to=%2Fjobs%2F" <> _}}} =
|
||||
view
|
||||
|> form("#review-task-disclosure-form",
|
||||
disclosure: %{
|
||||
reason: "The redacted result is safe and useful to publish without raw evidence."
|
||||
}
|
||||
)
|
||||
|> render_submit(%{"visibility" => "public_summary"})
|
||||
|
||||
assert Tarakan.Work.get_task!(task.id).visibility == "public"
|
||||
end
|
||||
|
||||
test "a claimant can release work back to the queue", %{conn: conn, task: task} do
|
||||
worker = account_fixture()
|
||||
conn = log_in_account(conn, worker)
|
||||
{:ok, view, _html} = live(conn, ~p"/jobs/#{task.id}")
|
||||
|
||||
view |> element("#claim-review-task-button") |> render_click()
|
||||
view |> element("#release-review-task-button") |> render_click()
|
||||
|
||||
assert has_element?(view, "#review-task-status", "Open")
|
||||
assert has_element?(view, "#claim-review-task-button")
|
||||
end
|
||||
end
|
||||
60
test/tarakan_web/nav_active_state_test.exs
Normal file
60
test/tarakan_web/nav_active_state_test.exs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
defmodule TarakanWeb.NavActiveStateTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
# The highlight used to be applied by JavaScript after paint, so the server
|
||||
# rendered nothing active and dead routes never highlighted at all.
|
||||
test "a LiveView marks its own nav entry active on first paint", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/leaderboard")
|
||||
|
||||
assert html =~ ~r/href="\/leaderboard"[^>]*aria-current="page"/
|
||||
refute html =~ ~r/href="\/pricing"[^>]*aria-current="page"/
|
||||
end
|
||||
|
||||
test "a dead controller route marks its nav entry active too", %{conn: conn} do
|
||||
html = conn |> get(~p"/pricing") |> html_response(200)
|
||||
|
||||
assert html =~ ~r/href="\/pricing"[^>]*aria-current="page"/
|
||||
refute html =~ ~r/href="\/leaderboard"[^>]*aria-current="page"/
|
||||
end
|
||||
|
||||
test "a section stays active on its subtree" do
|
||||
alias TarakanWeb.CurrentPath
|
||||
|
||||
assert CurrentPath.active?("/jobs/12", "/jobs")
|
||||
assert CurrentPath.active?("/jobs", "/jobs")
|
||||
refute CurrentPath.active?("/jobsomething", "/jobs")
|
||||
# Root must not own every page.
|
||||
refute CurrentPath.active?("/jobs", "/")
|
||||
assert CurrentPath.active?("/", "/")
|
||||
refute CurrentPath.active?(nil, "/jobs")
|
||||
end
|
||||
|
||||
# Leaderboard, Models and Pricing used to be hidden below lg while the other
|
||||
# five were not. The desktop bar starts at md and the hamburger stops at md,
|
||||
# so between 768 and 1024 those three were unreachable from any navigation.
|
||||
test "every primary nav entry is reachable at every width", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/")
|
||||
|
||||
nav =
|
||||
Regex.run(~r/<nav[^>]*aria-label="Primary".*?<\/nav>/s, html)
|
||||
|> List.first()
|
||||
|
||||
for path <- ~w(/explore /infestations /jobs /bounties /agents /leaderboard /models /pricing) do
|
||||
assert nav =~ ~s(href="#{path}"), "#{path} missing from the primary nav"
|
||||
end
|
||||
|
||||
refute nav =~ "hidden lg:", "no primary nav entry may be hidden at a breakpoint"
|
||||
end
|
||||
|
||||
test "the mobile menu carries the same entries", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/")
|
||||
|
||||
menu = Regex.run(~r/<nav[^>]*aria-label="Site".*?<\/nav>/s, html) |> List.first()
|
||||
|
||||
for path <- ~w(/explore /infestations /jobs /bounties /agents /leaderboard /models /pricing) do
|
||||
assert menu =~ ~s(href="#{path}"), "#{path} missing from the mobile menu"
|
||||
end
|
||||
end
|
||||
end
|
||||
17
test/tarakan_web/oauth_rate_limit_test.exs
Normal file
17
test/tarakan_web/oauth_rate_limit_test.exs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
defmodule TarakanWeb.OAuthRateLimitTest do
|
||||
use TarakanWeb.ConnCase
|
||||
|
||||
test "OAuth routes are rate limited per client IP" do
|
||||
for _ <- 1..20 do
|
||||
conn = get(build_conn(), ~p"/auth/github")
|
||||
assert conn.status == 302
|
||||
end
|
||||
|
||||
conn = get(build_conn(), ~p"/auth/github")
|
||||
assert conn.status == 429
|
||||
assert text_response(conn, 429) =~ "too many requests"
|
||||
|
||||
conn = get(build_conn(), ~p"/auth/gitlab")
|
||||
assert conn.status == 429
|
||||
end
|
||||
end
|
||||
85
test/tarakan_web/plugs/api_rate_limit_test.exs
Normal file
85
test/tarakan_web/plugs/api_rate_limit_test.exs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
defmodule TarakanWeb.Plugs.ApiRateLimitTest do
|
||||
use Tarakan.DataCase, async: true
|
||||
|
||||
import Plug.Conn
|
||||
import Plug.Test
|
||||
|
||||
alias Tarakan.Accounts.Scope
|
||||
alias TarakanWeb.Plugs.ApiRateLimit
|
||||
|
||||
defp call_ip(conn, opts) do
|
||||
ApiRateLimit.call(conn, ApiRateLimit.init([mode: :ip] ++ opts))
|
||||
end
|
||||
|
||||
defp call_actor(conn, scope, opts) do
|
||||
conn
|
||||
|> assign(:current_scope, scope)
|
||||
|> ApiRateLimit.call(ApiRateLimit.init([mode: :actor] ++ opts))
|
||||
end
|
||||
|
||||
defp credential_scope(account, token_id) do
|
||||
Scope.for_account(account, token_id: token_id, authentication_method: :api_credential)
|
||||
end
|
||||
|
||||
test "requests carrying a Bearer token count against the IP bucket" do
|
||||
opts = [request_limit: 2, mutation_limit: 2, window_seconds: 60]
|
||||
|
||||
for _ <- 1..2 do
|
||||
conn =
|
||||
conn(:get, "/api/repositories")
|
||||
|> put_req_header("authorization", "Bearer garbage-token")
|
||||
|> call_ip(opts)
|
||||
|
||||
refute conn.halted
|
||||
end
|
||||
|
||||
conn =
|
||||
conn(:get, "/api/repositories")
|
||||
|> put_req_header("authorization", "Bearer garbage-token")
|
||||
|> call_ip(opts)
|
||||
|
||||
assert conn.halted
|
||||
assert conn.status == 429
|
||||
assert Jason.decode!(conn.resp_body)["error"] == "rate limit exceeded"
|
||||
end
|
||||
|
||||
test "IPv6 clients in the same /64 share one IP bucket" do
|
||||
opts = [request_limit: 1, mutation_limit: 1, window_seconds: 60]
|
||||
|
||||
first =
|
||||
conn(:get, "/api/repositories")
|
||||
|> Map.put(:remote_ip, {0x2001, 0x0DB8, 0, 0, 0, 0, 0, 1})
|
||||
|> call_ip(opts)
|
||||
|
||||
refute first.halted
|
||||
|
||||
second =
|
||||
conn(:get, "/api/repositories")
|
||||
|> Map.put(:remote_ip, {0x2001, 0x0DB8, 0, 0, 0xAAAA, 0xBBBB, 0xCCCC, 0xDDDD})
|
||||
|> call_ip(opts)
|
||||
|
||||
assert second.halted
|
||||
assert second.status == 429
|
||||
end
|
||||
|
||||
test "plain requests still count against the IP bucket" do
|
||||
opts = [request_limit: 1, mutation_limit: 1, window_seconds: 60]
|
||||
|
||||
refute conn(:get, "/api/repositories") |> call_ip(opts) |> Map.fetch!(:halted)
|
||||
assert conn(:get, "/api/repositories") |> call_ip(opts) |> Map.fetch!(:halted)
|
||||
end
|
||||
|
||||
test "authenticated actors get the multiplied actor budget" do
|
||||
opts = [request_limit: 2, mutation_limit: 2, window_seconds: 60]
|
||||
scope = credential_scope(account_fixture(), 11_001)
|
||||
|
||||
# 2 × @actor_multiplier (4) = 8 requests fit; the 9th is rejected.
|
||||
for _ <- 1..8 do
|
||||
refute conn(:get, "/api/jobs") |> call_actor(scope, opts) |> Map.fetch!(:halted)
|
||||
end
|
||||
|
||||
limited = conn(:get, "/api/jobs") |> call_actor(scope, opts)
|
||||
assert limited.halted
|
||||
assert limited.status == 429
|
||||
end
|
||||
end
|
||||
114
test/tarakan_web/plugs/client_ip_test.exs
Normal file
114
test/tarakan_web/plugs/client_ip_test.exs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
defmodule TarakanWeb.Plugs.ClientIpTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
import Plug.Conn
|
||||
import Plug.Test
|
||||
|
||||
alias Tarakan.TrustedProxies
|
||||
alias TarakanWeb.Plugs.ClientIp
|
||||
|
||||
setup do
|
||||
previous = Application.get_env(:tarakan, :trusted_proxies, [])
|
||||
|
||||
on_exit(fn ->
|
||||
Application.put_env(:tarakan, :trusted_proxies, previous)
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "does not trust X-Forwarded-For without configured proxies" do
|
||||
Application.put_env(:tarakan, :trusted_proxies, [])
|
||||
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> Map.put(:remote_ip, {10, 0, 0, 5})
|
||||
|> put_req_header("x-forwarded-for", "203.0.113.9")
|
||||
|> ClientIp.call([])
|
||||
|
||||
assert conn.remote_ip == {10, 0, 0, 5}
|
||||
end
|
||||
|
||||
test "rewrites remote_ip when the peer is a trusted proxy" do
|
||||
Application.put_env(:tarakan, :trusted_proxies, TrustedProxies.parse("10.0.0.0/8"))
|
||||
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> Map.put(:remote_ip, {10, 0, 0, 5})
|
||||
|> put_req_header("x-forwarded-for", "203.0.113.9, 10.0.0.5")
|
||||
|> ClientIp.call([])
|
||||
|
||||
assert conn.remote_ip == {203, 0, 113, 9}
|
||||
end
|
||||
|
||||
test "ignores forwarded headers from untrusted peers" do
|
||||
Application.put_env(:tarakan, :trusted_proxies, TrustedProxies.parse("10.0.0.0/8"))
|
||||
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> Map.put(:remote_ip, {198, 51, 100, 1})
|
||||
|> put_req_header("x-forwarded-for", "203.0.113.9")
|
||||
|> ClientIp.call([])
|
||||
|
||||
assert conn.remote_ip == {198, 51, 100, 1}
|
||||
end
|
||||
|
||||
test "picks the rightmost untrusted hop, not a client-spoofed leftmost entry" do
|
||||
Application.put_env(:tarakan, :trusted_proxies, TrustedProxies.parse("10.0.0.0/8"))
|
||||
|
||||
# Attacker sends a forged leftmost value; the trusted proxy appends the real
|
||||
# peer it observed. remote_ip must be the real hop, never the forged one.
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> Map.put(:remote_ip, {10, 0, 0, 5})
|
||||
|> put_req_header("x-forwarded-for", "203.0.113.9, 198.51.100.7")
|
||||
|> ClientIp.call([])
|
||||
|
||||
assert conn.remote_ip == {198, 51, 100, 7}
|
||||
end
|
||||
|
||||
test "strips multiple trailing trusted proxies down to the client" do
|
||||
Application.put_env(:tarakan, :trusted_proxies, TrustedProxies.parse("10.0.0.0/8"))
|
||||
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> Map.put(:remote_ip, {10, 0, 0, 9})
|
||||
|> put_req_header("x-forwarded-for", "203.0.113.9, 10.0.0.5, 10.0.0.9")
|
||||
|> ClientIp.call([])
|
||||
|
||||
assert conn.remote_ip == {203, 0, 113, 9}
|
||||
end
|
||||
|
||||
test "matches an IPv4 trusted proxy against an IPv6-mapped IPv4 peer" do
|
||||
# On a dual-stack (`::`) listener, an IPv4 proxy arrives as ::ffff:10.0.0.5.
|
||||
Application.put_env(:tarakan, :trusted_proxies, TrustedProxies.parse("10.0.0.0/8"))
|
||||
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> Map.put(:remote_ip, {0, 0, 0, 0, 0, 0xFFFF, 0x0A00, 0x0005})
|
||||
|> put_req_header("x-forwarded-for", "203.0.113.9")
|
||||
|> ClientIp.call([])
|
||||
|
||||
assert conn.remote_ip == {203, 0, 113, 9}
|
||||
end
|
||||
|
||||
test "normalizes an IPv6-mapped remote_ip to plain IPv4 for rate-limit keys" do
|
||||
conn = %Plug.Conn{remote_ip: {0, 0, 0, 0, 0, 0xFFFF, 0xCB00, 0x7109}}
|
||||
assert ClientIp.remote_ip_string(conn) == "203.0.113.9"
|
||||
end
|
||||
|
||||
test "remote_ip_bucket keeps the full IPv4 address" do
|
||||
conn = %Plug.Conn{remote_ip: {203, 0, 113, 9}}
|
||||
assert ClientIp.remote_ip_bucket(conn) == {:v4, 203, 0, 113, 9}
|
||||
end
|
||||
|
||||
test "remote_ip_bucket truncates IPv6 to the /64 prefix" do
|
||||
conn = %Plug.Conn{remote_ip: {0x2001, 0x0DB8, 0x0001, 0x0002, 1, 2, 3, 4}}
|
||||
assert ClientIp.remote_ip_bucket(conn) == {:v6, 0x2001, 0x0DB8, 0x0001, 0x0002}
|
||||
end
|
||||
|
||||
test "remote_ip_bucket folds IPv4-mapped IPv6 into the IPv4 bucket" do
|
||||
conn = %Plug.Conn{remote_ip: {0, 0, 0, 0, 0, 0xFFFF, 0xCB00, 0x7109}}
|
||||
assert ClientIp.remote_ip_bucket(conn) == {:v4, 203, 0, 113, 9}
|
||||
end
|
||||
end
|
||||
68
test/tarakan_web/plugs/code_browser_rate_limit_test.exs
Normal file
68
test/tarakan_web/plugs/code_browser_rate_limit_test.exs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
defmodule TarakanWeb.Plugs.CodeBrowserRateLimitTest do
|
||||
use Tarakan.DataCase, async: true
|
||||
|
||||
import Plug.Conn
|
||||
import Plug.Test
|
||||
|
||||
alias TarakanWeb.Plugs.CodeBrowserRateLimit
|
||||
|
||||
test "allows requests under the per-IP budget" do
|
||||
opts = CodeBrowserRateLimit.init(limit: 2, window_seconds: 60)
|
||||
|
||||
for path <- [
|
||||
"/findings/55d5c681-240a-4e3a-a1f8-45933a30c4ef/code",
|
||||
"/github.com/openai/codex"
|
||||
] do
|
||||
conn = conn(:get, path) |> CodeBrowserRateLimit.call(opts)
|
||||
refute conn.halted
|
||||
end
|
||||
end
|
||||
|
||||
test "answers 429 once the per-IP budget is exceeded" do
|
||||
opts = CodeBrowserRateLimit.init(limit: 2, window_seconds: 60)
|
||||
|
||||
for _ <- 1..2 do
|
||||
conn = conn(:get, "/github.com/openai/codex") |> CodeBrowserRateLimit.call(opts)
|
||||
refute conn.halted
|
||||
end
|
||||
|
||||
conn = conn(:get, "/github.com/openai/codex") |> CodeBrowserRateLimit.call(opts)
|
||||
|
||||
assert conn.halted
|
||||
assert conn.status == 429
|
||||
assert [retry_after] = get_resp_header(conn, "retry-after")
|
||||
assert String.to_integer(retry_after) > 0
|
||||
end
|
||||
|
||||
test "IPv6 clients in the same /64 share one bucket" do
|
||||
opts = CodeBrowserRateLimit.init(limit: 1, window_seconds: 60)
|
||||
|
||||
first =
|
||||
conn(:get, "/github.com/openai/codex")
|
||||
|> Map.put(:remote_ip, {0x2001, 0x0DB8, 0, 0, 0, 0, 0, 1})
|
||||
|> CodeBrowserRateLimit.call(opts)
|
||||
|
||||
refute first.halted
|
||||
|
||||
second =
|
||||
conn(:get, "/github.com/openai/codex")
|
||||
|> Map.put(:remote_ip, {0x2001, 0x0DB8, 0, 0, 0xFFFF, 0xEEEE, 0xDDDD, 0xCCCC})
|
||||
|> CodeBrowserRateLimit.call(opts)
|
||||
|
||||
assert second.halted
|
||||
assert second.status == 429
|
||||
end
|
||||
|
||||
test "admins and moderators are exempt from the budget" do
|
||||
opts = CodeBrowserRateLimit.init(limit: 1, window_seconds: 60)
|
||||
|
||||
for role <- ["admin", "moderator"], _ <- 1..3 do
|
||||
conn =
|
||||
conn(:get, "/github.com/openai/codex")
|
||||
|> assign(:current_scope, %{platform_role: role})
|
||||
|> CodeBrowserRateLimit.call(opts)
|
||||
|
||||
refute conn.halted
|
||||
end
|
||||
end
|
||||
end
|
||||
75
test/tarakan_web/plugs/forwarded_proto_test.exs
Normal file
75
test/tarakan_web/plugs/forwarded_proto_test.exs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
defmodule TarakanWeb.Plugs.ForwardedProtoTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
import Plug.Conn
|
||||
import Plug.Test
|
||||
|
||||
alias Tarakan.TrustedProxies
|
||||
alias TarakanWeb.Plugs.ForwardedProto
|
||||
|
||||
setup do
|
||||
previous = Application.get_env(:tarakan, :trusted_proxies, [])
|
||||
|
||||
on_exit(fn ->
|
||||
Application.put_env(:tarakan, :trusted_proxies, previous)
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
test "rewrites the scheme when the peer is a trusted proxy" do
|
||||
Application.put_env(:tarakan, :trusted_proxies, TrustedProxies.parse("127.0.0.1,::1"))
|
||||
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> Map.put(:remote_ip, {127, 0, 0, 1})
|
||||
|> put_req_header("x-forwarded-proto", "https")
|
||||
|> ForwardedProto.call([])
|
||||
|
||||
assert conn.scheme == :https
|
||||
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> Map.put(:remote_ip, {0, 0, 0, 0, 0, 0, 0, 1})
|
||||
|> Map.put(:scheme, :https)
|
||||
|> put_req_header("x-forwarded-proto", "http")
|
||||
|> ForwardedProto.call([])
|
||||
|
||||
assert conn.scheme == :http
|
||||
end
|
||||
|
||||
test "ignores X-Forwarded-Proto from untrusted peers" do
|
||||
Application.put_env(:tarakan, :trusted_proxies, TrustedProxies.parse("10.0.0.0/8"))
|
||||
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> Map.put(:remote_ip, {198, 51, 100, 1})
|
||||
|> put_req_header("x-forwarded-proto", "https")
|
||||
|> ForwardedProto.call([])
|
||||
|
||||
assert conn.scheme == :http
|
||||
end
|
||||
|
||||
test "ignores X-Forwarded-Proto without configured proxies" do
|
||||
Application.put_env(:tarakan, :trusted_proxies, [])
|
||||
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> Map.put(:remote_ip, {127, 0, 0, 1})
|
||||
|> put_req_header("x-forwarded-proto", "https")
|
||||
|> ForwardedProto.call([])
|
||||
|
||||
assert conn.scheme == :http
|
||||
end
|
||||
|
||||
test "leaves the scheme untouched without the header" do
|
||||
Application.put_env(:tarakan, :trusted_proxies, TrustedProxies.parse("127.0.0.1"))
|
||||
|
||||
conn =
|
||||
conn(:get, "/")
|
||||
|> Map.put(:remote_ip, {127, 0, 0, 1})
|
||||
|> ForwardedProto.call([])
|
||||
|
||||
assert conn.scheme == :http
|
||||
end
|
||||
end
|
||||
37
test/tarakan_web/safe_redirect_test.exs
Normal file
37
test/tarakan_web/safe_redirect_test.exs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
defmodule TarakanWeb.SafeRedirectTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias TarakanWeb.SafeRedirect
|
||||
|
||||
test "keeps same-origin absolute paths" do
|
||||
assert SafeRedirect.local_path("/") == "/"
|
||||
|
||||
assert SafeRedirect.local_path("/accounts/settings?tab=identities") ==
|
||||
"/accounts/settings?tab=identities"
|
||||
end
|
||||
|
||||
test "rejects authority paths, backslashes, controls, and encoded variants" do
|
||||
malicious_paths = [
|
||||
"https://attacker.example/",
|
||||
"//attacker.example/",
|
||||
"/\\attacker.example/",
|
||||
"/%5cattacker.example/",
|
||||
"/%255cattacker.example/",
|
||||
"/%25255cattacker.example/",
|
||||
"/%2f%2fattacker.example/",
|
||||
"/%252f%252fattacker.example/",
|
||||
"/safe%0d%0aLocation:%20https://attacker.example/"
|
||||
]
|
||||
|
||||
for path <- malicious_paths do
|
||||
assert SafeRedirect.local_path(path) == "/", "expected #{inspect(path)} to be rejected"
|
||||
end
|
||||
end
|
||||
|
||||
test "uses the caller's fallback for invalid input" do
|
||||
assert SafeRedirect.local_path("//attacker.example", "/accounts/log-in") ==
|
||||
"/accounts/log-in"
|
||||
|
||||
assert SafeRedirect.local_path(nil, "/accounts/log-in") == "/accounts/log-in"
|
||||
end
|
||||
end
|
||||
22
test/tarakan_web/theme_boot_test.exs
Normal file
22
test/tarakan_web/theme_boot_test.exs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
defmodule TarakanWeb.ThemeBootTest do
|
||||
use TarakanWeb.ConnCase, async: true
|
||||
|
||||
test "the theme script is blocking and precedes the deferred bundle", %{conn: conn} do
|
||||
html = conn |> get(~p"/") |> html_response(200)
|
||||
|
||||
theme_at = :binary.match(html, "/assets/js/theme") |> elem(0)
|
||||
app_at = :binary.match(html, "/assets/js/app") |> elem(0)
|
||||
|
||||
assert theme_at < app_at, "theme.js must load before app.js"
|
||||
|
||||
# The whole point: it must not be deferred, or it runs after first paint
|
||||
# and the toggle flashes the wrong option again.
|
||||
[theme_tag] = Regex.run(~r|<script[^>]*/assets/js/theme[^>]*>|, html)
|
||||
refute theme_tag =~ "defer"
|
||||
refute theme_tag =~ "type=\"module\""
|
||||
|
||||
# And the bundle that needs to stay deferred still is.
|
||||
[app_tag] = Regex.run(~r|<script[^>]*/assets/js/app[^>]*>|, html)
|
||||
assert app_tag =~ "defer"
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue