From d7be59707a39b91715ea37845eaaca89b44ab09e Mon Sep 17 00:00:00 2001 From: Jamil Date: Thu, 27 Feb 2025 23:41:11 +0000 Subject: [PATCH] fix(portal): Improve resource address validation (#8288) We had a number of validation issues: - DNS resources allow address `1.1.1.1` or `1.1.1.1/32`. These are not valid and will cause issues during resolution. - IP resources were allowing basically any string character on `edit` caused by a logic bug in the changeset - CIDR resources, same as above - `*.*.*.*.google.com` and similar DNS wildcard resources were not allowed This PR beefs all of those up so that we have a higher degree of certainty that our data is valid. If invalid data reaches connlib, it will cause a panic. This PR also introduces a migration to migrate any invalid resources into the proper format in the DB. Fixes #8287 --- .../domain/resources/resource/changeset.ex | 135 +++++---- ...4_append_default_netmask_to_empty_cidr.exs | 23 ++ ...7193411_convert_dns_ip_resources_to_ip.exs | 39 +++ .../test/domain/config/validator_test.exs | 19 ++ .../resources/resource/changeset_test.exs | 259 ++++++++++++++++-- .../domain/test/domain/resources_test.exs | 2 +- .../web/test/web/live/resources/edit_test.exs | 75 +++++ 7 files changed, 462 insertions(+), 90 deletions(-) create mode 100644 elixir/apps/domain/priv/repo/migrations/20250227051414_append_default_netmask_to_empty_cidr.exs create mode 100644 elixir/apps/domain/priv/repo/migrations/20250227193411_convert_dns_ip_resources_to_ip.exs diff --git a/elixir/apps/domain/lib/domain/resources/resource/changeset.ex b/elixir/apps/domain/lib/domain/resources/resource/changeset.ex index f5250a0ca..014c3bab3 100644 --- a/elixir/apps/domain/lib/domain/resources/resource/changeset.ex +++ b/elixir/apps/domain/lib/domain/resources/resource/changeset.ex @@ -8,8 +8,7 @@ defmodule Domain.Resources.Resource.Changeset do @replace_fields ~w[type address filters]a @required_fields ~w[name type]a - # This list is based on the list of TLDs from the IANA but only contains - # all the country zones and most common generic zones. + # Reference list of common TLDs from IANA @common_tlds ~w[ com org net edu gov mil biz info name mobi pro ac ad ae af ag ai al am ao ar as at au aw ax az @@ -26,9 +25,7 @@ defmodule Domain.Resources.Resource.Changeset do va vc ve vg vi vn vu wf ws ye yt za zm zw ] - @prohibited_tlds ~w[ - localhost - ] + @prohibited_tlds ~w[localhost] def create(%Accounts.Account{} = account, attrs, %Auth.Subject{} = subject) do %Resource{connections: []} @@ -51,6 +48,7 @@ defmodule Domain.Resources.Resource.Changeset do |> cast(attrs, @fields) |> changeset() |> validate_required(@required_fields) + |> update_change(:address, &String.trim/1) |> validate_address(account) |> put_change(:persistent_id, Ecto.UUID.generate()) |> put_change(:account_id, account.id) @@ -60,10 +58,43 @@ defmodule Domain.Resources.Resource.Changeset do ) end + def update(%Resource{} = resource, attrs, %Auth.Subject{} = subject) do + resource + |> cast(attrs, @update_fields) + |> validate_required(@required_fields) + |> validate_address(subject.account) + |> changeset() + |> cast_assoc(:connections, + with: &Connection.Changeset.changeset(resource.account_id, &1, &2, subject), + required: true + ) + |> maybe_breaking_change() + end + + def delete(%Resource{} = resource) do + resource + |> change() + |> put_default_value(:deleted_at, DateTime.utc_now()) + end + defp validate_address(changeset, account) do if has_errors?(changeset, :type) do changeset else + # Force address revalidation if type has changed + changeset = + if Map.has_key?(changeset.changes, :type) do + case fetch_field(changeset, :address) do + {_, address} when not is_nil(address) -> + force_change(changeset, :address, address) + + _ -> + changeset + end + else + changeset + end + case fetch_field(changeset, :type) do {_data_or_changes, :dns} -> changeset @@ -92,34 +123,52 @@ defmodule Domain.Resources.Resource.Changeset do defp validate_dns_address(changeset) do changeset |> validate_length(:address, min: 1, max: 253) - |> validate_format(:address, ~r/^[\p{L}\*\?0-9-]{1,63}(\.[\p{L}\*\?0-9-]{1,63})*$/iu) - |> validate_format(:address, ~r/^[^\.]/, message: "must start with a letter or number") - |> validate_format(:address, ~r/[^\.]$/, message: "must end with a letter or number") - |> validate_format(:address, ~r/[\w]/iu, message: "must contain at least one letter") + # Reject IPs (IPv4 and IPv6) + |> validate_change(:address, fn field, address -> + cond do + String.match?(address, ~r/^(\d+\.){3}\d+(\/\d+)?$/) -> + [{field, "IP addresses are not allowed, use an IP Resource instead"}] + + String.match?( + address, + ~r/^([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}(\/\d+)?$|^::([0-9a-fA-F]{0,4}:){0,6}[0-9a-fA-F]{0,4}(\/\d+)?$/ + ) -> + [{field, "IP addresses are not allowed, use an IP Resource instead"}] + + true -> + [] + end + end) + |> validate_format( + :address, + ~r/^[a-zA-Z0-9\p{L}\-*?]+(?:\.[a-zA-Z0-9\p{L}\-*?]+)*$/u, + message: + "must be a valid hostname (letters, digits, hyphens, dots; wildcards *, ?, ** allowed)" + ) |> validate_change(:address, fn field, dns_address -> - {tld, domain} = - dns_address - |> String.split(".") - |> Enum.reverse() - |> case do - [tld, domain | _rest] -> {String.downcase(tld), String.downcase(domain)} - [tld | _rest] -> {String.downcase(tld), ""} - [] -> {"", ""} + parts = String.split(dns_address, ".") + + {tld, domain_parts} = + case Enum.reverse(parts) do + [tld | rest] -> {String.downcase(tld), rest} + [] -> {"", []} end cond do String.contains?(tld, ["*", "?"]) -> - [{field, {"TLD cannot contain wildcards", []}}] + [{field, "TLD cannot contain wildcards"}] tld in @prohibited_tlds -> [ {field, - {"#{tld} cannot be used as a TLD. " <> - "Try adding a DNS alias to /etc/hosts on the Gateway(s) instead", []}} + "#{tld} cannot be used as a TLD. Try adding a DNS alias to /etc/hosts on the Gateway(s) instead"} ] - tld in @common_tlds and String.contains?(domain, ["*", "?"]) -> - [{field, {"second level domain for IANA TLDs cannot contain wildcards", []}}] + Enum.all?(parts, &(&1 == "*")) -> + [{field, "wildcard pattern must include a valid domain"}] + + tld in @common_tlds and Enum.all?(domain_parts, &String.match?(&1, ~r/^[\*\?]+$/)) -> + [{field, "domain for IANA TLDs cannot consist solely of wildcards"}] true -> [] @@ -145,18 +194,12 @@ defmodule Domain.Resources.Resource.Changeset do ) |> validate_not_in_cidr( :address, - %Postgrex.INET{ - address: {0, 0, 0, 0, 0, 0, 0, 0}, - netmask: 128 - }, + %Postgrex.INET{address: {0, 0, 0, 0, 0, 0, 0, 0}, netmask: 128}, message: internet_resource_message ) |> validate_not_in_cidr( :address, - %Postgrex.INET{ - address: {0, 0, 0, 0, 0, 0, 0, 1}, - netmask: 128 - }, + %Postgrex.INET{address: {0, 0, 0, 0, 0, 0, 0, 1}, netmask: 128}, message: "cannot contain loopback addresses" ) |> validate_address_is_not_in_private_range() @@ -173,18 +216,12 @@ defmodule Domain.Resources.Resource.Changeset do ) |> validate_not_in_cidr( :address, - %Postgrex.INET{ - address: {0, 0, 0, 0, 0, 0, 0, 0}, - netmask: 128 - }, + %Postgrex.INET{address: {0, 0, 0, 0, 0, 0, 0, 0}, netmask: 128}, message: "cannot contain all IPv6 addresses" ) |> validate_not_in_cidr( :address, - %Postgrex.INET{ - address: {0, 0, 0, 0, 0, 0, 0, 1}, - netmask: 128 - }, + %Postgrex.INET{address: {0, 0, 0, 0, 0, 0, 0, 1}, netmask: 128}, message: "cannot contain loopback addresses" ) |> validate_address_is_not_in_private_range() @@ -209,22 +246,8 @@ defmodule Domain.Resources.Resource.Changeset do end end - def update(%Resource{} = resource, attrs, %Auth.Subject{} = subject) do - resource - |> cast(attrs, @update_fields) - |> validate_required(@required_fields) - |> changeset() - |> cast_assoc(:connections, - with: &Connection.Changeset.changeset(resource.account_id, &1, &2, subject), - required: true - ) - |> maybe_breaking_change() - end + defp maybe_breaking_change(%{valid?: false} = changeset), do: {changeset, false} - defp maybe_breaking_change(%{valid?: false} = changeset), - do: {changeset, false} - - # NOTE: Kept for backwards compatibility. defp maybe_breaking_change(changeset) do if any_field_changed?(changeset, @replace_fields) do {changeset, true} @@ -243,12 +266,6 @@ defmodule Domain.Resources.Resource.Changeset do |> unique_constraint(:type, name: :unique_internet_resource_per_account) end - def delete(%Resource{} = resource) do - resource - |> change() - |> put_default_value(:deleted_at, DateTime.utc_now()) - end - defp cast_filter(%Resource.Filter{} = filter, attrs) do filter |> cast(attrs, [:protocol, :ports]) diff --git a/elixir/apps/domain/priv/repo/migrations/20250227051414_append_default_netmask_to_empty_cidr.exs b/elixir/apps/domain/priv/repo/migrations/20250227051414_append_default_netmask_to_empty_cidr.exs new file mode 100644 index 000000000..b6d389814 --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20250227051414_append_default_netmask_to_empty_cidr.exs @@ -0,0 +1,23 @@ +defmodule Domain.Repo.Migrations.AppendDefaultNetmaskToEmptyCidr do + use Ecto.Migration + + def change do + # Update the netmask of all empty IPv4 CIDR columns to 32 + execute(""" + UPDATE resources + SET address = address || '/32' + WHERE type = 'cidr' + AND address !~ '/' + AND address ~ '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' + """) + + # Update the netmask of all empty IPv6 CIDR columns to 128 + execute(""" + UPDATE resources + SET address = address || '/128' + WHERE type = 'cidr' + AND address !~ '/' + AND address ~ '^[0-9a-fA-F:]+$' + """) + end +end diff --git a/elixir/apps/domain/priv/repo/migrations/20250227193411_convert_dns_ip_resources_to_ip.exs b/elixir/apps/domain/priv/repo/migrations/20250227193411_convert_dns_ip_resources_to_ip.exs new file mode 100644 index 000000000..c8e5e9c21 --- /dev/null +++ b/elixir/apps/domain/priv/repo/migrations/20250227193411_convert_dns_ip_resources_to_ip.exs @@ -0,0 +1,39 @@ +defmodule Domain.Repo.Migrations.ConvertDnsIpResourcesToIp do + use Ecto.Migration + + def change do + # Postgres doesn't natively support try_cast, but we can define our own + execute(""" + CREATE OR REPLACE FUNCTION try_cast_inet(text) RETURNS inet AS $$ + BEGIN + RETURN $1::inet; + EXCEPTION + WHEN OTHERS THEN + RETURN NULL; + END; + $$ LANGUAGE plpgsql + """) + + # Convert IP addresses + execute(""" + UPDATE resources + SET type = 'ip' + WHERE type = 'dns' + AND try_cast_inet(address) IS NOT NULL + AND address !~ '/' + """) + + # Convert CIDR blocks + execute(""" + UPDATE resources + SET type = 'cidr' + WHERE type = 'dns' + AND try_cast_inet(address) IS NOT NULL + AND address ~ '/' + """) + + execute(""" + DROP FUNCTION IF EXISTS try_cast_inet(text) + """) + end +end diff --git a/elixir/apps/domain/test/domain/config/validator_test.exs b/elixir/apps/domain/test/domain/config/validator_test.exs index 970b1a6a1..e86040fcb 100644 --- a/elixir/apps/domain/test/domain/config/validator_test.exs +++ b/elixir/apps/domain/test/domain/config/validator_test.exs @@ -39,9 +39,28 @@ defmodule Domain.Config.ValidatorTest do type = {:one_of, [Types.IP, Types.CIDR]} + assert validate(:key, "::1", type, []) == + {:ok, %Postgrex.INET{address: {0, 0, 0, 0, 0, 0, 0, 1}, netmask: nil}} + + assert validate(:key, "::1/foo", type, []) == + {:error, + {"::1/foo", + [ + "must be one of: Elixir.Domain.Types.IP, Elixir.Domain.Types.CIDR", + "CIDR netmask is invalid or missing" + ]}} + assert validate(:key, "1.1.1.1", type, []) == {:ok, %Postgrex.INET{address: {1, 1, 1, 1}, netmask: nil}} + assert validate(:key, "1.1.1.1/foo", type, []) == + {:error, + {"1.1.1.1/foo", + [ + "must be one of: Elixir.Domain.Types.IP, Elixir.Domain.Types.CIDR", + "CIDR netmask is invalid or missing" + ]}} + assert validate(:key, "127.0.0.1/24", type, []) == {:ok, %Postgrex.INET{address: {127, 0, 0, 1}, netmask: 24}} diff --git a/elixir/apps/domain/test/domain/resources/resource/changeset_test.exs b/elixir/apps/domain/test/domain/resources/resource/changeset_test.exs index 5d15ef8e2..6a638b0a6 100644 --- a/elixir/apps/domain/test/domain/resources/resource/changeset_test.exs +++ b/elixir/apps/domain/test/domain/resources/resource/changeset_test.exs @@ -2,6 +2,16 @@ defmodule Domain.Resources.Resource.ChangesetTest do use Domain.DataCase, async: true import Domain.Resources.Resource.Changeset + setup do + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(type: :account_admin_user, account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor) + subject = Fixtures.Auth.create_subject(identity: identity) + resource = Fixtures.Resources.create_resource(account: account) + + %{account: account, actor: actor, identity: identity, subject: subject, resource: resource} + end + describe "create/2" do test "validates and normalizes CIDR ranges" do for {string, cidr} <- [ @@ -23,17 +33,23 @@ defmodule Domain.Resources.Resource.ChangesetTest do assert changeset.valid? end - refute create(%{type: :cidr, address: "192.168.1.256/28"}).valid? - refute create(%{type: :cidr, address: "100.64.0.0/8"}).valid? - refute create(%{type: :cidr, address: "fd00:2021:1111::/102"}).valid? - refute create(%{type: :cidr, address: "0.0.0.0/32"}).valid? - refute create(%{type: :cidr, address: "0.0.0.0/16"}).valid? - refute create(%{type: :cidr, address: "0.0.0.0/0"}).valid? - refute create(%{type: :cidr, address: "127.0.0.1/32"}).valid? - refute create(%{type: :cidr, address: "::0/32"}).valid? - refute create(%{type: :cidr, address: "::1/128"}).valid? - refute create(%{type: :cidr, address: "::8/8"}).valid? - refute create(%{type: :cidr, address: "2607:f8b0:4012:0::200e/128:80"}).valid? + [ + "foobar", + "192.168.1.256/28", + "100.64.0.0/8", + "fd00:2021:1111::/102", + "0.0.0.0/32", + "0.0.0.0/16", + "0.0.0.0/0", + "127.0.0.1/32", + "::0/32", + "::1/128", + "::8/8", + "2607:f8b0:4012:0::200e/128:80" + ] + |> Enum.each(fn string -> + refute create(%{type: :cidr, address: string}).valid? + end) end test "validates and normalizes IP addresses" do @@ -55,18 +71,35 @@ defmodule Domain.Resources.Resource.ChangesetTest do assert changeset.valid? end - refute create(%{type: :ip, address: "192.168.1.256"}).valid? - refute create(%{type: :ip, address: "100.64.0.0"}).valid? - refute create(%{type: :ip, address: "fd00:2021:1111::"}).valid? - refute create(%{type: :ip, address: "0.0.0.0"}).valid? - refute create(%{type: :ip, address: "::0"}).valid? - refute create(%{type: :ip, address: "127.0.0.1"}).valid? - refute create(%{type: :ip, address: "::1"}).valid? - refute create(%{type: :ip, address: "[2607:f8b0:4012:0::200e]:80"}).valid? + [ + "foobar", + "192.168.1.256", + "100.64.0.0", + "fd00:2021:1111::", + "0.0.0.0", + "::0", + "127.0.0.1", + "::1", + "[2607:f8b0:4012:0::200e]:80" + ] + |> Enum.each(fn string -> + refute create(%{type: :ip, address: string}).valid? + end) end test "accepts valid DNS addresses" do for valid_address <- [ + "**.foo.google.com", + "?.foo.google.com", + "web-*.foo.google.com", + "web-?.foo.google.com", + "web-*.google.com", + "web-?.google.com", + "**.*.?.foo.foo.com", + "**.google.com", + "?.google.com", + "*.*.*.*.google.com", + "?.?.?.?.google.com", "*.google", "?.google", "google", @@ -88,17 +121,183 @@ defmodule Domain.Resources.Resource.ChangesetTest do assert changeset.valid? end - refute create(%{type: :dns, address: "1.1.1.1"}).valid? - refute create(%{type: :dns, address: ".example.com"}).valid? - refute create(%{type: :dns, address: "example.com."}).valid? - refute create(%{type: :dns, address: "exa&mple.com"}).valid? - refute create(%{type: :dns, address: ""}).valid? - refute create(%{type: :dns, address: "http://example.com/"}).valid? - refute create(%{type: :dns, address: "//example.com/"}).valid? - refute create(%{type: :dns, address: "example.com/"}).valid? - refute create(%{type: :dns, address: ".example.com"}).valid? - refute create(%{type: :dns, address: "example."}).valid? - refute create(%{type: :dns, address: "example.com:80"}).valid? + [ + "1.1.1.1/32", + "1.1.1.1", + ".example.com", + "example..com", + "**", + "**.", + "*.", + "*.*", + "?", + "?.", + "example.com.", + "exa&mple.com", + "", + "http://example.com/", + "//example.com/", + "example.com/", + ".example.com", + "example.", + "example.com:80" + ] + |> Enum.each(fn invalid_address -> + refute create(%{type: :dns, address: invalid_address}).valid? + end) + end + end + + describe "update/2" do + test "validates and normalizes CIDR ranges", %{resource: resource, subject: subject} do + for {string, cidr} <- [ + {"192.168.1.1/24", "192.168.1.0/24"}, + {"101.100.100.0/28", "101.100.100.0/28"}, + {"192.168.1.255/28", "192.168.1.240/28"}, + {"192.168.1.255/32", "192.168.1.255/32"}, + {"2607:f8b0:4012:0::200e/128", "2607:f8b0:4012::200e/128"} + ] do + {changeset, _} = + update( + resource, + %{ + name: "foo", + type: :cidr, + address: string, + address_description: string + }, + subject + ) + + assert changeset.changes.address == cidr + assert changeset.valid? + end + + [ + "foobar", + "192.168.1.256/28", + "100.64.0.0/8", + "fd00:2021:1111::/102", + "0.0.0.0/32", + "0.0.0.0/16", + "0.0.0.0/0", + "127.0.0.1/32", + "::0/32", + "::1/128", + "::8/8", + "2607:f8b0:4012:0::200e/128:80" + ] + |> Enum.each(fn invalid_cidr -> + {changeset, _} = update(resource, %{type: :cidr, address: invalid_cidr}, subject) + refute changeset.valid? + end) + end + + test "validates and normalizes IP addresses", %{resource: resource, subject: subject} do + for {string, ip} <- [ + {"192.168.1.1", "192.168.1.1"}, + {"101.100.100.0", "101.100.100.0"}, + {"192.168.1.255", "192.168.1.255"}, + {"2607:f8b0:4012:0::200e", "2607:f8b0:4012::200e"} + ] do + {changeset, _} = + update( + resource, + %{ + name: "foo", + type: :ip, + address: string, + address_description: string + }, + subject + ) + + assert changeset.changes.address == ip + assert changeset.valid? + end + + [ + "foobar", + "192.168.1.256", + "100.64.0.0", + "fd00:2021:1111::", + "0.0.0.0", + "::0", + "127.0.0.1", + "::1", + "[2607:f8b0:4012:0::200e]:80" + ] + |> Enum.each(fn invalid_ip -> + {changeset, _} = update(resource, %{type: :ip, address: invalid_ip}, subject) + refute changeset.valid? + end) + end + + test "accepts valid DNS addresses", %{resource: resource, subject: subject} do + for valid_address <- [ + "**.foo.google.com", + "?.foo.google.com", + "web-*.foo.google.com", + "web-?.foo.google.com", + "web-*.google.com", + "web-?.google.com", + "**.*.?.foo.foo.com", + "**.google.com", + "?.google.com", + "*.*.*.*.google.com", + "?.?.?.?.google.com", + "*.google", + "?.google", + "google", + "example.com", + "example.weird", + "1234567890.com", + "#{String.duplicate("a", 63)}.com", + "такі.справи", + "subdomain.subdomain2.example.space" + ] do + {changeset, _} = + update( + resource, + %{ + name: "foo", + type: :dns, + address: valid_address, + address_description: valid_address + }, + subject + ) + + assert changeset.valid? + end + + [ + "2600::1/32", + "2600::1", + "1.1.1.1/32", + "1.1.1.1", + ".example.com", + "example..com", + "**", + "**.", + "*.", + "*.*", + "?", + "?.", + "example.com.", + "exa&mple.com", + "", + "http://example.com/", + "//example.com/", + "example.com/", + ".example.com", + "example.", + "example.com:80" + ] + |> Enum.each(fn invalid_address -> + {changeset, _} = update(resource, %{type: :dns, address: invalid_address}, subject) + refute changeset.valid? + end) end end diff --git a/elixir/apps/domain/test/domain/resources_test.exs b/elixir/apps/domain/test/domain/resources_test.exs index 0b918b36c..608b3384a 100644 --- a/elixir/apps/domain/test/domain/resources_test.exs +++ b/elixir/apps/domain/test/domain/resources_test.exs @@ -1129,7 +1129,7 @@ defmodule Domain.ResourcesTest do attrs = Fixtures.Resources.resource_attrs(address: "*.com") assert {:error, changeset} = create_resource(attrs, subject) - error = "second level domain for IANA TLDs cannot contain wildcards" + error = "domain for IANA TLDs cannot consist solely of wildcards" assert error in errors_on(changeset).address attrs = Fixtures.Resources.resource_attrs(address: "foo.*") diff --git a/elixir/apps/web/test/web/live/resources/edit_test.exs b/elixir/apps/web/test/web/live/resources/edit_test.exs index a0e6405a7..ef52c0c7b 100644 --- a/elixir/apps/web/test/web/live/resources/edit_test.exs +++ b/elixir/apps/web/test/web/live/resources/edit_test.exs @@ -456,6 +456,81 @@ defmodule Web.Live.Resources.EditTest do assert_redirect(lv, ~p"/#{account}/resources") end + test "prevents updating resource type to ip when address is not an IP address", %{ + account: account, + identity: identity, + resource: resource, + conn: conn + } do + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/resources/#{resource}/edit") + + attrs = %{ + "type" => "ip" + } + + assert lv + |> form("form", resource: attrs) + |> render_submit() + |> form_validation_errors() == %{ + "resource[address]" => ["is not a valid IP address"] + } + end + + test "prevents updating resource type to dns when address is an IP", %{ + account: account, + identity: identity, + conn: conn + } do + resource = Fixtures.Resources.create_resource(account: account, type: :ip, address: "1.1.1.1") + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/resources/#{resource}/edit") + + attrs = %{ + "type" => "dns" + } + + assert lv + |> form("form", resource: attrs) + |> render_submit() + |> form_validation_errors() == %{ + "resource[address]" => ["IP addresses are not allowed, use an IP Resource instead"] + } + end + + test "prevents updating resource type to dns when address is a CIDR", %{ + account: account, + identity: identity, + conn: conn + } do + resource = + Fixtures.Resources.create_resource(account: account, type: :cidr, address: "1.1.1.1/32") + + {:ok, lv, _html} = + conn + |> authorize_conn(identity) + |> live(~p"/#{account}/resources/#{resource}/edit") + + attrs = %{ + "type" => "dns" + } + + assert lv + |> form("form", resource: attrs) + |> render_submit() + |> form_validation_errors() == %{ + "resource[address]" => [ + "must be a valid hostname (letters, digits, hyphens, dots; wildcards *, ?, ** allowed)", + "IP addresses are not allowed, use an IP Resource instead" + ] + } + end + test "redirects to resources page when resource address is edited", %{ account: account, identity: identity,