From d723336c2a84ee7813ea52275469c96f4b9afafd Mon Sep 17 00:00:00 2001 From: Jamil Date: Sat, 8 Mar 2025 03:08:33 +0000 Subject: [PATCH] feat(portal): Support `search_domain` field in Account.Config (#8391) Introduces a simple `search_domain` field embed into our existing `Accounts.Account.Config` embedded schema. This will be sent to clients to append to single-label DNS queries. UI and API changes will come in subsequent PRs: this one adds field and (lots of) validations only. Related: #8365 --- .../apps/domain/lib/domain/accounts/config.ex | 2 + .../lib/domain/accounts/config/changeset.ex | 31 ++++- .../apps/domain/test/domain/accounts_test.exs | 112 ++++++++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) diff --git a/elixir/apps/domain/lib/domain/accounts/config.ex b/elixir/apps/domain/lib/domain/accounts/config.ex index 751b69cf7..3823cd9d7 100644 --- a/elixir/apps/domain/lib/domain/accounts/config.ex +++ b/elixir/apps/domain/lib/domain/accounts/config.ex @@ -3,6 +3,8 @@ defmodule Domain.Accounts.Config do @primary_key false embedded_schema do + field :search_domain, :string + embeds_many :clients_upstream_dns, ClientsUpstreamDNS, primary_key: false, on_replace: :delete do diff --git a/elixir/apps/domain/lib/domain/accounts/config/changeset.ex b/elixir/apps/domain/lib/domain/accounts/config/changeset.ex index a926c9450..8e5876a4d 100644 --- a/elixir/apps/domain/lib/domain/accounts/config/changeset.ex +++ b/elixir/apps/domain/lib/domain/accounts/config/changeset.ex @@ -7,16 +7,45 @@ defmodule Domain.Accounts.Config.Changeset do def changeset(config \\ %Config{}, attrs) do config - |> cast(attrs, []) + |> cast(attrs, [:search_domain]) |> cast_embed(:clients_upstream_dns, with: &client_upstream_dns_changeset/2, sort_param: :clients_upstream_dns_sort, drop_param: :clients_upstream_dns_drop ) |> cast_embed(:notifications, with: ¬ifications_changeset/2) + |> validate_search_domain() |> validate_unique_clients_upstream_dns() end + defp validate_search_domain(changeset) do + changeset + |> validate_change(:search_domain, fn :search_domain, domain -> + cond do + domain == nil || domain == "" -> + [search_domain: "cannot be empty"] + + String.length(domain) > 255 -> + [search_domain: "must not exceed 255 characters"] + + String.starts_with?(domain, ".") || String.ends_with?(domain, ".") -> + [search_domain: "must not start or end with a dot"] + + String.contains?(domain, "..") -> + [search_domain: "must not contain consecutive dots"] + + !String.match?(domain, ~r/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i) -> + [search_domain: "must be a valid fully-qualified domain name"] + + Enum.any?(String.split(domain, "."), &(String.length(&1) > 63)) -> + [search_domain: "each label must not exceed 63 characters"] + + true -> + [] + end + end) + end + defp validate_unique_clients_upstream_dns(changeset) do with false <- has_errors?(changeset, :clients_upstream_dns), {_data_or_changes, client_upstream_dns} <- fetch_field(changeset, :clients_upstream_dns) do diff --git a/elixir/apps/domain/test/domain/accounts_test.exs b/elixir/apps/domain/test/domain/accounts_test.exs index 0f38b92a8..27270702c 100644 --- a/elixir/apps/domain/test/domain/accounts_test.exs +++ b/elixir/apps/domain/test/domain/accounts_test.exs @@ -583,6 +583,118 @@ defmodule Domain.AccountsTest do } end + test "returns error when search_domain is invalid", %{account: account} do + attrs = %{ + config: %{ + search_domain: "invalid" + } + } + + assert {:error, changeset} = update_account_by_id(account.id, attrs) + + assert errors_on(changeset) == %{ + config: %{ + search_domain: ["must be a valid fully-qualified domain name"] + } + } + end + + test "returns error when search_domain is too long", %{account: account} do + attrs = %{ + config: %{ + search_domain: String.duplicate("a", 256) + } + } + + assert {:error, changeset} = update_account_by_id(account.id, attrs) + + assert errors_on(changeset) == %{ + config: %{ + search_domain: ["must not exceed 255 characters"] + } + } + end + + test "returns error when search_domain starts with a dot", %{account: account} do + attrs = %{ + config: %{ + search_domain: ".example.com" + } + } + + assert {:error, changeset} = update_account_by_id(account.id, attrs) + + assert errors_on(changeset) == %{ + config: %{ + search_domain: ["must not start or end with a dot"] + } + } + end + + test "returns error when search_domain ends with a dot", %{account: account} do + attrs = %{ + config: %{ + search_domain: "example.com." + } + } + + assert {:error, changeset} = update_account_by_id(account.id, attrs) + + assert errors_on(changeset) == %{ + config: %{ + search_domain: ["must not start or end with a dot"] + } + } + end + + test "returns error when search_domain contains consecutive dots", %{account: account} do + attrs = %{ + config: %{ + search_domain: "example..com" + } + } + + assert {:error, changeset} = update_account_by_id(account.id, attrs) + + assert errors_on(changeset) == %{ + config: %{ + search_domain: ["must not contain consecutive dots"] + } + } + end + + test "returns error when search_domain labels exceed 63 characters", %{account: account} do + attrs = %{ + config: %{ + search_domain: "a" <> String.duplicate("a", 63) <> ".com" + } + } + + assert {:error, changeset} = update_account_by_id(account.id, attrs) + + assert errors_on(changeset) == %{ + config: %{ + search_domain: ["each label must not exceed 63 characters"] + } + } + end + + test "returns error when search_domain contains invalid characters", %{account: account} do + attrs = %{ + config: %{ + search_domain: "example.com!" + } + } + + assert {:error, changeset} = update_account_by_id(account.id, attrs) + + assert errors_on(changeset) == %{ + config: %{ + search_domain: ["must be a valid fully-qualified domain name"] + } + } + end + test "updates account and broadcasts a message", %{account: account} do Bypass.open() |> Domain.Mocks.Stripe.mock_update_customer_endpoint(account)