diff --git a/lib/towerops/integrations/integration.ex b/lib/towerops/integrations/integration.ex new file mode 100644 index 00000000..3fc4feca --- /dev/null +++ b/lib/towerops/integrations/integration.ex @@ -0,0 +1,54 @@ +defmodule Towerops.Integrations.Integration do + @moduledoc """ + Schema for per-organization third-party integrations. + + Each organization can have one integration per provider. Credentials + are encrypted at rest using Cloak AES-256-GCM. + """ + use Ecto.Schema + + import Ecto.Changeset + + alias Towerops.Encrypted + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @valid_providers ~w(preseem) + @valid_sync_statuses ~w(never success partial failed) + + schema "integrations" do + field :provider, :string + field :enabled, :boolean, default: false + field :credentials, Encrypted.Map + field :sync_interval_minutes, :integer, default: 10 + field :last_synced_at, :utc_datetime + field :last_sync_status, :string, default: "never" + + belongs_to :organization, Towerops.Organizations.Organization + + timestamps(type: :utc_datetime) + end + + def changeset(integration, attrs) do + integration + |> cast(attrs, [ + :organization_id, + :provider, + :enabled, + :credentials, + :sync_interval_minutes, + :last_synced_at, + :last_sync_status + ]) + |> validate_required([:organization_id, :provider]) + |> validate_inclusion(:provider, @valid_providers) + |> validate_inclusion(:last_sync_status, @valid_sync_statuses) + |> validate_number(:sync_interval_minutes, greater_than: 0) + |> unique_constraint([:organization_id, :provider], + error_key: :provider, + message: "has already been taken" + ) + |> foreign_key_constraint(:organization_id) + end +end diff --git a/priv/repo/migrations/20260212225249_create_integrations.exs b/priv/repo/migrations/20260212225249_create_integrations.exs new file mode 100644 index 00000000..e977e39e --- /dev/null +++ b/priv/repo/migrations/20260212225249_create_integrations.exs @@ -0,0 +1,24 @@ +defmodule Towerops.Repo.Migrations.CreateIntegrations do + use Ecto.Migration + + def change do + create table(:integrations, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all), + null: false + + add :provider, :string, null: false + add :enabled, :boolean, default: false, null: false + add :credentials, :binary + add :sync_interval_minutes, :integer, default: 10 + add :last_synced_at, :utc_datetime + add :last_sync_status, :string, default: "never" + + timestamps(type: :utc_datetime) + end + + create unique_index(:integrations, [:organization_id, :provider]) + create index(:integrations, [:organization_id]) + end +end diff --git a/test/towerops/integrations/integration_test.exs b/test/towerops/integrations/integration_test.exs new file mode 100644 index 00000000..353144f6 --- /dev/null +++ b/test/towerops/integrations/integration_test.exs @@ -0,0 +1,103 @@ +defmodule Towerops.Integrations.IntegrationTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Integrations.Integration + + setup do + user = user_fixture() + organization = organization_fixture(user.id) + %{organization: organization} + end + + describe "changeset/2" do + test "valid changeset with required fields", %{organization: org} do + attrs = %{ + organization_id: org.id, + provider: "preseem", + enabled: true, + credentials: %{"api_key" => "test-key-123"} + } + + changeset = Integration.changeset(%Integration{}, attrs) + assert changeset.valid? + end + + test "requires organization_id" do + attrs = %{provider: "preseem"} + changeset = Integration.changeset(%Integration{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).organization_id + end + + test "requires provider" do + attrs = %{organization_id: Ecto.UUID.generate()} + changeset = Integration.changeset(%Integration{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).provider + end + + test "validates provider is a known value" do + attrs = %{organization_id: Ecto.UUID.generate(), provider: "unknown_provider"} + changeset = Integration.changeset(%Integration{}, attrs) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).provider + end + + test "validates sync_interval_minutes is positive" do + attrs = %{ + organization_id: Ecto.UUID.generate(), + provider: "preseem", + sync_interval_minutes: 0 + } + + changeset = Integration.changeset(%Integration{}, attrs) + refute changeset.valid? + assert "must be greater than 0" in errors_on(changeset).sync_interval_minutes + end + + test "validates last_sync_status is a known value" do + attrs = %{ + organization_id: Ecto.UUID.generate(), + provider: "preseem", + last_sync_status: "bogus" + } + + changeset = Integration.changeset(%Integration{}, attrs) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).last_sync_status + end + + test "enforces unique constraint on org + provider", %{organization: org} do + attrs = %{organization_id: org.id, provider: "preseem"} + {:ok, _} = %Integration{} |> Integration.changeset(attrs) |> Repo.insert() + assert {:error, changeset} = %Integration{} |> Integration.changeset(attrs) |> Repo.insert() + assert "has already been taken" in errors_on(changeset).provider + end + end + + describe "credentials encryption" do + test "credentials are encrypted at rest", %{organization: org} do + attrs = %{ + organization_id: org.id, + provider: "preseem", + credentials: %{"api_key" => "super-secret-key"} + } + + {:ok, integration} = %Integration{} |> Integration.changeset(attrs) |> Repo.insert() + reloaded = Repo.get!(Integration, integration.id) + assert reloaded.credentials == %{"api_key" => "super-secret-key"} + + raw = + Repo.one( + from i in "integrations", + where: i.id == type(^integration.id, :binary_id), + select: i.credentials + ) + + refute raw == Jason.encode!(attrs.credentials) + end + end +end