diff --git a/backend/lib/edgehog/astarte/realm/realm.ex b/backend/lib/edgehog/astarte/realm/realm.ex index 89b6b605b..06b94350d 100644 --- a/backend/lib/edgehog/astarte/realm/realm.ex +++ b/backend/lib/edgehog/astarte/realm/realm.ex @@ -84,6 +84,7 @@ defmodule Edgehog.Astarte.Realm do identities do identity :name, [:name] identity :unique_name_for_cluster, [:name, :cluster_id], all_tenants?: true + identity :one_realm_per_tenant, [:tenant_id] end postgres do diff --git a/backend/priv/repo/migrations/20241209134733_realm_tenant_uniqueness.exs b/backend/priv/repo/migrations/20241209134733_realm_tenant_uniqueness.exs new file mode 100644 index 000000000..cd25beb92 --- /dev/null +++ b/backend/priv/repo/migrations/20241209134733_realm_tenant_uniqueness.exs @@ -0,0 +1,41 @@ +# +# This file is part of Edgehog. +# +# Copyright 2024 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.Repo.Migrations.RealmTenantUniqueness do + @moduledoc """ + Updates resources based on their most recent snapshots. + + This file was autogenerated with `mix ash_postgres.generate_migrations` + """ + + use Ecto.Migration + + def up do + create unique_index(:realms, [:tenant_id, :tenant_id], + name: "realms_one_realm_per_tenant_index" + ) + end + + def down do + drop_if_exists unique_index(:realms, [:tenant_id, :tenant_id], + name: "realms_one_realm_per_tenant_index" + ) + end +end diff --git a/backend/priv/resource_snapshots/repo/realms/20241209134733.json b/backend/priv/resource_snapshots/repo/realms/20241209134733.json new file mode 100644 index 000000000..22b373870 --- /dev/null +++ b/backend/priv/resource_snapshots/repo/realms/20241209134733.json @@ -0,0 +1,246 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "nil", + "generated?": true, + "primary_key?": true, + "references": null, + "size": null, + "source": "id", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "name", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "private_key", + "type": "text" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "inserted_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"(now() AT TIME ZONE 'utc')\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "tenant_id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "realms_tenant_id_fkey", + "on_delete": "delete", + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "tenants" + }, + "size": null, + "source": "tenant_id", + "type": "bigint" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": { + "deferrable": false, + "destination_attribute": "id", + "destination_attribute_default": null, + "destination_attribute_generated": null, + "index?": false, + "match_type": null, + "match_with": null, + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "name": "realms_cluster_id_fkey", + "on_delete": null, + "on_update": null, + "primary_key?": true, + "schema": "public", + "table": "clusters" + }, + "size": null, + "source": "cluster_id", + "type": "bigint" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [ + { + "all_tenants?": true, + "concurrently": false, + "error_fields": [ + "id", + "tenant_id" + ], + "fields": [ + { + "type": "atom", + "value": "id" + }, + { + "type": "atom", + "value": "tenant_id" + } + ], + "include": null, + "message": null, + "name": null, + "nulls_distinct": true, + "prefix": null, + "table": null, + "unique": true, + "using": null, + "where": null + }, + { + "all_tenants?": true, + "concurrently": false, + "error_fields": [ + "tenant_id" + ], + "fields": [ + { + "type": "atom", + "value": "tenant_id" + } + ], + "include": null, + "message": null, + "name": null, + "nulls_distinct": true, + "prefix": null, + "table": null, + "unique": false, + "using": null, + "where": null + }, + { + "all_tenants?": true, + "concurrently": false, + "error_fields": [ + "cluster_id" + ], + "fields": [ + { + "type": "atom", + "value": "cluster_id" + } + ], + "include": null, + "message": null, + "name": null, + "nulls_distinct": true, + "prefix": null, + "table": null, + "unique": false, + "using": null, + "where": null + } + ], + "custom_statements": [], + "has_create_action": true, + "hash": "46D0CC2683A8CD9B4DBD9A59D03E844D2FDA972CC5F9382DA4341683AE9253C8", + "identities": [ + { + "all_tenants?": false, + "base_filter": null, + "index_name": "realms_name_index", + "keys": [ + { + "type": "atom", + "value": "name" + } + ], + "name": "name", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": true, + "base_filter": null, + "index_name": "realms_unique_name_for_cluster_index", + "keys": [ + { + "type": "atom", + "value": "name" + }, + { + "type": "atom", + "value": "cluster_id" + } + ], + "name": "unique_name_for_cluster", + "nils_distinct?": true, + "where": null + }, + { + "all_tenants?": false, + "base_filter": null, + "index_name": "realms_one_realm_per_tenant_index", + "keys": [ + { + "type": "atom", + "value": "tenant_id" + } + ], + "name": "one_realm_per_tenant", + "nils_distinct?": true, + "where": null + } + ], + "multitenancy": { + "attribute": "tenant_id", + "global": false, + "strategy": "attribute" + }, + "repo": "Elixir.Edgehog.Repo", + "schema": null, + "table": "realms" +} \ No newline at end of file diff --git a/backend/test/edgehog/astarte/realm_test.exs b/backend/test/edgehog/astarte/realm_test.exs index 847f09c60..6d874125f 100644 --- a/backend/test/edgehog/astarte/realm_test.exs +++ b/backend/test/edgehog/astarte/realm_test.exs @@ -55,6 +55,17 @@ defmodule Edgehog.Astarte.RealmTest do assert %{field: :private_key} = error end + test "create_realm/2 does not allow creating multiple realms for the same tenant" do + tenant = tenant_fixture() + cluster = cluster_fixture() + _realm = realm_fixture(cluster_id: cluster.id, tenant: tenant) + + assert {:error, %Invalid{errors: [error]}} = + create_realm(tenant: tenant) + + assert %{field: :tenant_id, message: "has already been taken"} = error + end + test "with a duplicate name in the same tenant returns error" do tenant = tenant_fixture() cluster = cluster_fixture() diff --git a/backend/test/support/fixtures/devices_fixtures.ex b/backend/test/support/fixtures/devices_fixtures.ex index 83797bb76..5e7cd376e 100644 --- a/backend/test/support/fixtures/devices_fixtures.ex +++ b/backend/test/support/fixtures/devices_fixtures.ex @@ -25,6 +25,7 @@ defmodule Edgehog.DevicesFixtures do """ alias Edgehog.AstarteFixtures + alias Edgehog.Tenants.Tenant @doc """ Generate a unique hardware_type handle. @@ -64,7 +65,11 @@ defmodule Edgehog.DevicesFixtures do {realm_id, opts} = Keyword.pop_lazy(opts, :realm_id, fn -> - [tenant: tenant] |> AstarteFixtures.realm_fixture() |> Map.fetch!(:id) + tenant = Ash.get!(Tenant, tenant, tenant: tenant, load: :realm) + + if tenant.realm == nil, + do: AstarteFixtures.realm_fixture(tenant: tenant).id, + else: tenant.realm.id end) default_device_id = AstarteFixtures.random_device_id()