Skip to content

Commit 0f774fb

Browse files
committed
fix: lifecycle guard for play_card API endpoint (#2631)
Reject card plays with 422 Unprocessable Entity when: - game.started_at == nil (game has not started yet) - game.finished_at != nil (game has already ended) Add tests covering both lifecycle guard cases, and update existing tests that exercise the success and card/player validation paths to first start the game, since those paths now require an active game.
1 parent 98eaf66 commit 0f774fb

2 files changed

Lines changed: 63 additions & 27 deletions

File tree

copi.owasp.org/lib/copi_web/controllers/api_controller.ex

Lines changed: 34 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,36 +3,43 @@ defmodule CopiWeb.ApiController do
33
alias Copi.Cornucopia.Game
44
def play_card(conn, %{"game_id" => game_id, "player_id" => player_id, "dealt_card_id" => dealt_card_id}) do
55
with {:ok, game} <- Game.find(game_id) do
6-
player = Enum.find(game.players, fn player -> player.id == player_id end)
7-
if player do
8-
dealt_card = Enum.find(player.dealt_cards, fn dealt_card -> Integer.to_string(dealt_card.id) == dealt_card_id end)
9-
if dealt_card do
10-
current_round = game.rounds_played + 1
11-
cond do
12-
dealt_card.played_in_round ->
13-
conn |> put_status(:not_acceptable) |> json(%{"error" => "Card already played"})
14-
Enum.find(player.dealt_cards, fn dealt_card -> dealt_card.played_in_round == current_round end) ->
15-
conn |> put_status(:forbidden) |> json(%{"error" => "Player already played a card in this round"})
16-
true ->
17-
dealt_card = Ecto.Changeset.change dealt_card, played_in_round: current_round
18-
case Copi.Repo.update dealt_card do
19-
{:ok, dealt_card} ->
20-
with {:ok, updated_game} <- Game.find(game.id) do
21-
CopiWeb.Endpoint.broadcast(topic(game.id), "game:updated", updated_game)
22-
else
23-
{:error, _reason} ->
24-
conn |> put_status(:internal_server_error) |> json(%{"error" => "Could not find updated game"})
6+
cond do
7+
is_nil(game.started_at) ->
8+
conn |> put_status(:unprocessable_entity) |> json(%{"error" => "Game has not started yet"})
9+
not is_nil(game.finished_at) ->
10+
conn |> put_status(:unprocessable_entity) |> json(%{"error" => "Game has already ended"})
11+
true ->
12+
player = Enum.find(game.players, fn player -> player.id == player_id end)
13+
if player do
14+
dealt_card = Enum.find(player.dealt_cards, fn dealt_card -> Integer.to_string(dealt_card.id) == dealt_card_id end)
15+
if dealt_card do
16+
current_round = game.rounds_played + 1
17+
cond do
18+
dealt_card.played_in_round ->
19+
conn |> put_status(:not_acceptable) |> json(%{"error" => "Card already played"})
20+
Enum.find(player.dealt_cards, fn dealt_card -> dealt_card.played_in_round == current_round end) ->
21+
conn |> put_status(:forbidden) |> json(%{"error" => "Player already played a card in this round"})
22+
true ->
23+
dealt_card = Ecto.Changeset.change dealt_card, played_in_round: current_round
24+
case Copi.Repo.update dealt_card do
25+
{:ok, dealt_card} ->
26+
with {:ok, updated_game} <- Game.find(game.id) do
27+
CopiWeb.Endpoint.broadcast(topic(game.id), "game:updated", updated_game)
28+
else
29+
{:error, _reason} ->
30+
conn |> put_status(:internal_server_error) |> json(%{"error" => "Could not find updated game"})
31+
end
32+
conn |> json(%{"id" => dealt_card.id})
33+
{:error, _changeset} ->
34+
conn |> put_status(:internal_server_error) |> json(%{"error" => "Could not update dealt card"})
2535
end
26-
conn |> json(%{"id" => dealt_card.id})
27-
{:error, _changeset} ->
28-
conn |> put_status(:internal_server_error) |> json(%{"error" => "Could not update dealt card"})
2936
end
37+
else
38+
conn |> put_status(:not_found) |> json(%{"error" => "Could not find player and dealt card"})
39+
end
40+
else
41+
conn |> put_status(:not_found) |> json(%{"error" => "Player not found in this game"})
3042
end
31-
else
32-
conn |> put_status(:not_found) |> json(%{"error" => "Could not find player and dealt card"})
33-
end
34-
else
35-
conn |> put_status(:not_found) |> json(%{"error" => "Player not found in this game"})
3643
end
3744
else
3845
{:error, _reason} -> conn |> put_status(:not_found) |> json(%{"error" => "Could not find game"})

copi.owasp.org/test/copi_web/controllers/api_controller_test.exs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ defmodule CopiWeb.ApiControllerTest do
2222
end
2323

2424
test "play_card success", %{conn: conn, game: game, player: player, dealt_card: dealt_card} do
25+
{:ok, _} = Cornucopia.update_game(game, %{started_at: DateTime.truncate(DateTime.utc_now(), :second)})
26+
2527
conn = put(conn, "/api/games/#{game.id}/players/#{player.id}/card", %{
2628
"game_id" => game.id,
2729
"player_id" => player.id,
@@ -35,6 +37,7 @@ defmodule CopiWeb.ApiControllerTest do
3537
end
3638

3739
test "play_card fails if card already played", %{conn: conn, game: game, player: player, dealt_card: dealt_card} do
40+
{:ok, _} = Cornucopia.update_game(game, %{started_at: DateTime.truncate(DateTime.utc_now(), :second)})
3841
{:ok, _} = Repo.update(Ecto.Changeset.change(dealt_card, played_in_round: 1))
3942

4043
conn = put(conn, "/api/games/#{game.id}/players/#{player.id}/card", %{
@@ -75,7 +78,33 @@ defmodule CopiWeb.ApiControllerTest do
7578
assert json_response(conn, 404)["error"] == "Player not found in this game"
7679
end
7780

81+
test "play_card returns 422 when game has not started", %{conn: conn, game: game, player: player, dealt_card: dealt_card} do
82+
conn = put(conn, "/api/games/#{game.id}/players/#{player.id}/card", %{
83+
"game_id" => game.id,
84+
"player_id" => player.id,
85+
"dealt_card_id" => to_string(dealt_card.id)
86+
})
87+
88+
assert json_response(conn, 422)["error"] == "Game has not started yet"
89+
end
90+
91+
test "play_card returns 422 when game has already ended", %{conn: conn, game: game, player: player, dealt_card: dealt_card} do
92+
{:ok, _} = Cornucopia.update_game(game, %{
93+
started_at: DateTime.truncate(DateTime.utc_now(), :second),
94+
finished_at: DateTime.truncate(DateTime.utc_now(), :second)
95+
})
96+
97+
conn = put(conn, "/api/games/#{game.id}/players/#{player.id}/card", %{
98+
"game_id" => game.id,
99+
"player_id" => player.id,
100+
"dealt_card_id" => to_string(dealt_card.id)
101+
})
102+
103+
assert json_response(conn, 422)["error"] == "Game has already ended"
104+
end
105+
78106
test "play_card fails if player already played in round", %{conn: conn, game: game, player: player, dealt_card: dealt_card} do
107+
{:ok, _} = Cornucopia.update_game(game, %{started_at: DateTime.truncate(DateTime.utc_now(), :second)})
79108
{:ok, card2} = Cornucopia.create_card(%{
80109
category: "Cornucopia", value: "K", description: "desc", misc: "misc",
81110
edition: "webapp", external_id: "2", language: "en", version: "1",

0 commit comments

Comments
 (0)