diff --git a/test/cadet/token_exchange_test.exs b/test/cadet/token_exchange_test.exs new file mode 100644 index 000000000..707a2916f --- /dev/null +++ b/test/cadet/token_exchange_test.exs @@ -0,0 +1,171 @@ +defmodule Cadet.TokenExchangeTest do + use Cadet.DataCase + + alias Cadet.TokenExchange + + describe "get_by_code" do + test "returns error when code not found" do + result = TokenExchange.get_by_code("nonexistent_code") + assert {:error, "Not found"} == result + end + + test "returns error when code is expired" do + user = insert(:user) + + TokenExchange.insert(%{ + code: "expired_code", + generated_at: Timex.shift(Timex.now(), hours: -2), + expires_at: Timex.shift(Timex.now(), hours: -1), + user_id: user.id + }) + + result = TokenExchange.get_by_code("expired_code") + assert {:error, "Expired"} == result + end + + test "returns ok with user when code is valid and not expired" do + user = insert(:user) + code = "valid_code_123" + + {:ok, token} = + TokenExchange.insert(%{ + code: code, + generated_at: Timex.now(), + expires_at: Timex.shift(Timex.now(), minutes: 5), + user_id: user.id + }) + + assert {:ok, struct} = TokenExchange.get_by_code(code) + assert struct.code == code + assert struct.user.id == user.id + end + + test "deletes the code after successful retrieval" do + user = insert(:user) + code = "code_to_delete" + + TokenExchange.insert(%{ + code: code, + generated_at: Timex.now(), + expires_at: Timex.shift(Timex.now(), minutes: 5), + user_id: user.id + }) + + # First retrieval should succeed + assert {:ok, _struct} = TokenExchange.get_by_code(code) + + # Second retrieval should fail as the code was deleted + assert {:error, "Not found"} = TokenExchange.get_by_code(code) + end + + test "preloads user association" do + user = insert(:user, name: "Test User") + code = "code_with_user" + + TokenExchange.insert(%{ + code: code, + generated_at: Timex.now(), + expires_at: Timex.shift(Timex.now(), minutes: 5), + user_id: user.id + }) + + {:ok, struct} = TokenExchange.get_by_code(code) + assert struct.user.name == "Test User" + end + end + + describe "delete_expired" do + test "deletes all expired tokens" do + user1 = insert(:user) + user2 = insert(:user) + + # Insert expired tokens + TokenExchange.insert(%{ + code: "expired_1", + generated_at: Timex.shift(Timex.now(), hours: -2), + expires_at: Timex.shift(Timex.now(), minutes: -30), + user_id: user1.id + }) + + TokenExchange.insert(%{ + code: "expired_2", + generated_at: Timex.shift(Timex.now(), hours: -1), + expires_at: Timex.shift(Timex.now(), minutes: -15), + user_id: user2.id + }) + + # Insert valid token + TokenExchange.insert(%{ + code: "valid_token", + generated_at: Timex.now(), + expires_at: Timex.shift(Timex.now(), minutes: 10), + user_id: user1.id + }) + + # Execute delete_expired + {deleted_count, _} = TokenExchange.delete_expired() + + assert deleted_count == 2 + # Verify valid token still exists + assert {:ok, _} = TokenExchange.get_by_code("valid_token") + end + end + + describe "insert" do + test "creates a new token exchange record" do + user = insert(:user) + code = "test_code_insert" + + {:ok, token} = + TokenExchange.insert(%{ + code: code, + generated_at: Timex.now(), + expires_at: Timex.shift(Timex.now(), minutes: 5), + user_id: user.id + }) + + assert token.code == code + assert token.user_id == user.id + end + + test "fails when required fields are missing" do + user = insert(:user) + + {:error, changeset} = + TokenExchange.insert(%{ + code: "incomplete_code", + user_id: user.id + }) + + refute changeset.valid? + end + end + + describe "changeset" do + test "validates required fields" do + user = insert(:user) + + changeset = + TokenExchange.changeset(%TokenExchange{}, %{ + code: "test_code", + generated_at: Timex.now(), + expires_at: Timex.shift(Timex.now(), minutes: 5), + user_id: user.id + }) + + assert changeset.valid? + end + + test "marks changeset invalid when required fields are missing" do + changeset = + TokenExchange.changeset(%TokenExchange{}, %{ + code: "test_code" + }) + + refute changeset.valid? + assert Keyword.has_key?(changeset.errors, :generated_at) + assert Keyword.has_key?(changeset.errors, :expires_at) + assert Keyword.has_key?(changeset.errors, :user_id) + end + end +end diff --git a/test/cadet_web/controllers/auth_controller_test.exs b/test/cadet_web/controllers/auth_controller_test.exs index df63098a3..a294e9e2d 100644 --- a/test/cadet_web/controllers/auth_controller_test.exs +++ b/test/cadet_web/controllers/auth_controller_test.exs @@ -1,12 +1,14 @@ defmodule CadetWeb.AuthControllerTest do use CadetWeb.ConnCase use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney + use Mock import Cadet.Factory import Mock alias Cadet.Auth.Guardian alias CadetWeb.AuthController + alias Cadet.TokenExchange setup_all do HTTPoison.start() @@ -218,4 +220,114 @@ defmodule CadetWeb.AuthControllerTest do assert {:error, _} = Guardian.decode_and_verify(refresh_token) end end + + describe "GET /auth/exchange" do + test "returns 403 when code is not found", %{conn: conn} do + conn = + get(conn, "/v2/auth/exchange", %{ + "code" => "nonexistent_code", + "provider" => "test" + }) + + assert response(conn, 403) == "Invalid code" + end + + test "returns 403 when code is expired", %{conn: conn} do + user = insert(:user) + + TokenExchange.insert(%{ + code: "expired_code", + generated_at: Timex.shift(Timex.now(), hours: -2), + expires_at: Timex.shift(Timex.now(), hours: -1), + user_id: user.id + }) + + conn = + get(conn, "/v2/auth/exchange", %{ + "code" => "expired_code", + "provider" => "test" + }) + + assert response(conn, 403) == "Invalid code" + end + + test "exchanges valid code for tokens and redirects", %{conn: conn} do + user = insert(:user) + code = "valid_exchange_code" + + TokenExchange.insert(%{ + code: code, + generated_at: Timex.now(), + expires_at: Timex.shift(Timex.now(), minutes: 5), + user_id: user.id + }) + + # Need to configure the identity provider with post-exchange redirect URL + original_config = Application.get_env(:cadet, :identity_providers) + + config_with_redirect = + Map.put(original_config, "test", { + Cadet.Auth.Providers.Config, + original_config["test"] |> elem(1) |> Enum.map(& &1) + }) + + Application.put_env(:cadet, :identity_providers, %{ + "test" => { + elem(config_with_redirect["test"], 0), + elem(config_with_redirect["test"], 1), + client_post_exchange_redirect_url: "http://localhost:3000/callback" + } + }) + + try do + conn = + get(conn, "/v2/auth/exchange", %{ + "code" => code, + "provider" => "test" + }) + + assert response(conn, 302) + assert get_resp_header(conn, "location") != [] + + location = get_resp_header(conn, "location") |> hd() + assert String.contains?(location, "access_token=") + assert String.contains?(location, "refresh_token=") + after + Application.put_env(:cadet, :identity_providers, original_config) + end + end + end + + describe "GET /auth/saml_redirect_vscode" do + test "missing parameter", %{conn: conn} do + conn = get(conn, "/v2/auth/saml_redirect_vscode", %{}) + # The controller doesn't have a clause for missing params, so it will just pass through + # Check if response is not a successful auth + end + + test_with_mock "success with saml redirect vscode", %{conn: conn}, Samly, [], + get_active_assertion: fn _ -> + %{attributes: %{"SamAccountName" => "username", "DisplayName" => "name"}} + end do + original_config = Application.get_env(:cadet, :identity_providers) + + Application.put_env(:cadet, :identity_providers, %{ + "saml" => { + Cadet.Auth.Providers.SAML, + %{ + assertion_extractor: Cadet.Auth.Providers.NusstfAssertionExtractor, + vscode_redirect_url_prefix: "vscode://source-academy.source-academy/sso" + } + } + }) + + try do + conn = get(conn, "/v2/auth/saml_redirect_vscode", %{"provider" => "saml"}) + assert response(conn, 302) + assert get_resp_header(conn, "location") != [] + after + Application.put_env(:cadet, :identity_providers, original_config) + end + end + end end diff --git a/test/cadet_web/router_test.exs b/test/cadet_web/router_test.exs index bcec6425a..e1df6483c 100644 --- a/test/cadet_web/router_test.exs +++ b/test/cadet_web/router_test.exs @@ -2,10 +2,30 @@ defmodule CadetWeb.RouterTest do use CadetWeb.ConnCase alias CadetWeb.Router + alias Cadet.TokenExchange test "Swagger", %{conn: conn} do Router.swagger_info() conn = get(conn, "/swagger/index.html") assert response(conn, 200) end + + describe "route definitions" do + test "GET /auth/saml_redirect_vscode route exists", %{conn: conn} do + # This test verifies the route is defined. The actual endpoint behavior is tested in auth_controller_test + assert CadetWeb.Router.Helpers.auth_path(CadetWeb.Endpoint, :saml_redirect_vscode) == + "/v2/auth/saml_redirect_vscode" + end + + test "GET /auth/exchange route exists", %{conn: conn} do + # This test verifies the route is defined. The actual endpoint behavior is tested in auth_controller_test + assert CadetWeb.Router.Helpers.auth_path(CadetWeb.Endpoint, :exchange) == + "/v2/auth/exchange" + end + + test "POST /auth/refresh route still exists", %{conn: conn} do + assert CadetWeb.Router.Helpers.auth_path(CadetWeb.Endpoint, :refresh) == + "/v2/auth/refresh" + end + end end diff --git a/test/factories/factory.ex b/test/factories/factory.ex index 35d844ddf..747788ecf 100644 --- a/test/factories/factory.ex +++ b/test/factories/factory.ex @@ -5,6 +5,7 @@ defmodule Cadet.Factory do use ExMachina.Ecto, repo: Cadet.Repo use Cadet.Accounts.{ + TokenExchangeFactory, NotificationFactory, UserFactory, CourseRegistrationFactory, diff --git a/test/factories/token_exchange_factory.ex b/test/factories/token_exchange_factory.ex new file mode 100644 index 000000000..a2cb43b74 --- /dev/null +++ b/test/factories/token_exchange_factory.ex @@ -0,0 +1,23 @@ +defmodule Cadet.TokenExchangeFactory do + @moduledoc """ + Factory for TokenExchange entity + """ + + defmacro __using__(_opts) do + quote do + alias Cadet.TokenExchange + + def token_exchange_factory do + user = build(:user) + code_ttl = 60 + + %TokenExchange{ + code: TokenExchange |> generate_code(), + generated_at: Timex.now(), + expires_at: Timex.add(Timex.now(), Timex.Duration.from_seconds(code_ttl)), + user_id: user.id + } + end + end + end +end