From 6989e1e3ba026a2a876a07ae075e85f0e65b6e12 Mon Sep 17 00:00:00 2001 From: Riccardo Binetti Date: Mon, 18 Dec 2023 13:55:57 +0100 Subject: [PATCH] Port Astarte.Realm resource to Ash Signed-off-by: Riccardo Binetti --- backend/lib/edgehog/astarte/realm.ex | 78 ++++++++---- backend/lib/edgehog/astarte/registry.ex | 1 + .../edgehog/validations/pem_private_key.ex | 50 ++++++++ .../lib/edgehog/validations/validations.ex | 9 ++ backend/test/edgehog/astarte_test.exs | 116 ++++++++++-------- .../test/support/fixtures/astarte_fixtures.ex | 20 +-- 6 files changed, 188 insertions(+), 86 deletions(-) create mode 100644 backend/lib/edgehog/validations/pem_private_key.ex diff --git a/backend/lib/edgehog/astarte/realm.ex b/backend/lib/edgehog/astarte/realm.ex index c3bce541a..16cd12f61 100644 --- a/backend/lib/edgehog/astarte/realm.ex +++ b/backend/lib/edgehog/astarte/realm.ex @@ -1,7 +1,7 @@ # # This file is part of Edgehog. # -# Copyright 2021 SECO Mind Srl +# Copyright 2021-2023 SECO Mind Srl # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -19,32 +19,64 @@ # defmodule Edgehog.Astarte.Realm do - use Ecto.Schema - import Ecto.Changeset - import Edgehog.ChangesetValidation + use Edgehog.MultitenantResource, + data_layer: AshPostgres.DataLayer - alias Edgehog.Astarte.Cluster - alias Edgehog.Astarte.Device + alias Edgehog.Validations - schema "realms" do - field :name, :string - field :private_key, :string - field :tenant_id, :id - belongs_to :cluster, Cluster - has_many :devices, Device + code_interface do + define_for Edgehog.Astarte + define :fetch_by_name, action: :by_name, args: [:name] + define :create + define :destroy + end + + actions do + defaults [:read, :destroy] + + read :by_name do + get_by :name + end + + create :create do + primary? true + + argument :cluster_id, :integer, allow_nil?: false + + change manage_relationship(:cluster_id, :cluster, type: :append) + end + end + + attributes do + integer_primary_key :id + + attribute :name, :string, allow_nil?: false + + attribute :private_key, :string do + allow_nil? false + constraints trim?: false + end + + create_timestamp :inserted_at + update_timestamp :updated_at + end + + relationships do + belongs_to :cluster, Edgehog.Astarte.Cluster + end + + identities do + identity :name_tenant_id, [:name, :tenant_id] + identity :name_cluster_id, [:name, :cluster_id] + end - timestamps() + validations do + validate Validations.realm_name(:name) + validate {Validations.PEMPrivateKey, attribute: :private_key} end - @doc false - def changeset(realm, attrs) do - realm - |> cast(attrs, [:name, :private_key]) - |> validate_required([:name, :private_key]) - |> foreign_key_constraint(:cluster_id) - |> unique_constraint([:name, :tenant_id]) - |> unique_constraint([:name, :cluster_id]) - |> validate_realm_name(:name) - |> validate_pem_private_key(:private_key) + postgres do + table "realms" + repo Edgehog.Repo end end diff --git a/backend/lib/edgehog/astarte/registry.ex b/backend/lib/edgehog/astarte/registry.ex index 4ba2bdb8f..ffb525522 100644 --- a/backend/lib/edgehog/astarte/registry.ex +++ b/backend/lib/edgehog/astarte/registry.ex @@ -23,5 +23,6 @@ defmodule Edgehog.Astarte.Registry do entries do entry Edgehog.Astarte.Cluster + entry Edgehog.Astarte.Realm end end diff --git a/backend/lib/edgehog/validations/pem_private_key.ex b/backend/lib/edgehog/validations/pem_private_key.ex new file mode 100644 index 000000000..15f35a888 --- /dev/null +++ b/backend/lib/edgehog/validations/pem_private_key.ex @@ -0,0 +1,50 @@ +# +# This file is part of Edgehog. +# +# Copyright 2023 SECO Mind Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +defmodule Edgehog.Validations.PEMPrivateKey do + use Ash.Resource.Validation + + def init(opts) do + if is_atom(opts[:attribute]) do + {:ok, opts} + else + {:error, "attribute must be an atom"} + end + end + + def validate(changeset, opts) do + case Ash.Changeset.fetch_argument_or_change(changeset, opts[:attribute]) do + {:ok, nil} -> + :ok + + {:ok, changing_to} when is_binary(changing_to) -> + case X509.PrivateKey.from_pem(changing_to) do + {:ok, _} -> + :ok + + {:error, _reason} -> + {:error, field: opts[:attribute], message: "is not a valid PEM private key"} + end + + _ -> + :ok + end + end +end diff --git a/backend/lib/edgehog/validations/validations.ex b/backend/lib/edgehog/validations/validations.ex index b238eeb16..42bea5ac8 100644 --- a/backend/lib/edgehog/validations/validations.ex +++ b/backend/lib/edgehog/validations/validations.ex @@ -32,4 +32,13 @@ defmodule Edgehog.Validations do match: ~r/^[a-z\d\-]+$/, message: "should only contain lower case ASCII letters (from a to z), digits and -"} end + + def realm_name(attribute) do + {Validation.Match, + attribute: attribute, + match: ~r/^[a-z][a-z0-9]{0,47}$/, + message: + "should only contain lower case ASCII letters (from a to z) and digits, " <> + "and start with a lower case ASCII letter"} + end end diff --git a/backend/test/edgehog/astarte_test.exs b/backend/test/edgehog/astarte_test.exs index 8eb9a5cfd..736093942 100644 --- a/backend/test/edgehog/astarte_test.exs +++ b/backend/test/edgehog/astarte_test.exs @@ -106,89 +106,97 @@ defmodule Edgehog.AstarteTest do end end - describe "realms" do + describe "Realm" do + @describetag :ported_to_ash + alias Edgehog.Astarte.Realm @valid_private_key X509.PrivateKey.new_ec(:secp256r1) |> X509.PrivateKey.to_pem() - @other_private_key X509.PrivateKey.new_ec(:secp256r1) |> X509.PrivateKey.to_pem() - - setup do - %{cluster: cluster_fixture()} - end @invalid_attrs %{name: nil, private_key: nil} - test "list_realms/0 returns all realms", %{cluster: cluster} do - realm = realm_fixture(cluster) - assert Astarte.list_realms() == [realm] - end - - test "get_realm!/1 returns the realm with given id", %{cluster: cluster} do - realm = realm_fixture(cluster) - assert Astarte.get_realm!(realm.id) == realm - end - - test "create_realm/1 with valid data creates a realm", %{cluster: cluster} do - valid_attrs = %{name: "somename", private_key: @valid_private_key} + test "create with valid data creates a realm" do + cluster = cluster_fixture() + tenant = tenant_fixture() + valid_attrs = %{cluster_id: cluster.id, name: "somename", private_key: @valid_private_key} - assert {:ok, %Realm{} = realm} = Astarte.create_realm(cluster, valid_attrs) + assert {:ok, %Realm{} = realm} = Realm.create(valid_attrs, tenant: tenant) assert realm.name == "somename" assert realm.private_key == @valid_private_key + assert realm.tenant_id == tenant.tenant_id end - test "create_realm/1 with invalid data returns error changeset", %{cluster: cluster} do - assert {:error, %Ecto.Changeset{}} = Astarte.create_realm(cluster, @invalid_attrs) + test "create with invalid name returns error" do + assert {:error, %Ash.Error.Invalid{errors: [error]}} = create_realm(name: "42INVALID") + assert %{field: :name} = error end - test "create_realm/2 with a duplicate name in the same tenant returns error", %{ - cluster: cluster - } do - realm = realm_fixture(cluster) + test "create with invalid private key returns error" do + assert {:error, %Ash.Error.Invalid{errors: [error]}} = + create_realm(private_key: "not a private key") - attrs = %{name: realm.name, private_key: @valid_private_key} + assert %{field: :private_key} = error + end - assert {:error, changeset} = Astarte.create_realm(cluster, attrs) - assert "has already been taken" in errors_on(changeset)[:name] + test "create with a duplicate name in the same tenant returns error" do + tenant = tenant_fixture() + cluster = cluster_fixture() + realm = realm_fixture(cluster_id: cluster.id, tenant: tenant) + + attrs = %{cluster_id: cluster.id, name: realm.name, private_key: @valid_private_key} + + assert {:error, %Ash.Error.Invalid{errors: [error]}} = Realm.create(attrs, tenant: tenant) + assert %{field: :name, message: "has already been taken"} = error end - test "create_realm/2 with a duplicate name in another tenant returns error", %{ - cluster: cluster - } do - realm = realm_fixture(cluster) + test "create with a duplicate name in another tenant returns error" do + other_tenant = tenant_fixture() + cluster = cluster_fixture() + realm = realm_fixture(tenant: other_tenant, cluster_id: cluster.id) tenant = tenant_fixture() - Repo.put_tenant_id(tenant.tenant_id) + attrs = %{cluster_id: cluster.id, name: realm.name, private_key: @valid_private_key} - attrs = %{name: realm.name, private_key: @valid_private_key} - - assert {:error, changeset} = Astarte.create_realm(cluster, attrs) - assert "has already been taken" in errors_on(changeset)[:name] + assert {:error, %Ash.Error.Invalid{errors: [error]}} = Realm.create(attrs, tenant: tenant) + assert %{field: :name, message: "has already been taken"} = error end - test "update_realm/2 with valid data updates the realm", %{cluster: cluster} do - realm = realm_fixture(cluster) - update_attrs = %{name: "someupdatedname", private_key: @other_private_key} + test "fetch_by_name returns the realm given its name" do + tenant = tenant_fixture() + realm = realm_fixture(tenant: tenant) - assert {:ok, %Realm{} = realm} = Astarte.update_realm(realm, update_attrs) - assert realm.name == "someupdatedname" - assert realm.private_key == @other_private_key + assert {:ok, realm} = Realm.fetch_by_name(realm.name, tenant: tenant, load: [:cluster]) end - test "update_realm/2 with invalid data returns error changeset", %{cluster: cluster} do - realm = realm_fixture(cluster) - assert {:error, %Ecto.Changeset{}} = Astarte.update_realm(realm, @invalid_attrs) - assert realm == Astarte.get_realm!(realm.id) + test "fetch_by_name returns error for non-existing realm" do + tenant = tenant_fixture() + + assert {:error, %Ash.Error.Query.NotFound{}} = + Realm.fetch_by_name("nonexisting", tenant: tenant) end - test "delete_realm/1 deletes the realm", %{cluster: cluster} do - realm = realm_fixture(cluster) - assert {:ok, %Realm{}} = Astarte.delete_realm(realm) - assert_raise Ecto.NoResultsError, fn -> Astarte.get_realm!(realm.id) end + test "destroy deletes the realm" do + tenant = tenant_fixture() + realm = realm_fixture(tenant: tenant) + assert :ok = Realm.destroy(realm) + + assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}} = + Astarte.get(Realm, realm.id, tenant: tenant) end - test "change_realm/1 returns a realm changeset", %{cluster: cluster} do - realm = realm_fixture(cluster) - assert %Ecto.Changeset{} = Astarte.change_realm(realm) + defp create_realm(opts \\ []) do + {tenant, opts} = Keyword.pop_lazy(opts, :tenant, &Edgehog.TenantsFixtures.tenant_fixture/0) + + {cluster_id, opts} = + Keyword.pop_lazy(opts, :cluster_id, fn -> cluster_fixture() |> Map.fetch!(:id) end) + + opts + |> Enum.into(%{ + cluster_id: cluster_id, + name: unique_realm_name(), + private_key: @valid_private_key + }) + |> Edgehog.Astarte.Realm.create(tenant: tenant) end end diff --git a/backend/test/support/fixtures/astarte_fixtures.ex b/backend/test/support/fixtures/astarte_fixtures.ex index 886c2af83..ebda8f5a7 100644 --- a/backend/test/support/fixtures/astarte_fixtures.ex +++ b/backend/test/support/fixtures/astarte_fixtures.ex @@ -57,17 +57,19 @@ defmodule Edgehog.AstarteFixtures do @doc """ Generate a realm. """ - def realm_fixture(cluster, attrs \\ %{}) do - attrs = - attrs - |> Enum.into(%{ - name: unique_realm_name(), - private_key: @private_key - }) + def realm_fixture(opts \\ []) do + {tenant, opts} = Keyword.pop_lazy(opts, :tenant, &Edgehog.TenantsFixtures.tenant_fixture/0) - {:ok, realm} = Edgehog.Astarte.create_realm(cluster, attrs) + {cluster_id, opts} = + Keyword.pop_lazy(opts, :cluster_id, fn -> cluster_fixture() |> Map.fetch!(:id) end) - realm + opts + |> Enum.into(%{ + cluster_id: cluster_id, + name: unique_realm_name(), + private_key: @private_key + }) + |> Edgehog.Astarte.Realm.create!(tenant: tenant.tenant_id) end @doc """