neiam
/waxx
waxx
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
changes
5e612cd · gmorell · 2026-05-25 03:27
Files changed
modified
lib/waxx_web/api/v1/board_json.ex
+2
−0
@@ -119,6 +119,8 @@ defmodule WaxxWeb.Api.V1.BoardJSON do
| 119 | 119 | %{id: sb.id, name: sb.name, position: sb.position} |
| 120 | 120 | end |
| 121 | 121 | |
| 122 | + def subboard_response(%Subboard{} = sb), do: %{subboard: subboard(sb)} | |
| 123 | + | |
| 122 | 124 | ## Cards --------------------------------------------------------------- |
| 123 | 125 | |
| 124 | 126 | def cards(cards) when is_list(cards) do |
modified
lib/waxx_web/api/v1/card_controller.ex
+31
−5
@@ -20,7 +20,7 @@ defmodule WaxxWeb.Api.V1.CardController do
| 20 | 20 | use WaxxWeb, :controller |
| 21 | 21 | |
| 22 | 22 | alias Waxx.{Accounts, Kanban, Repo} |
| 23 | − alias Waxx.Kanban.{BoardField, BoardLabel, Card} | |
| 23 | + alias Waxx.Kanban.{BoardField, BoardLabel, Card, Subboard} | |
| 24 | 24 | alias WaxxWeb.Api.V1.BoardJSON |
| 25 | 25 | |
| 26 | 26 | action_fallback WaxxWeb.Api.FallbackController |
@@ -75,16 +75,42 @@ defmodule WaxxWeb.Api.V1.CardController do
| 75 | 75 | {:ok, target_stage_id} <- require_param(params, "board_stage_id") do |
| 76 | 76 | target_index = parse_position(params["position"]) |
| 77 | 77 | |
| 78 | − case Kanban.move_card(card, target_stage_id, target_index, actor: user) do | |
| 79 | − {:ok, moved} -> | |
| 80 | − json(conn, BoardJSON.card_response(reload_card(moved))) | |
| 78 | + with {:ok, moved} <- Kanban.move_card(card, target_stage_id, target_index, actor: user), | |
| 79 | + {:ok, final} <- apply_subboard_change(moved, params, user) do | |
| 80 | + json(conn, BoardJSON.card_response(reload_card(final))) | |
| 81 | + else | |
| 82 | + {:error, reason} | |
| 83 | + when reason in [:invalid_transition, :invalid_stage, :invalid_subboard] -> | |
| 84 | + {:error, reason} | |
| 81 | 85 | |
| 82 | − {:error, reason} when reason in [:invalid_transition, :invalid_stage] -> | |
| 86 | + {:error, reason} -> | |
| 83 | 87 | {:error, reason} |
| 84 | 88 | end |
| 85 | 89 | end |
| 86 | 90 | end |
| 87 | 91 | |
| 92 | + # Optional second leg of a move: re-assign the card's subboard. Omitting | |
| 93 | + # the key leaves it alone; `null` clears it to the default row. | |
| 94 | + defp apply_subboard_change(card, params, user) do | |
| 95 | + if Map.has_key?(params, "subboard_id") do | |
| 96 | + case params["subboard_id"] do | |
| 97 | + nil -> | |
| 98 | + Kanban.set_card_subboard(card, nil, actor: user) | |
| 99 | + | |
| 100 | + id when is_binary(id) -> | |
| 101 | + case Repo.get(Subboard, id) do | |
| 102 | + nil -> {:error, :not_found} | |
| 103 | + %Subboard{} = sb -> Kanban.set_card_subboard(card, sb, actor: user) | |
| 104 | + end | |
| 105 | + | |
| 106 | + _ -> | |
| 107 | + {:error, :validation_failed} | |
| 108 | + end | |
| 109 | + else | |
| 110 | + {:ok, card} | |
| 111 | + end | |
| 112 | + end | |
| 113 | + | |
| 88 | 114 | ## Delete ------------------------------------------------------------- |
| 89 | 115 | |
| 90 | 116 | def delete(conn, %{"id" => id}) do |
added
lib/waxx_web/api/v1/subboard_controller.ex
+56
−0
@@ -0,0 +1,56 @@
| 1 | +defmodule WaxxWeb.Api.V1.SubboardController do | |
| 2 | + @moduledoc """ | |
| 3 | + Subboards = rows in the 2-D kanban grid. Owners only. | |
| 4 | + | |
| 5 | + POST /api/v1/boards/:board_id/subboards {name} | |
| 6 | + DELETE /api/v1/subboards/:id | |
| 7 | + | |
| 8 | + Cards previously assigned to a deleted subboard fall back to the | |
| 9 | + default row via the FK's `on_delete: :nilify_all`. | |
| 10 | + """ | |
| 11 | + | |
| 12 | + use WaxxWeb, :controller | |
| 13 | + | |
| 14 | + alias Waxx.{Kanban, Repo} | |
| 15 | + alias Waxx.Kanban.Subboard | |
| 16 | + alias WaxxWeb.Api.V1.BoardJSON | |
| 17 | + | |
| 18 | + action_fallback WaxxWeb.Api.FallbackController | |
| 19 | + | |
| 20 | + def create(conn, %{"board_id" => board_id} = params) do | |
| 21 | + user = conn.assigns.current_scope.user | |
| 22 | + | |
| 23 | + with {:ok, board} <- fetch_owner_board(board_id, user), | |
| 24 | + attrs <- Map.take(params, ["name"]), | |
| 25 | + {:ok, sb} <- Kanban.create_subboard(board, attrs) do | |
| 26 | + conn | |
| 27 | + |> put_status(:created) | |
| 28 | + |> json(BoardJSON.subboard_response(sb)) | |
| 29 | + end | |
| 30 | + end | |
| 31 | + | |
| 32 | + def delete(conn, %{"id" => id}) do | |
| 33 | + user = conn.assigns.current_scope.user | |
| 34 | + | |
| 35 | + with %Subboard{board_id: board_id} = sb <- | |
| 36 | + Repo.get(Subboard, id) || {:error, :not_found}, | |
| 37 | + {:ok, _board} <- fetch_owner_board(board_id, user), | |
| 38 | + {:ok, _} <- Kanban.delete_subboard(sb) do | |
| 39 | + send_resp(conn, :no_content, "") | |
| 40 | + else | |
| 41 | + {:error, _} = err -> err | |
| 42 | + end | |
| 43 | + end | |
| 44 | + | |
| 45 | + defp fetch_owner_board(board_id, user) do | |
| 46 | + case Kanban.get_board_for_user(board_id, user) do | |
| 47 | + nil -> | |
| 48 | + {:error, :not_found} | |
| 49 | + | |
| 50 | + board -> | |
| 51 | + if Kanban.can_manage?(Kanban.role_for(board.id, user)), | |
| 52 | + do: {:ok, board}, | |
| 53 | + else: {:error, :forbidden} | |
| 54 | + end | |
| 55 | + end | |
| 56 | +end |
modified
lib/waxx_web/public_url.ex
+90
−62
@@ -1,15 +1,23 @@
| 1 | 1 | defmodule WaxxWeb.PublicUrl do |
| 2 | 2 | @moduledoc """ |
| 3 | 3 | Derives the public-facing origin (`scheme://host[:port]`) the native |
| 4 | − client should dial back to, given a `%Plug.Conn{}`. | |
| 4 | + client should dial back to. | |
| 5 | 5 | |
| 6 | − Precedence — mirrors `derive_base_url/2` in | |
| 7 | − `dms/src/handlers/api_tokens.rs`: | |
| 6 | + Resolution (highest precedence first): | |
| 8 | 7 | |
| 9 | 8 | 1. `WAXX_PUBLIC_URL` env (explicit override for reverse-proxy setups). |
| 10 | − 2. `X-Forwarded-Proto` + `X-Forwarded-Host` (standard proxy headers). | |
| 11 | − 3. The request's `Host` header (covers LAN access on `0.0.0.0`). | |
| 12 | − 4. The endpoint's configured URL as a last resort. | |
| 9 | + 2. Composed from forwarded headers + conn fallbacks: | |
| 10 | + - scheme: `X-Forwarded-Proto` (first value) if present, else `conn.scheme` | |
| 11 | + - host: `X-Forwarded-Host` (first value, may carry `:port`) if present, | |
| 12 | + else `conn.host` (with `conn.port` appended if non-default) | |
| 13 | + 3. The endpoint's configured URL as a last resort. | |
| 14 | + | |
| 15 | + Proto and host are sourced independently so a proxy that only sets | |
| 16 | + `X-Forwarded-Proto: https` (the common Traefik / nginx default — | |
| 17 | + Host is preserved unchanged) still yields the right scheme. Treating | |
| 18 | + them as a unit caused QRs to encode `http://` behind TLS-terminated | |
| 19 | + proxies, which then broke WebSocket upgrades when Plug.SSL bounced | |
| 20 | + the upgrade with a 301. | |
| 13 | 21 | |
| 14 | 22 | Returned URL has no trailing slash so callers can concatenate |
| 15 | 23 | `"\#{base}/api/..."` without worrying about double slashes. |
@@ -19,62 +27,101 @@ defmodule WaxxWeb.PublicUrl do
| 19 | 27 | |
| 20 | 28 | @spec derive(Plug.Conn.t()) :: String.t() |
| 21 | 29 | def derive(%Plug.Conn{} = conn) do |
| 22 | − cond do | |
| 23 | − override = env_override() -> override | |
| 24 | − forwarded = from_forwarded_headers(conn) -> forwarded | |
| 25 | − host = from_conn_host(conn) -> host | |
| 26 | − true -> endpoint_url() | |
| 30 | + case env_override() do | |
| 31 | + nil -> | |
| 32 | + compose(scheme_from_conn(conn), host_from_conn(conn)) || endpoint_url() | |
| 33 | + | |
| 34 | + url -> | |
| 35 | + url | |
| 27 | 36 | end |
| 28 | 37 | end |
| 29 | 38 | |
| 30 | 39 | @doc """ |
| 31 | − LiveView-flavoured derivation. Pulls `:uri` and `:x_headers` from | |
| 32 | − `Phoenix.LiveView.get_connect_info/2` so the same precedence applies on | |
| 33 | − the connected socket. Falls back to the endpoint URL when the info | |
| 34 | − isn't available (e.g. disconnected first render). | |
| 40 | + LiveView-flavoured derivation. Must be called from `mount/3` (LiveView | |
| 41 | + destroys connect_info after mount). The endpoint must expose `:uri` | |
| 42 | + and `:x_headers` in `connect_info`. | |
| 35 | 43 | """ |
| 36 | 44 | @spec derive(Phoenix.LiveView.Socket.t()) :: String.t() |
| 37 | 45 | def derive(%Phoenix.LiveView.Socket{} = socket) do |
| 38 | − cond do | |
| 39 | − override = env_override() -> | |
| 40 | − override | |
| 46 | + case env_override() do | |
| 47 | + nil -> | |
| 48 | + compose(scheme_from_socket(socket), host_from_socket(socket)) || endpoint_url() | |
| 49 | + | |
| 50 | + url -> | |
| 51 | + url | |
| 52 | + end | |
| 53 | + end | |
| 41 | 54 | |
| 42 | − forwarded = forwarded_from_socket(socket) -> | |
| 43 | − forwarded | |
| 55 | + ## Scheme + host extraction ------------------------------------------ | |
| 44 | 56 | |
| 45 | − uri = uri_from_socket(socket) -> | |
| 46 | − from_uri(uri) | |
| 57 | + defp scheme_from_conn(conn) do | |
| 58 | + forwarded_proto(get_req_header(conn, "x-forwarded-proto")) || | |
| 59 | + to_string(conn.scheme) | |
| 60 | + end | |
| 47 | 61 | |
| 48 | − true -> | |
| 49 | − endpoint_url() | |
| 62 | + defp host_from_conn(conn) do | |
| 63 | + case get_req_header(conn, "x-forwarded-host") do | |
| 64 | + [h | _] -> normalise_host(h) | |
| 65 | + _ -> host_with_port(conn.host, to_string(conn.scheme), conn.port) | |
| 50 | 66 | end |
| 51 | 67 | end |
| 52 | 68 | |
| 53 | − defp forwarded_from_socket(socket) do | |
| 69 | + defp scheme_from_socket(socket) do | |
| 70 | + headers = Phoenix.LiveView.get_connect_info(socket, :x_headers) || [] | |
| 71 | + | |
| 72 | + forwarded_proto([find_header(headers, "x-forwarded-proto")]) || | |
| 73 | + uri_field(socket, :scheme) | |
| 74 | + end | |
| 75 | + | |
| 76 | + defp host_from_socket(socket) do | |
| 54 | 77 | headers = Phoenix.LiveView.get_connect_info(socket, :x_headers) || [] |
| 55 | − proto = find_header(headers, "x-forwarded-proto") | |
| 56 | − host = find_header(headers, "x-forwarded-host") | |
| 57 | 78 | |
| 58 | − if proto && host do | |
| 59 | − scheme = proto |> String.split(",") |> List.first() |> String.trim() | |
| 60 | − strip_trailing_slash("#{scheme}://#{host}") | |
| 79 | + case find_header(headers, "x-forwarded-host") do | |
| 80 | + nil -> uri_host(socket) | |
| 81 | + host -> normalise_host(host) | |
| 61 | 82 | end |
| 62 | 83 | end |
| 63 | 84 | |
| 64 | − defp uri_from_socket(socket), do: Phoenix.LiveView.get_connect_info(socket, :uri) | |
| 85 | + defp uri_field(socket, key) do | |
| 86 | + case Phoenix.LiveView.get_connect_info(socket, :uri) do | |
| 87 | + %URI{} = uri -> Map.get(uri, key) |> to_string_safe() | |
| 88 | + _ -> nil | |
| 89 | + end | |
| 90 | + end | |
| 65 | 91 | |
| 66 | − defp from_uri(%URI{scheme: scheme, host: host, port: port}) when is_binary(host) do | |
| 67 | − base = | |
| 68 | − if default_port?(scheme, port) do | |
| 69 | − "#{scheme}://#{host}" | |
| 70 | − else | |
| 71 | − "#{scheme}://#{host}:#{port}" | |
| 72 | − end | |
| 92 | + defp uri_host(socket) do | |
| 93 | + case Phoenix.LiveView.get_connect_info(socket, :uri) do | |
| 94 | + %URI{host: host, scheme: scheme, port: port} when is_binary(host) -> | |
| 95 | + host_with_port(host, scheme, port) | |
| 73 | 96 | |
| 74 | − strip_trailing_slash(base) | |
| 97 | + _ -> | |
| 98 | + nil | |
| 99 | + end | |
| 75 | 100 | end |
| 76 | 101 | |
| 77 | − defp from_uri(_), do: nil | |
| 102 | + ## Helpers ------------------------------------------------------------ | |
| 103 | + | |
| 104 | + defp compose(nil, _), do: nil | |
| 105 | + defp compose(_, nil), do: nil | |
| 106 | + defp compose(scheme, host), do: strip_trailing_slash("#{scheme}://#{host}") | |
| 107 | + | |
| 108 | + defp forwarded_proto([proto | _]) when is_binary(proto) do | |
| 109 | + proto |> String.split(",") |> List.first() |> String.trim() | |
| 110 | + end | |
| 111 | + | |
| 112 | + defp forwarded_proto(_), do: nil | |
| 113 | + | |
| 114 | + # X-Forwarded-Host may itself be a comma-separated list (chained proxies). | |
| 115 | + # Take the first value verbatim — it may already include `:port`. | |
| 116 | + defp normalise_host(h) when is_binary(h) do | |
| 117 | + h |> String.split(",") |> List.first() |> String.trim() | |
| 118 | + end | |
| 119 | + | |
| 120 | + defp host_with_port(host, _scheme, nil), do: host | |
| 121 | + | |
| 122 | + defp host_with_port(host, scheme, port) do | |
| 123 | + if default_port?(scheme, port), do: host, else: "#{host}:#{port}" | |
| 124 | + end | |
| 78 | 125 | |
| 79 | 126 | defp default_port?("http", 80), do: true |
| 80 | 127 | defp default_port?("https", 443), do: true |
@@ -95,31 +142,12 @@ defmodule WaxxWeb.PublicUrl do
| 95 | 142 | end |
| 96 | 143 | end |
| 97 | 144 | |
| 98 | − defp from_forwarded_headers(conn) do | |
| 99 | − with [proto | _] <- get_req_header(conn, "x-forwarded-proto"), | |
| 100 | − [host | _] <- get_req_header(conn, "x-forwarded-host") do | |
| 101 | − scheme = proto |> String.split(",") |> List.first() |> String.trim() | |
| 102 | − strip_trailing_slash("#{scheme}://#{host}") | |
| 103 | − else | |
| 104 | − _ -> nil | |
| 105 | − end | |
| 106 | − end | |
| 107 | − | |
| 108 | − # Plug normalises the Host header into `conn.host` and `conn.port`. | |
| 109 | − defp from_conn_host(%Plug.Conn{host: host, scheme: scheme, port: port}) | |
| 110 | − when is_binary(host) and host != "" do | |
| 111 | − if default_port?(to_string(scheme), port) do | |
| 112 | − strip_trailing_slash("#{scheme}://#{host}") | |
| 113 | − else | |
| 114 | − strip_trailing_slash("#{scheme}://#{host}:#{port}") | |
| 115 | − end | |
| 116 | − end | |
| 117 | − | |
| 118 | − defp from_conn_host(_), do: nil | |
| 119 | − | |
| 120 | 145 | defp endpoint_url do |
| 121 | 146 | WaxxWeb.Endpoint.url() |> strip_trailing_slash() |
| 122 | 147 | end |
| 123 | 148 | |
| 149 | + defp to_string_safe(nil), do: nil | |
| 150 | + defp to_string_safe(v), do: to_string(v) | |
| 151 | + | |
| 124 | 152 | defp strip_trailing_slash(s), do: String.replace_suffix(s, "/", "") |
| 125 | 153 | end |
modified
lib/waxx_web/router.ex
+3
−0
@@ -148,6 +148,9 @@ defmodule WaxxWeb.Router do
| 148 | 148 | get "/users/invites", AppInviteController, :index |
| 149 | 149 | post "/users/invites", AppInviteController, :create |
| 150 | 150 | delete "/users/invites/:id", AppInviteController, :delete |
| 151 | + | |
| 152 | + post "/boards/:board_id/subboards", SubboardController, :create | |
| 153 | + delete "/subboards/:id", SubboardController, :delete | |
| 151 | 154 | end |
| 152 | 155 | end |
| 153 | 156 | end |
added
test/waxx_web/api/v1/subboards_test.exs
+133
−0
@@ -0,0 +1,133 @@
| 1 | +defmodule WaxxWeb.Api.V1.SubboardsTest do | |
| 2 | + use WaxxWeb.ConnCase, async: true | |
| 3 | + | |
| 4 | + import Waxx.AccountsFixtures | |
| 5 | + import Waxx.KanbanFixtures | |
| 6 | + | |
| 7 | + alias Waxx.{Kanban, Repo} | |
| 8 | + | |
| 9 | + defp setup_owner do | |
| 10 | + user = confirmed_user_fixture() | |
| 11 | + board = board_fixture(user) | |
| 12 | + %{user: user, board: board, token: api_token_fixture(user)} | |
| 13 | + end | |
| 14 | + | |
| 15 | + defp auth(conn, token), do: put_req_header(conn, "authorization", "Bearer " <> token) | |
| 16 | + | |
| 17 | + describe "POST /api/v1/boards/:id/subboards" do | |
| 18 | + test "owner can create a subboard", %{conn: conn} do | |
| 19 | + %{token: token, board: board} = setup_owner() | |
| 20 | + | |
| 21 | + conn = | |
| 22 | + conn | |
| 23 | + |> auth(token) | |
| 24 | + |> post(~p"/api/v1/boards/#{board.id}/subboards", %{name: "Backend"}) | |
| 25 | + | |
| 26 | + assert %{"subboard" => sb} = json_response(conn, 201) | |
| 27 | + assert sb["name"] == "Backend" | |
| 28 | + assert sb["position"] == 0 | |
| 29 | + end | |
| 30 | + | |
| 31 | + test "non-owner gets 403", %{conn: conn} do | |
| 32 | + owner = confirmed_user_fixture() | |
| 33 | + editor = confirmed_user_fixture() | |
| 34 | + board = board_fixture(owner) | |
| 35 | + {:ok, _} = Kanban.add_member(board, editor, "editor") | |
| 36 | + token = api_token_fixture(editor) | |
| 37 | + | |
| 38 | + conn = | |
| 39 | + conn | |
| 40 | + |> auth(token) | |
| 41 | + |> post(~p"/api/v1/boards/#{board.id}/subboards", %{name: "X"}) | |
| 42 | + | |
| 43 | + assert json_response(conn, 403) | |
| 44 | + end | |
| 45 | + end | |
| 46 | + | |
| 47 | + describe "DELETE /api/v1/subboards/:id" do | |
| 48 | + test "owner can delete; cards fall back to default row", %{conn: conn} do | |
| 49 | + %{token: token, board: board, user: user} = setup_owner() | |
| 50 | + {:ok, sb} = Kanban.create_subboard(board, %{"name" => "QA"}) | |
| 51 | + card = card_fixture(board, user) | |
| 52 | + {:ok, _} = Kanban.set_card_subboard(card, sb) | |
| 53 | + | |
| 54 | + conn = conn |> auth(token) |> delete(~p"/api/v1/subboards/#{sb.id}") | |
| 55 | + assert response(conn, 204) == "" | |
| 56 | + | |
| 57 | + refreshed = Repo.get!(Waxx.Kanban.Card, card.id) | |
| 58 | + assert is_nil(refreshed.subboard_id) | |
| 59 | + end | |
| 60 | + | |
| 61 | + test "non-owner gets 403", %{conn: conn} do | |
| 62 | + owner = confirmed_user_fixture() | |
| 63 | + editor = confirmed_user_fixture() | |
| 64 | + board = board_fixture(owner) | |
| 65 | + {:ok, _} = Kanban.add_member(board, editor, "editor") | |
| 66 | + {:ok, sb} = Kanban.create_subboard(board, %{"name" => "X"}) | |
| 67 | + token = api_token_fixture(editor) | |
| 68 | + | |
| 69 | + conn = conn |> auth(token) |> delete(~p"/api/v1/subboards/#{sb.id}") | |
| 70 | + assert json_response(conn, 403) | |
| 71 | + end | |
| 72 | + end | |
| 73 | + | |
| 74 | + describe "POST /api/v1/cards/:id/move with subboard_id" do | |
| 75 | + test "moves the card to the target subboard", %{conn: conn} do | |
| 76 | + %{token: token, board: board, user: user} = setup_owner() | |
| 77 | + {:ok, sb} = Kanban.create_subboard(board, %{"name" => "Backend"}) | |
| 78 | + card = card_fixture(board, user) | |
| 79 | + [_todo, done] = board.stages | |
| 80 | + | |
| 81 | + conn = | |
| 82 | + conn | |
| 83 | + |> auth(token) | |
| 84 | + |> post(~p"/api/v1/cards/#{card.id}/move", %{ | |
| 85 | + board_stage_id: done.id, | |
| 86 | + subboard_id: sb.id | |
| 87 | + }) | |
| 88 | + | |
| 89 | + assert %{"card" => c} = json_response(conn, 200) | |
| 90 | + assert c["board_stage_id"] == done.id | |
| 91 | + assert c["subboard_id"] == sb.id | |
| 92 | + end | |
| 93 | + | |
| 94 | + test "null subboard_id clears the assignment", %{conn: conn} do | |
| 95 | + %{token: token, board: board, user: user} = setup_owner() | |
| 96 | + {:ok, sb} = Kanban.create_subboard(board, %{"name" => "Backend"}) | |
| 97 | + card = card_fixture(board, user) | |
| 98 | + {:ok, _} = Kanban.set_card_subboard(card, sb) | |
| 99 | + [_todo, done] = board.stages | |
| 100 | + | |
| 101 | + conn = | |
| 102 | + conn | |
| 103 | + |> auth(token) | |
| 104 | + |> post(~p"/api/v1/cards/#{card.id}/move", %{ | |
| 105 | + board_stage_id: done.id, | |
| 106 | + subboard_id: nil | |
| 107 | + }) | |
| 108 | + | |
| 109 | + assert %{"card" => c} = json_response(conn, 200) | |
| 110 | + assert c["subboard_id"] == nil | |
| 111 | + end | |
| 112 | + | |
| 113 | + test "rejects a subboard from a different board", %{conn: conn} do | |
| 114 | + %{token: token, board: board, user: user} = setup_owner() | |
| 115 | + card = card_fixture(board, user) | |
| 116 | + [_todo, done] = board.stages | |
| 117 | + | |
| 118 | + other = confirmed_user_fixture() | |
| 119 | + other_board = board_fixture(other) | |
| 120 | + {:ok, foreign_sb} = Kanban.create_subboard(other_board, %{"name" => "X"}) | |
| 121 | + | |
| 122 | + conn = | |
| 123 | + conn | |
| 124 | + |> auth(token) | |
| 125 | + |> post(~p"/api/v1/cards/#{card.id}/move", %{ | |
| 126 | + board_stage_id: done.id, | |
| 127 | + subboard_id: foreign_sb.id | |
| 128 | + }) | |
| 129 | + | |
| 130 | + assert json_response(conn, 422)["error"]["code"] == "invalid_subboard" | |
| 131 | + end | |
| 132 | + end | |
| 133 | +end |
modified
test/waxx_web/public_url_test.exs
+16
−0
@@ -36,6 +36,22 @@ defmodule WaxxWeb.PublicUrlTest do
| 36 | 36 | assert PublicUrl.derive(conn) == "https://proxy.example.com" |
| 37 | 37 | end |
| 38 | 38 | |
| 39 | + test "X-Forwarded-Proto alone upgrades the scheme; host comes from conn" do | |
| 40 | + conn = | |
| 41 | + build_conn(host: "waxx.neiam.co", scheme: :http, port: 80) | |
| 42 | + |> Plug.Conn.put_req_header("x-forwarded-proto", "https") | |
| 43 | + | |
| 44 | + assert PublicUrl.derive(conn) == "https://waxx.neiam.co" | |
| 45 | + end | |
| 46 | + | |
| 47 | + test "X-Forwarded-Host alone keeps conn scheme; host from header" do | |
| 48 | + conn = | |
| 49 | + build_conn(host: "internal", scheme: :https, port: 443) | |
| 50 | + |> Plug.Conn.put_req_header("x-forwarded-host", "edge.example.com") | |
| 51 | + | |
| 52 | + assert PublicUrl.derive(conn) == "https://edge.example.com" | |
| 53 | + end | |
| 54 | + | |
| 39 | 55 | test "X-Forwarded-Proto with comma-separated values picks the first" do |
| 40 | 56 | conn = |
| 41 | 57 | build_conn(host: "internal.example.com", scheme: :http, port: 80) |
Parents: 1405e6a