Skip to content

Commit 0861a82

Browse files
authored
fix: normalize booleans on join payloads (#1690)
1 parent 6f3981a commit 0861a82

File tree

7 files changed

+215
-6
lines changed

7 files changed

+215
-6
lines changed

lib/realtime_web/channels/payloads/broadcast.ex

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@ defmodule RealtimeWeb.Channels.Payloads.Broadcast do
55
use Ecto.Schema
66
import Ecto.Changeset
77
alias RealtimeWeb.Channels.Payloads.Join
8+
alias RealtimeWeb.Channels.Payloads.FlexibleBoolean
89

910
embedded_schema do
10-
field :ack, :boolean, default: false
11-
field :self, :boolean, default: false
11+
field :ack, FlexibleBoolean, default: false
12+
field :self, FlexibleBoolean, default: false
1213
embeds_one :replay, RealtimeWeb.Channels.Payloads.Broadcast.Replay
1314
end
1415

1516
def changeset(broadcast, attrs) do
16-
cast(broadcast, attrs, [:ack, :self], message: &Join.error_message/2)
17+
broadcast
18+
|> cast(attrs, [:ack, :self], message: &Join.error_message/2)
1719
|> cast_embed(:replay, invalid_message: "unable to parse, expected a map")
1820
end
1921
end

lib/realtime_web/channels/payloads/config.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@ defmodule RealtimeWeb.Channels.Payloads.Config do
88
alias RealtimeWeb.Channels.Payloads.Broadcast
99
alias RealtimeWeb.Channels.Payloads.Presence
1010
alias RealtimeWeb.Channels.Payloads.PostgresChange
11+
alias RealtimeWeb.Channels.Payloads.FlexibleBoolean
1112

1213
embedded_schema do
1314
embeds_one :broadcast, Broadcast
1415
embeds_one :presence, Presence
1516
embeds_many :postgres_changes, PostgresChange
16-
field :private, :boolean, default: false
17+
field :private, FlexibleBoolean, default: false
1718
end
1819

1920
def changeset(config, attrs) do
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
defmodule RealtimeWeb.Channels.Payloads.FlexibleBoolean do
2+
@moduledoc """
3+
Custom Ecto type that handles boolean values coming as strings.
4+
5+
Accepts:
6+
- Boolean values (true/false) - used as-is
7+
- Strings "true", "True", "TRUE", etc. - cast to true
8+
- Strings "false", "False", "FALSE", etc. - cast to false
9+
- Any other value - returns error
10+
"""
11+
use Ecto.Type
12+
13+
@impl true
14+
def type, do: :boolean
15+
16+
@impl true
17+
def cast(value) when is_boolean(value), do: {:ok, value}
18+
19+
def cast(value) when is_binary(value) do
20+
case String.downcase(value) do
21+
"true" -> {:ok, true}
22+
"false" -> {:ok, false}
23+
_ -> :error
24+
end
25+
end
26+
27+
def cast(_), do: :error
28+
29+
@impl true
30+
def load(value), do: {:ok, value}
31+
32+
@impl true
33+
def dump(value) when is_boolean(value), do: {:ok, value}
34+
def dump(_), do: :error
35+
end

lib/realtime_web/channels/payloads/join.ex

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,10 @@ defmodule RealtimeWeb.Channels.Payloads.Join do
5252
type = Keyword.get(meta, :type)
5353

5454
if type,
55-
do: "unable to parse, expected #{type}",
55+
do: "unable to parse, expected #{format_type(type)}",
5656
else: "unable to parse"
5757
end
58+
59+
defp format_type(RealtimeWeb.Channels.Payloads.FlexibleBoolean), do: :boolean
60+
defp format_type(type), do: type
5861
end

lib/realtime_web/channels/payloads/presence.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ defmodule RealtimeWeb.Channels.Payloads.Presence do
55
use Ecto.Schema
66
import Ecto.Changeset
77
alias RealtimeWeb.Channels.Payloads.Join
8+
alias RealtimeWeb.Channels.Payloads.FlexibleBoolean
89

910
embedded_schema do
10-
field :enabled, :boolean, default: true
11+
field :enabled, FlexibleBoolean, default: true
1112
field :key, :any, default: UUID.uuid1(), virtual: true
1213
end
1314

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
defmodule RealtimeWeb.Channels.Payloads.FlexibleBooleanTest do
2+
use ExUnit.Case, async: true
3+
4+
alias RealtimeWeb.Channels.Payloads.FlexibleBoolean
5+
6+
describe "type/0" do
7+
test "returns :boolean" do
8+
assert FlexibleBoolean.type() == :boolean
9+
end
10+
end
11+
12+
describe "cast/1" do
13+
test "casts boolean true as-is" do
14+
assert FlexibleBoolean.cast(true) == {:ok, true}
15+
end
16+
17+
test "casts boolean false as-is" do
18+
assert FlexibleBoolean.cast(false) == {:ok, false}
19+
end
20+
21+
test "casts string 'true' in any case to boolean true" do
22+
assert FlexibleBoolean.cast("true") == {:ok, true}
23+
assert FlexibleBoolean.cast("True") == {:ok, true}
24+
assert FlexibleBoolean.cast("TRUE") == {:ok, true}
25+
assert FlexibleBoolean.cast("tRuE") == {:ok, true}
26+
end
27+
28+
test "casts string 'false' in any case to boolean false" do
29+
assert FlexibleBoolean.cast("false") == {:ok, false}
30+
assert FlexibleBoolean.cast("False") == {:ok, false}
31+
assert FlexibleBoolean.cast("FALSE") == {:ok, false}
32+
assert FlexibleBoolean.cast("fAlSe") == {:ok, false}
33+
end
34+
35+
test "returns error for invalid string values" do
36+
assert FlexibleBoolean.cast("test") == :error
37+
assert FlexibleBoolean.cast("yes") == :error
38+
assert FlexibleBoolean.cast("no") == :error
39+
assert FlexibleBoolean.cast("1") == :error
40+
assert FlexibleBoolean.cast("0") == :error
41+
assert FlexibleBoolean.cast("") == :error
42+
end
43+
44+
test "returns error for non-boolean, non-string values" do
45+
assert FlexibleBoolean.cast(1) == :error
46+
assert FlexibleBoolean.cast(0) == :error
47+
assert FlexibleBoolean.cast(nil) == :error
48+
assert FlexibleBoolean.cast(%{}) == :error
49+
assert FlexibleBoolean.cast([]) == :error
50+
end
51+
end
52+
53+
describe "load/1" do
54+
test "loads boolean values" do
55+
assert FlexibleBoolean.load(true) == {:ok, true}
56+
assert FlexibleBoolean.load(false) == {:ok, false}
57+
end
58+
end
59+
60+
describe "dump/1" do
61+
test "dumps boolean values" do
62+
assert FlexibleBoolean.dump(true) == {:ok, true}
63+
assert FlexibleBoolean.dump(false) == {:ok, false}
64+
end
65+
66+
test "returns error for non-boolean values" do
67+
assert FlexibleBoolean.dump("true") == :error
68+
assert FlexibleBoolean.dump(1) == :error
69+
assert FlexibleBoolean.dump(nil) == :error
70+
end
71+
end
72+
end

test/realtime_web/channels/payloads/join_test.exs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,5 +119,100 @@ defmodule RealtimeWeb.Channels.Payloads.JoinTest do
119119

120120
assert {:ok, %Join{config: %Config{postgres_changes: []}}} = Join.validate(config)
121121
end
122+
123+
test "accepts string 'true' for boolean fields" do
124+
config = %{
125+
"config" => %{
126+
"private" => "true",
127+
"broadcast" => %{"ack" => "true", "self" => "true"},
128+
"presence" => %{"enabled" => "true"}
129+
}
130+
}
131+
132+
assert {:ok, %Join{config: config_result}} = Join.validate(config)
133+
134+
assert %Config{
135+
private: true,
136+
broadcast: %Broadcast{ack: true, self: true},
137+
presence: %Presence{enabled: true}
138+
} = config_result
139+
end
140+
141+
test "accepts string 'True' for boolean fields" do
142+
config = %{
143+
"config" => %{
144+
"private" => "True",
145+
"broadcast" => %{"ack" => "True", "self" => "True"},
146+
"presence" => %{"enabled" => "True"}
147+
}
148+
}
149+
150+
assert {:ok, %Join{config: config_result}} = Join.validate(config)
151+
152+
assert %Config{
153+
private: true,
154+
broadcast: %Broadcast{ack: true, self: true},
155+
presence: %Presence{enabled: true}
156+
} = config_result
157+
end
158+
159+
test "accepts string 'false' for boolean fields" do
160+
config = %{
161+
"config" => %{
162+
"private" => "false",
163+
"broadcast" => %{"ack" => "false", "self" => "false"},
164+
"presence" => %{"enabled" => "false"}
165+
}
166+
}
167+
168+
assert {:ok, %Join{config: config_result}} = Join.validate(config)
169+
170+
assert %Config{
171+
private: false,
172+
broadcast: %Broadcast{ack: false, self: false},
173+
presence: %Presence{enabled: false}
174+
} = config_result
175+
end
176+
177+
test "accepts string 'False' for boolean fields" do
178+
config = %{
179+
"config" => %{
180+
"private" => "False",
181+
"broadcast" => %{"ack" => "False", "self" => "False"},
182+
"presence" => %{"enabled" => "False"}
183+
}
184+
}
185+
186+
assert {:ok, %Join{config: config_result}} = Join.validate(config)
187+
188+
assert %Config{
189+
private: false,
190+
broadcast: %Broadcast{ack: false, self: false},
191+
presence: %Presence{enabled: false}
192+
} = config_result
193+
end
194+
195+
test "rejects invalid boolean strings" do
196+
config = %{
197+
"config" => %{
198+
"private" => "yes",
199+
"broadcast" => %{"ack" => "a", "self" => "b"},
200+
"presence" => %{"enabled" => "no"}
201+
}
202+
}
203+
204+
assert {:error, :invalid_join_payload, errors} = Join.validate(config)
205+
206+
assert errors == %{
207+
config: %{
208+
private: ["unable to parse, expected boolean"],
209+
broadcast: %{
210+
ack: ["unable to parse, expected boolean"],
211+
self: ["unable to parse, expected boolean"]
212+
},
213+
presence: %{enabled: ["unable to parse, expected boolean"]}
214+
}
215+
}
216+
end
122217
end
123218
end

0 commit comments

Comments
 (0)