diff --git a/copi.owasp.org/test/copi/cornucopia_test.exs b/copi.owasp.org/test/copi/cornucopia_test.exs
index 1d526d467..347162c91 100644
--- a/copi.owasp.org/test/copi/cornucopia_test.exs
+++ b/copi.owasp.org/test/copi/cornucopia_test.exs
@@ -222,4 +222,25 @@ defmodule Copi.CornucopiaTest do
assert %Ecto.Changeset{} = Cornucopia.change_card(card)
end
end
+ describe "schema changesets and find functions" do
+ alias Copi.Cornucopia.{DealtCard, Vote, Player}
+
+ test "DealtCard.changeset/2 returns a changeset" do
+ changeset = DealtCard.changeset(%DealtCard{}, %{})
+ assert changeset.valid?
+ end
+
+ test "DealtCard.find/1 returns error when not found" do
+ assert {:error, :not_found} = DealtCard.find(999_999_999)
+ end
+
+ test "Vote.changeset/2 returns a changeset" do
+ changeset = Vote.changeset(%Vote{}, %{})
+ assert changeset.valid?
+ end
+
+ test "Player.find/1 returns error when not found" do
+ assert {:error, :not_found} = Player.find("00000000000000000000000000")
+ end
+ end
end
diff --git a/copi.owasp.org/test/copi_web/components/core_components_test.exs b/copi.owasp.org/test/copi_web/components/core_components_test.exs
index 4863a725e..8aa918421 100644
--- a/copi.owasp.org/test/copi_web/components/core_components_test.exs
+++ b/copi.owasp.org/test/copi_web/components/core_components_test.exs
@@ -119,4 +119,51 @@ defmodule CopiWeb.CoreComponentsTest do
assert html =~ "Title"
assert html =~ "Item 1"
end
+ test "renders copy_url_button" do
+ assigns = %{uri: "http://example.com/game/123"}
+ html = rendered_to_string(~H"""
+
+ """)
+ assert html =~ "copy-url-btn"
+ assert html =~ "http://example.com/game/123"
+ end
+
+ test "renders header component" do
+ assigns = %{}
+ html = rendered_to_string(~H"""
+
+ Page Title
+ <:subtitle>A subtitle
+
+ """)
+ assert html =~ "Page Title"
+ assert html =~ "A subtitle"
+ end
+
+ test "renders header2 component" do
+ assigns = %{}
+ html = rendered_to_string(~H"""
+ Section Title
+ """)
+ assert html =~ "Section Title"
+ assert html =~ "
Click me
+ """)
+ assert html =~ "Click me"
+ assert html =~ "Submit
+ """)
+ assert html =~ "Submit"
+ assert html =~ "bg-indigo-600"
+ end
end
diff --git a/copi.owasp.org/test/copi_web/controllers/api_controller_test.exs b/copi.owasp.org/test/copi_web/controllers/api_controller_test.exs
index d41a415b9..98eb2ed6b 100644
--- a/copi.owasp.org/test/copi_web/controllers/api_controller_test.exs
+++ b/copi.owasp.org/test/copi_web/controllers/api_controller_test.exs
@@ -69,4 +69,24 @@ defmodule CopiWeb.ApiControllerTest do
assert json_response(conn, 403)["error"] == "Player already played a card in this round"
end
+
+ test "play_card fails if game not found", %{conn: conn, player: player, dealt_card: dealt_card} do
+ conn = put(conn, "/api/games/nonexistent-game-id/players/#{player.id}/card", %{
+ "game_id" => "nonexistent-game-id",
+ "player_id" => player.id,
+ "dealt_card_id" => to_string(dealt_card.id)
+ })
+
+ assert json_response(conn, 404)["error"] == "Could not find game"
+ end
+
+ test "play_card fails if player not in game", %{conn: conn, game: game, dealt_card: dealt_card} do
+ conn = put(conn, "/api/games/#{game.id}/players/nonexistent-player-id/card", %{
+ "game_id" => game.id,
+ "player_id" => "nonexistent-player-id",
+ "dealt_card_id" => to_string(dealt_card.id)
+ })
+
+ assert json_response(conn, 404)["error"] == "Could not find player and dealt card"
+ end
end
diff --git a/copi.owasp.org/test/copi_web/controllers/card_controller_test.exs b/copi.owasp.org/test/copi_web/controllers/card_controller_test.exs
index 2d9a0ad35..1d4f60dba 100644
--- a/copi.owasp.org/test/copi_web/controllers/card_controller_test.exs
+++ b/copi.owasp.org/test/copi_web/controllers/card_controller_test.exs
@@ -44,4 +44,11 @@ defmodule CopiWeb.CardControllerTest do
end
+ describe "format_capec" do
+ test "returns refs unchanged" do
+ alias CopiWeb.CardController
+ assert CardController.format_capec([1, 2, 3]) == [1, 2, 3]
+ assert CardController.format_capec([]) == []
+ end
+ end
end
diff --git a/copi.owasp.org/test/copi_web/live/game_live/show_test.exs b/copi.owasp.org/test/copi_web/live/game_live/show_test.exs
index fb19034e7..5116b3d81 100644
--- a/copi.owasp.org/test/copi_web/live/game_live/show_test.exs
+++ b/copi.owasp.org/test/copi_web/live/game_live/show_test.exs
@@ -128,5 +128,21 @@ defmodule CopiWeb.GameLive.ShowTest do
alias CopiWeb.GameLive.Show
assert Show.card_played_in_round([], 1) == nil
end
+
+ test "handle_info with non-matching topic is ignored", %{conn: conn, game: game} do
+ {:ok, show_live, _html} = live(conn, "/games/#{game.id}")
+
+ {:ok, other_game} = Cornucopia.create_game(%{name: "Other Game", edition: "webapp"})
+ {:ok, updated_other} = Cornucopia.Game.find(other_game.id)
+
+ send(show_live.pid, %{
+ topic: "game:#{other_game.id}",
+ event: "game:updated",
+ payload: updated_other
+ })
+
+ :timer.sleep(50)
+ assert render(show_live) =~ game.name
+ end
end
end
diff --git a/copi.owasp.org/test/copi_web/live/player_live/show_test.exs b/copi.owasp.org/test/copi_web/live/player_live/show_test.exs
index d41fec568..202b17079 100644
--- a/copi.owasp.org/test/copi_web/live/player_live/show_test.exs
+++ b/copi.owasp.org/test/copi_web/live/player_live/show_test.exs
@@ -111,6 +111,30 @@ defmodule CopiWeb.PlayerLive.ShowTest do
# With no players, no one is still to play → round_open? is false → round_closed? is true
assert Show.round_closed?(%{players: [], rounds_played: 0}) == true
+ # last_round? returns false when a player still has a nil-round card
+ player_with_unplayed = %{dealt_cards: [%{played_in_round: nil}]}
+ refute Show.last_round?(%{players: [player_with_unplayed], rounds_played: 0})
+
+ # last_round? returns true when all cards are played
+ player_all_played = %{dealt_cards: [%{played_in_round: 1}]}
+ assert Show.last_round?(%{players: [player_all_played], rounds_played: 0})
+
+ # player_first places current player first
+ other = %{id: "other-id"}
+ current = %{id: "current-id"}
+ current_player = %{id: "current-id"}
+ sorted = Show.player_first([other, current], current_player)
+ assert List.first(sorted).id == "current-id"
+
+ # get_vote returns nil when no vote, returns vote when found
+ dealt_no_vote = %{votes: []}
+ fake_player = %{id: "player-id"}
+ assert Show.get_vote(dealt_no_vote, fake_player) == nil
+
+ vote = %{player_id: "player-id"}
+ dealt_with_vote = %{votes: [vote]}
+ assert Show.get_vote(dealt_with_vote, fake_player) == vote
+
assert Show.display_game_session("webapp") == "Cornucopia Web Session:"
assert Show.display_game_session("ecommerce") == "Cornucopia Web Session:"
assert Show.display_game_session("mobileapp") == "Cornucopia Mobile Session:"
@@ -119,5 +143,114 @@ defmodule CopiWeb.PlayerLive.ShowTest do
assert Show.display_game_session("mlsec") == "Elevation of MLSec Session:"
assert Show.display_game_session("eop") == "EoP Session:"
end
+
+ test "next_round when round is closed and not last round advances rounds_played",
+ %{conn: conn, player: player} do
+ game_id = player.game_id
+ {:ok, game} = Cornucopia.Game.find(game_id)
+
+ Copi.Repo.update!(
+ Ecto.Changeset.change(game, started_at: DateTime.truncate(DateTime.utc_now(), :second))
+ )
+
+ {:ok, card1} =
+ Cornucopia.create_card(%{
+ category: "C", value: "NR1", description: "D", edition: "webapp",
+ version: "2.2", external_id: "NR_CLOSED1", language: "en", misc: "m",
+ owasp_scp: [], owasp_devguide: [], owasp_asvs: [], owasp_appsensor: [],
+ capec: [], safecode: [], owasp_mastg: [], owasp_masvs: []
+ })
+
+ {:ok, card2} =
+ Cornucopia.create_card(%{
+ category: "C", value: "NR2", description: "D", edition: "webapp",
+ version: "2.2", external_id: "NR_CLOSED2", language: "en", misc: "m",
+ owasp_scp: [], owasp_devguide: [], owasp_asvs: [], owasp_appsensor: [],
+ capec: [], safecode: [], owasp_mastg: [], owasp_masvs: []
+ })
+
+ # Card played in round 1 → round_open? = false
+ Copi.Repo.insert!(%Copi.Cornucopia.DealtCard{
+ player_id: player.id, card_id: card1.id, played_in_round: 1
+ })
+ # Unplayed card → last_round? = false
+ Copi.Repo.insert!(%Copi.Cornucopia.DealtCard{
+ player_id: player.id, card_id: card2.id, played_in_round: nil
+ })
+
+ {:ok, show_live, _html} = live(conn, "/games/#{game_id}/players/#{player.id}")
+ render_click(show_live, "next_round", %{})
+ :timer.sleep(100)
+
+ {:ok, updated_game} = Cornucopia.Game.find(game_id)
+ assert updated_game.rounds_played == 1
+ assert updated_game.finished_at == nil
+ end
+
+ test "next_round when round is closed and IS last round sets finished_at",
+ %{conn: conn, player: player} do
+ game_id = player.game_id
+ {:ok, game} = Cornucopia.Game.find(game_id)
+
+ Copi.Repo.update!(
+ Ecto.Changeset.change(game, started_at: DateTime.truncate(DateTime.utc_now(), :second))
+ )
+
+ {:ok, card} =
+ Cornucopia.create_card(%{
+ category: "C", value: "NR3", description: "D", edition: "webapp",
+ version: "2.2", external_id: "NR_LAST1", language: "en", misc: "m",
+ owasp_scp: [], owasp_devguide: [], owasp_asvs: [], owasp_appsensor: [],
+ capec: [], safecode: [], owasp_mastg: [], owasp_masvs: []
+ })
+
+ # Only card is played → no nil-round cards → last_round? = true
+ Copi.Repo.insert!(%Copi.Cornucopia.DealtCard{
+ player_id: player.id, card_id: card.id, played_in_round: 1
+ })
+
+ {:ok, show_live, _html} = live(conn, "/games/#{game_id}/players/#{player.id}")
+ render_click(show_live, "next_round", %{})
+ :timer.sleep(100)
+
+ {:ok, updated_game} = Cornucopia.Game.find(game_id)
+ assert updated_game.rounds_played == 1
+ assert updated_game.finished_at != nil
+ end
+
+ test "toggle_vote adds then removes a vote for a dealt card", %{conn: conn, player: player} do
+ game_id = player.game_id
+ {:ok, game} = Cornucopia.Game.find(game_id)
+
+ Copi.Repo.update!(
+ Ecto.Changeset.change(game, started_at: DateTime.truncate(DateTime.utc_now(), :second))
+ )
+
+ {:ok, card} =
+ Cornucopia.create_card(%{
+ category: "C", value: "TV1", description: "D", edition: "webapp",
+ version: "2.2", external_id: "TV_CARD1", language: "en", misc: "m",
+ owasp_scp: [], owasp_devguide: [], owasp_asvs: [], owasp_appsensor: [],
+ capec: [], safecode: [], owasp_mastg: [], owasp_masvs: []
+ })
+
+ dealt = Copi.Repo.insert!(%Copi.Cornucopia.DealtCard{
+ player_id: player.id, card_id: card.id, played_in_round: 1
+ })
+
+ {:ok, show_live, _html} = live(conn, "/games/#{game_id}/players/#{player.id}")
+
+ render_click(show_live, "toggle_vote", %{"dealt_card_id" => to_string(dealt.id)})
+ :timer.sleep(100)
+
+ {:ok, updated_dealt} = Copi.Cornucopia.DealtCard.find(to_string(dealt.id))
+ assert length(updated_dealt.votes) == 1
+
+ render_click(show_live, "toggle_vote", %{"dealt_card_id" => to_string(dealt.id)})
+ :timer.sleep(100)
+
+ {:ok, updated_dealt2} = Copi.Cornucopia.DealtCard.find(to_string(dealt.id))
+ assert length(updated_dealt2.votes) == 0
+ end
end
end
diff --git a/copi.owasp.org/test/copi_web/plugs/rate_limiter_plug_test.exs b/copi.owasp.org/test/copi_web/plugs/rate_limiter_plug_test.exs
index 107d4d31a..1e18a2de7 100644
--- a/copi.owasp.org/test/copi_web/plugs/rate_limiter_plug_test.exs
+++ b/copi.owasp.org/test/copi_web/plugs/rate_limiter_plug_test.exs
@@ -74,4 +74,8 @@ defmodule CopiWeb.Plugs.RateLimiterPlugTest do
assert conn.status != 429
refute conn.halted
end
+ test "init/1 returns opts unchanged" do
+ assert RateLimiterPlug.init([]) == []
+ assert RateLimiterPlug.init(foo: :bar) == [foo: :bar]
+ end
end
diff --git a/scripts/convert.py b/scripts/convert.py
index 3f60754fc..9021d3012 100644
--- a/scripts/convert.py
+++ b/scripts/convert.py
@@ -812,7 +812,7 @@ def get_replacement_value_from_dict(el_text: str, replacement_values: List[Tuple
for k, v in replacement_values:
# Avoid expensive regex if key is not even in text
- if k.strip() not in el_text:
+ if not isinstance(k, str) or k.strip() not in el_text:
continue
el_new = get_replacement_mapping_value(k, v, el_text)
@@ -891,7 +891,7 @@ def get_template_for_edition(layout: str = "guide", template: str = "bridge", ed
def get_valid_layout_choices() -> List[str]:
layouts = []
- if convert_vars.args.layout.lower() == "all" or convert_vars.args.layout == "":
+ if not convert_vars.args.layout or convert_vars.args.layout.lower() == "all" or convert_vars.args.layout == "":
for layout in convert_vars.LAYOUT_CHOICES:
if layout not in ("all", "guide"):
layouts.append(layout)