From c3a9bac465e23632e6107ba1a7d385d3edec0dab Mon Sep 17 00:00:00 2001 From: Jamil Date: Wed, 5 Mar 2025 00:37:01 +0000 Subject: [PATCH] feat(portal): Add client endpoints to REST API (#8355) Adds the following endpoints: - `PUT /clients/:id` for updating the `name` - `PUT /clients/:client_id/verify` for verifying a client - `PUT /clients/:client_id/unverify` for unverifying a client - `GET /clients` for listing clients in an account - `GET /clients/:id` for getting a single client - `DELETE /clients/:id` for deleting a client Related: #8081 --- .../lib/api/controllers/client_controller.ex | 167 ++++++++++ .../api/lib/api/controllers/client_json.ex | 51 +++ .../api/controllers/fallback_controller.ex | 9 + elixir/apps/api/lib/api/router.ex | 4 + .../apps/api/lib/api/schemas/client_schema.ex | 311 ++++++++++++++++++ .../api/lib/api/schemas/gateway_schema.ex | 6 +- .../controllers/client_controller_test.exs | 297 +++++++++++++++++ .../domain/lib/domain/clients/authorizer.ex | 3 +- 8 files changed, 844 insertions(+), 4 deletions(-) create mode 100644 elixir/apps/api/lib/api/controllers/client_controller.ex create mode 100644 elixir/apps/api/lib/api/controllers/client_json.ex create mode 100644 elixir/apps/api/lib/api/schemas/client_schema.ex create mode 100644 elixir/apps/api/test/api/controllers/client_controller_test.exs diff --git a/elixir/apps/api/lib/api/controllers/client_controller.ex b/elixir/apps/api/lib/api/controllers/client_controller.ex new file mode 100644 index 000000000..030bc492e --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/client_controller.ex @@ -0,0 +1,167 @@ +defmodule API.ClientController do + use API, :controller + use OpenApiSpex.ControllerSpecs + alias API.Pagination + alias Domain.Clients + + action_fallback(API.FallbackController) + + tags(["Clients"]) + + operation(:index, + summary: "List Clients", + parameters: [ + limit: [ + in: :query, + description: "Limit Clients returned", + type: :integer, + example: 10 + ], + page_cursor: [in: :query, description: "Next/Prev page cursor", type: :string] + ], + responses: [ + ok: {"Client Response", "application/json", API.Schemas.Client.ListResponse} + ] + ) + + # List Clients + def index(conn, params) do + list_opts = + params + |> Pagination.params_to_list_opts() + |> Keyword.put(:preload, :online?) + + with {:ok, clients, metadata} <- Clients.list_clients(conn.assigns.subject, list_opts) do + render(conn, :index, clients: clients, metadata: metadata) + end + end + + operation(:show, + summary: "Show Client", + parameters: [ + id: [ + in: :path, + description: "Client ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + responses: [ + ok: {"Client Response", "application/json", API.Schemas.Client.Response} + ] + ) + + # Show a specific Client + def show(conn, %{"id" => id}) do + with {:ok, client} <- + Clients.fetch_client_by_id(id, conn.assigns.subject, preload: :online?) do + render(conn, :show, client: client) + end + end + + operation(:update, + summary: "Update Client", + parameters: [ + id: [ + in: :path, + description: "Client ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + request_body: + {"Client Attributes", "application/json", API.Schemas.Client.Request, required: true}, + responses: [ + ok: {"Client Response", "application/json", API.Schemas.Client.Response} + ] + ) + + # Update a Client + def update(conn, %{"id" => id, "client" => params}) do + subject = conn.assigns.subject + + with {:ok, client} <- Clients.fetch_client_by_id(id, subject, preload: :online?), + {:ok, client} <- Clients.update_client(client, params, subject) do + render(conn, :show, client: client) + end + end + + def update(_conn, _params) do + {:error, :bad_request} + end + + operation(:verify, + summary: "Verify Client", + parameters: [ + id: [ + in: :path, + description: "Client ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + responses: [ + ok: {"Client Response", "application/json", API.Schemas.Client.Response} + ] + ) + + # Verify a Client + def verify(conn, %{"id" => id}) do + subject = conn.assigns.subject + + with {:ok, client} <- Clients.fetch_client_by_id(id, subject, preload: :online?), + {:ok, client} <- Clients.verify_client(client, subject) do + render(conn, :show, client: client) + end + end + + operation(:unverify, + summary: "Unverify Client", + parameters: [ + id: [ + in: :path, + description: "Client ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + responses: [ + ok: {"Client Response", "application/json", API.Schemas.Client.Response} + ] + ) + + # Unverify a Client + def unverify(conn, %{"id" => id}) do + subject = conn.assigns.subject + + with {:ok, client} <- Clients.fetch_client_by_id(id, subject, preload: :online?), + {:ok, client} <- Clients.remove_client_verification(client, subject) do + render(conn, :show, client: client) + end + end + + operation(:delete, + summary: "Delete a Client", + parameters: [ + id: [ + in: :path, + description: "Client ID", + type: :string, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + responses: [ + ok: {"Client Response", "application/json", API.Schemas.Client.Response} + ] + ) + + # Delete a Client + def delete(conn, %{"id" => id}) do + subject = conn.assigns.subject + + with {:ok, client} <- Clients.fetch_client_by_id(id, subject, preload: :online?), + {:ok, client} <- Clients.delete_client(client, subject) do + render(conn, :show, client: client) + end + end +end diff --git a/elixir/apps/api/lib/api/controllers/client_json.ex b/elixir/apps/api/lib/api/controllers/client_json.ex new file mode 100644 index 000000000..6a436258d --- /dev/null +++ b/elixir/apps/api/lib/api/controllers/client_json.ex @@ -0,0 +1,51 @@ +defmodule API.ClientJSON do + alias API.Pagination + alias Domain.Clients + + @doc """ + Renders a list of Clients. + """ + def index(%{clients: clients, metadata: metadata}) do + %{ + data: Enum.map(clients, &data/1), + metadata: Pagination.metadata(metadata) + } + end + + @doc """ + Render a single Client + """ + def show(%{client: client}) do + %{data: data(client)} + end + + defp data(%Clients.Client{} = client) do + %{ + id: client.id, + external_id: client.external_id, + actor_id: client.actor_id, + name: client.name, + ipv4: client.ipv4, + ipv6: client.ipv6, + online: client.online?, + last_seen_user_agent: client.last_seen_user_agent, + last_seen_remote_ip: client.last_seen_remote_ip, + last_seen_remote_ip_location_region: client.last_seen_remote_ip_location_region, + last_seen_remote_ip_location_city: client.last_seen_remote_ip_location_city, + last_seen_remote_ip_location_lat: client.last_seen_remote_ip_location_lat, + last_seen_remote_ip_location_lon: client.last_seen_remote_ip_location_lon, + last_seen_version: client.last_seen_version, + last_seen_at: client.last_seen_at, + device_serial: client.device_serial, + device_uuid: client.device_uuid, + identifier_for_vendor: client.identifier_for_vendor, + firebase_installation_id: client.firebase_installation_id, + verified_at: client.verified_at, + verified_by: client.verified_by, + verified_by_actor_id: client.verified_by_actor_id, + verified_by_identity_id: client.verified_by_identity_id, + created_at: client.inserted_at, + updated_at: client.updated_at + } + end +end diff --git a/elixir/apps/api/lib/api/controllers/fallback_controller.ex b/elixir/apps/api/lib/api/controllers/fallback_controller.ex index 4b0c29023..91d4eed8f 100644 --- a/elixir/apps/api/lib/api/controllers/fallback_controller.ex +++ b/elixir/apps/api/lib/api/controllers/fallback_controller.ex @@ -15,6 +15,15 @@ defmodule API.FallbackController do |> render(:"401") end + def call(conn, {:error, {:unauthorized, details}}) do + reason = Keyword.get(details, :reason, "Unauthorized") + + conn + |> put_status(:unauthorized) + |> put_view(json: API.ErrorJSON) + |> render(:"401", reason: reason) + end + def call(conn, {:error, :bad_request}) do conn |> put_status(:bad_request) diff --git a/elixir/apps/api/lib/api/router.ex b/elixir/apps/api/lib/api/router.ex index 4910c7cd7..35d02310a 100644 --- a/elixir/apps/api/lib/api/router.ex +++ b/elixir/apps/api/lib/api/router.ex @@ -40,6 +40,10 @@ defmodule API.Router do scope "/", API do pipe_through :api + resources "/clients", ClientController, except: [:new, :edit, :create] + put "/clients/:id/verify", ClientController, :verify + put "/clients/:id/unverify", ClientController, :unverify + resources "/resources", ResourceController, except: [:new, :edit] resources "/policies", PolicyController, except: [:new, :edit] diff --git a/elixir/apps/api/lib/api/schemas/client_schema.ex b/elixir/apps/api/lib/api/schemas/client_schema.ex new file mode 100644 index 000000000..8b91171d7 --- /dev/null +++ b/elixir/apps/api/lib/api/schemas/client_schema.ex @@ -0,0 +1,311 @@ +defmodule API.Schemas.Client do + alias OpenApiSpex.Schema + + defmodule GetSchema do + require OpenApiSpex + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "Client", + description: "Client", + type: :object, + properties: %{ + id: %Schema{type: :string, description: "Client ID"}, + actor_id: %Schema{type: :string, description: "Actor ID"}, + name: %Schema{ + type: :string, + description: "Client Name" + }, + ipv4: %Schema{ + type: :string, + description: "Tunnel IPv4 Address of Client" + }, + ipv6: %Schema{ + type: :string, + description: "Tunnel IPv6 Address of Client" + }, + online: %Schema{ + type: :boolean, + description: "Online status of Client" + }, + last_seen_user_agent: %Schema{ + type: :string, + description: "Last seen user agent" + }, + last_seen_remote_ip: %Schema{ + type: :string, + description: "Last seen remote IP" + }, + last_seen_remote_ip_location_region: %Schema{ + type: :string, + description: "Last seen remote IP location region" + }, + last_seen_remote_ip_location_city: %Schema{ + type: :string, + description: "Last seen remote IP location city" + }, + last_seen_remote_ip_location_lat: %Schema{ + type: :number, + description: "Last seen remote IP location latitude" + }, + last_seen_remote_ip_location_lon: %Schema{ + type: :number, + description: "Last seen remote IP location longitude" + }, + last_seen_version: %Schema{ + type: :string, + description: "Last seen version" + }, + last_seen_at: %Schema{ + type: :string, + description: "Last seen at" + }, + device_serial: %Schema{ + type: :string, + description: "Device manufacturer serial number (unavailable for mobile devices)" + }, + device_uuid: %Schema{ + type: :string, + description: "Device manufacturer UUID (unavailable for mobile devices)" + }, + identifier_for_vendor: %Schema{ + type: :string, + description: "App installation ID (iOS only)" + }, + firebase_installation_id: %Schema{ + type: :string, + description: "Firebase installation ID (Android only)" + }, + verified_at: %Schema{ + type: :string, + description: "Client verification timestamp" + }, + verified_by: %Schema{ + type: :string, + description: "Client verification method", + enum: [:system, :actor, :identity] + }, + verified_by_actor_id: %Schema{ + type: :string, + description: "Actor ID who verified the client" + }, + verified_by_identity_id: %Schema{ + type: :string, + description: "Identity ID who verified the client" + }, + created_at: %Schema{ + type: :string, + description: "Client creation timestamp" + }, + updated_at: %Schema{ + type: :string, + description: "Client update timestamp" + } + }, + required: [ + :id, + :actor_id, + :external_id, + :name, + :ipv4, + :ipv6, + :online, + :last_seen_user_agent, + :last_seen_remote_ip, + :last_seen_remote_ip_location_region, + :last_seen_remote_ip_location_city, + :last_seen_remote_ip_location_lat, + :last_seen_remote_ip_location_lon, + :last_seen_version, + :last_seen_at, + :created_at, + :updated_at + ], + example: %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "external_id" => "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c", + "actor_id" => "6ecc106b-75c1-48a5-846c-14782180c1ff", + "name" => "John's Macbook Air", + "ipv4" => "100.64.0.1", + "ipv6" => "fd00:2021:1111::1", + "online" => true, + "last_seen_user_agent" => "Mac OS/15.1.1 connlib/1.4.5 (arm64; 24.1.0)", + "last_seen_remote_ip" => "1.2.3.4", + "last_seen_remote_ip_location_region" => "California", + "last_seen_remote_ip_location_city" => "San Francisco", + "last_seen_remote_ip_location_lat" => 37.7749, + "last_seen_remote_ip_location_lon" => -122.4194, + "last_seen_version" => "1.4.5", + "last_seen_at" => "2025-01-01T00:00:00Z", + "created_at" => "2025-01-01T00:00:00Z", + "updated_at" => "2025-01-01T00:00:00Z" + } + }) + end + + defmodule PutSchema do + require OpenApiSpex + alias OpenApiSpex.Schema + + OpenApiSpex.schema(%{ + title: "ClientPut", + description: "Put schema for updating a single Client", + type: :object, + properties: %{ + name: %Schema{ + type: :string, + description: "Client Name" + } + }, + required: [:name], + example: %{ + "name" => "John's Macbook Air" + } + }) + end + + defmodule Request do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.Client + + OpenApiSpex.schema(%{ + title: "ClientPutRequest", + description: "PUT body for updating a Client", + type: :object, + properties: %{ + client: Client.PutSchema + }, + required: [:client], + example: %{ + "client" => %{ + "name" => "John's Macbook Air" + } + } + }) + end + + defmodule Response do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.Client + + OpenApiSpex.schema(%{ + title: "ClientResponse", + description: "Response schema for single Client", + type: :object, + properties: %{ + data: Client.GetSchema + }, + example: %{ + "data" => %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "external_id" => "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c", + "actor_id" => "6ecc106b-75c1-48a5-846c-14782180c1ff", + "name" => "John's Macbook Air", + "ipv4" => "100.64.0.1", + "ipv6" => "fd00:2021:1111::1", + "online" => true, + "last_seen_user_agent" => "Mac OS/15.1.1 connlib/1.4.5 (arm64; 24.1.0)", + "last_seen_remote_ip" => "1.2.3.4", + "last_seen_remote_ip_location_region" => "California", + "last_seen_remote_ip_location_city" => "San Francisco", + "last_seen_remote_ip_location_lat" => 37.7749, + "last_seen_remote_ip_location_lon" => -122.4194, + "last_seen_version" => "1.4.5", + "last_seen_at" => "2025-01-01T00:00:00Z", + "device_serial" => "GCCFX0DBQ6L5", + "device_uuid" => "7A461FF9-0BE2-64A9-A418-539D9A21827B", + "identifier_for_vendor" => nil, + "firebase_installation_id" => nil, + "verified_at" => "2025-01-01T00:00:00Z", + "verified_by" => "identity", + "verified_by_actor_id" => nil, + "verified_by_identity_id" => "6ecc106b-75c1-48a5-846c-14782180c1ff", + "created_at" => "2025-01-01T00:00:00Z", + "updated_at" => "2025-01-01T00:00:00Z" + } + } + }) + end + + defmodule ListResponse do + require OpenApiSpex + alias OpenApiSpex.Schema + alias API.Schemas.Client + + OpenApiSpex.schema(%{ + title: "ClientsResponse", + description: "Response schema for multiple Clients", + type: :object, + properties: %{ + data: %Schema{description: "Clients details", type: :array, items: Client.GetSchema}, + metadata: %Schema{description: "Pagination metadata", type: :object} + }, + example: %{ + "data" => [ + %{ + "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "external_id" => "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c", + "actor_id" => "6ecc106b-75c1-48a5-846c-14782180c1ff", + "name" => "John's Macbook Air", + "ipv4" => "100.64.0.1", + "ipv6" => "fd00:2021:1111::1", + "online" => true, + "last_seen_user_agent" => "Mac OS/15.1.1 connlib/1.4.5 (arm64; 24.1.0)", + "last_seen_remote_ip" => "1.2.3.4", + "last_seen_remote_ip_location_region" => "California", + "last_seen_remote_ip_location_city" => "San Francisco", + "last_seen_remote_ip_location_lat" => 37.7749, + "last_seen_remote_ip_location_lon" => -122.4194, + "last_seen_version" => "1.4.5", + "last_seen_at" => "2025-01-01T00:00:00Z", + "device_serial" => "GCCFX0DBQ6L5", + "device_uuid" => "7A461FF9-0BE2-64A9-A418-539D9A21827B", + "identifier_for_vendor" => nil, + "firebase_installation_id" => nil, + "verified_at" => "2025-01-01T00:00:00Z", + "verified_by" => "identity", + "verified_by_actor_id" => nil, + "verified_by_identity_id" => "6ecc106b-75c1-48a5-846c-14782180c1ff", + "created_at" => "2025-01-01T00:00:00Z", + "updated_at" => "2025-01-01T00:00:00Z" + }, + %{ + "id" => "9a7f82f-831a-4a9d-8f17-c66c2bb6e205", + "external_id" => "6c37c0042f40bbb16e007d0d6c8e77c0ac2cab3cc3b923c42d1157a934e436ac", + "actor_id" => "2ecc106b-75c1-48a5-846c-14782180c1ff", + "name" => "iPad", + "ipv4" => "100.64.0.2", + "ipv6" => "fd00:2021:1111::2", + "online" => false, + "last_seen_user_agent" => "iOS/18.3.1 connlib/1.4.6 (24.3.0)", + "last_seen_remote_ip" => "1.2.3.4", + "last_seen_remote_ip_location_region" => "California", + "last_seen_remote_ip_location_city" => "San Francisco", + "last_seen_remote_ip_location_lat" => 37.7749, + "last_seen_remote_ip_location_lon" => -122.4194, + "last_seen_version" => "1.4.6", + "last_seen_at" => "2025-01-01T00:00:00Z", + "device_serial" => nil, + "device_uuid" => nil, + "identifier_for_vendor" => "7A461FF9-0BE2-64A9-A418-539D9A21827B", + "firebase_installation_id" => nil, + "verified_at" => nil, + "verified_by" => nil, + "verified_by_actor_id" => nil, + "verified_by_identity_id" => nil, + "created_at" => "2025-01-01T00:00:00Z", + "updated_at" => "2025-01-01T00:00:00Z" + } + ], + "metadata" => %{ + "limit" => 2, + "total" => 100, + "prev_page" => "123123425", + "next_page" => "98776234123" + } + } + }) + end +end diff --git a/elixir/apps/api/lib/api/schemas/gateway_schema.ex b/elixir/apps/api/lib/api/schemas/gateway_schema.ex index feca608e6..46598e546 100644 --- a/elixir/apps/api/lib/api/schemas/gateway_schema.ex +++ b/elixir/apps/api/lib/api/schemas/gateway_schema.ex @@ -29,12 +29,12 @@ defmodule API.Schemas.Gateway do description: "Online status of Gateway" } }, - required: [:name, :type], + required: [:id, :name, :ipv4, :ipv6, :online], example: %{ "id" => "42a7f82f-831a-4a9d-8f17-c66c2bb6e205", "name" => "vpc-us-east", - "ipv4" => "1.2.3.4", - "ipv6" => "", + "ipv4" => "100.64.0.1", + "ipv6" => "fd00:2021:1111::1", "online" => true } }) diff --git a/elixir/apps/api/test/api/controllers/client_controller_test.exs b/elixir/apps/api/test/api/controllers/client_controller_test.exs new file mode 100644 index 000000000..cc8a36b75 --- /dev/null +++ b/elixir/apps/api/test/api/controllers/client_controller_test.exs @@ -0,0 +1,297 @@ +defmodule API.ClientControllerTest do + use API.ConnCase, async: true + alias Domain.Clients.Client + + setup do + account = Fixtures.Accounts.create_account() + actor = Fixtures.Actors.create_actor(type: :api_client, account: account) + identity = Fixtures.Auth.create_identity(account: account, actor: actor) + client = Fixtures.Clients.create_client(account: account) + subject = Fixtures.Auth.create_subject(identity: identity) + + %{ + account: account, + actor: actor, + client: client, + identity: identity, + subject: subject + } + end + + describe "index/2" do + test "returns error when not authorized", %{conn: conn, client: client} do + conn = get(conn, ~p"/clients/#{client}") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "lists all clients", %{ + conn: conn, + actor: actor, + client: client, + account: account + } do + clients = + for _ <- 1..3, + do: Fixtures.Clients.create_client(%{account: account}) + + clients = [client | clients] + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get(~p"/clients") + + assert %{ + "data" => data, + "metadata" => %{ + "count" => count, + "limit" => limit, + "next_page" => next_page, + "prev_page" => prev_page + } + } = json_response(conn, 200) + + # client was created in setup + assert count == 4 + assert limit == 50 + assert is_nil(next_page) + assert is_nil(prev_page) + + data_ids = Enum.map(data, & &1["id"]) + client_ids = Enum.map(clients, & &1.id) + + assert equal_ids?(data_ids, client_ids) + end + + test "lists clients with limit", %{ + conn: conn, + actor: actor, + client: client, + account: account + } do + clients = + for _ <- 1..3, + do: Fixtures.Clients.create_client(%{account: account}) + + clients = [client | clients] + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get(~p"/clients", limit: "2") + + assert %{ + "data" => data, + "metadata" => %{ + "count" => count, + "limit" => limit, + "next_page" => next_page, + "prev_page" => prev_page + } + } = json_response(conn, 200) + + assert limit == 2 + # client was created in setup + assert count == 4 + refute is_nil(next_page) + assert is_nil(prev_page) + + data_ids = Enum.map(data, & &1["id"]) |> MapSet.new() + client_ids = Enum.map(clients, & &1.id) |> MapSet.new() + + assert MapSet.subset?(data_ids, client_ids) + end + end + + describe "show/2" do + test "returns error when not authorized", %{ + conn: conn, + client: client + } do + conn = get(conn, ~p"/clients/#{client.id}") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "returns a single client", %{ + conn: conn, + actor: actor, + client: client + } do + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> get(~p"/clients/#{client.id}") + + assert json_response(conn, 200) == %{ + "data" => %{ + "id" => client.id, + "name" => client.name, + "ipv4" => "#{client.ipv4}", + "ipv6" => "#{client.ipv6}", + "actor_id" => client.actor_id, + "created_at" => client.inserted_at && DateTime.to_iso8601(client.inserted_at), + "device_serial" => client.device_serial, + "device_uuid" => client.device_uuid, + "external_id" => client.external_id, + "firebase_installation_id" => client.firebase_installation_id, + "identifier_for_vendor" => client.identifier_for_vendor, + "last_seen_at" => + client.last_seen_at && DateTime.to_iso8601(client.last_seen_at), + "last_seen_remote_ip" => "#{client.last_seen_remote_ip}", + "last_seen_remote_ip_location_city" => client.last_seen_remote_ip_location_city, + "last_seen_remote_ip_location_lat" => client.last_seen_remote_ip_location_lat, + "last_seen_remote_ip_location_lon" => client.last_seen_remote_ip_location_lon, + "last_seen_remote_ip_location_region" => + client.last_seen_remote_ip_location_region, + "last_seen_user_agent" => client.last_seen_user_agent, + "last_seen_version" => client.last_seen_version, + "online" => client.online?, + "updated_at" => client.updated_at && DateTime.to_iso8601(client.updated_at), + "verified_at" => client.verified_at && DateTime.to_iso8601(client.verified_at), + "verified_by" => client.verified_by, + "verified_by_actor_id" => client.verified_by_actor_id, + "verified_by_identity_id" => client.verified_by_identity_id + } + } + end + end + + describe "update/2" do + test "returns error when not authorized", %{conn: conn, client: client} do + conn = put(conn, ~p"/clients/#{client}", %{}) + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "returns error on empty params/body", %{conn: conn, actor: actor, client: client} do + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> put(~p"/clients/#{client}") + + assert resp = json_response(conn, 400) + assert resp == %{"error" => %{"reason" => "Bad Request"}} + end + + test "updates a client", %{conn: conn, actor: actor, client: client} do + attrs = %{"name" => "Updated Client"} + + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> put(~p"/clients/#{client}", client: attrs) + + assert resp = json_response(conn, 200) + + assert resp["data"]["id"] == client.id + assert resp["data"]["name"] == attrs["name"] + end + end + + describe "verify/2" do + test "returns error when not authorized", %{conn: conn, client: client} do + conn = put(conn, ~p"/clients/#{client}/verify", %{}) + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "verifies a client", %{conn: conn, actor: actor, client: client} do + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> put(~p"/clients/#{client}/verify") + + assert resp = json_response(conn, 200) + + assert resp["data"]["id"] == client.id + assert resp["data"]["verified_at"] + assert resp["data"]["verified_by"] + assert resp["data"]["verified_by_actor_id"] + refute resp["data"]["verified_by_identity_id"] + end + end + + describe "unverify/2" do + test "returns error when not authorized", %{conn: conn, client: client} do + conn = put(conn, ~p"/clients/#{client}/verify", %{}) + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "unverifies a client", %{conn: conn, actor: actor, client: client} do + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> put(~p"/clients/#{client}/unverify") + + assert resp = json_response(conn, 200) + + assert resp["data"]["id"] == client.id + refute resp["data"]["verified_at"] + refute resp["data"]["verified_by"] + refute resp["data"]["verified_by_actor_id"] + refute resp["data"]["verified_by_identity_id"] + end + end + + describe "delete/2" do + test "returns error when not authorized", %{ + conn: conn, + client: client + } do + conn = delete(conn, ~p"/clients/#{client.id}") + assert json_response(conn, 401) == %{"error" => %{"reason" => "Unauthorized"}} + end + + test "deletes a client", %{ + conn: conn, + actor: actor, + client: client + } do + conn = + conn + |> authorize_conn(actor) + |> put_req_header("content-type", "application/json") + |> delete(~p"/clients/#{client}") + + assert json_response(conn, 200) == %{ + "data" => %{ + "id" => client.id, + "name" => client.name, + "ipv4" => "#{client.ipv4}", + "ipv6" => "#{client.ipv6}", + "actor_id" => client.actor_id, + "created_at" => client.inserted_at && DateTime.to_iso8601(client.inserted_at), + "device_serial" => client.device_serial, + "device_uuid" => client.device_uuid, + "external_id" => client.external_id, + "firebase_installation_id" => client.firebase_installation_id, + "identifier_for_vendor" => client.identifier_for_vendor, + "last_seen_at" => + client.last_seen_at && DateTime.to_iso8601(client.last_seen_at), + "last_seen_remote_ip" => "#{client.last_seen_remote_ip}", + "last_seen_remote_ip_location_city" => client.last_seen_remote_ip_location_city, + "last_seen_remote_ip_location_lat" => client.last_seen_remote_ip_location_lat, + "last_seen_remote_ip_location_lon" => client.last_seen_remote_ip_location_lon, + "last_seen_remote_ip_location_region" => + client.last_seen_remote_ip_location_region, + "last_seen_user_agent" => client.last_seen_user_agent, + "last_seen_version" => client.last_seen_version, + "online" => nil, + "updated_at" => client.updated_at && DateTime.to_iso8601(client.updated_at), + "verified_at" => client.verified_at && DateTime.to_iso8601(client.verified_at), + "verified_by" => client.verified_by, + "verified_by_actor_id" => client.verified_by_actor_id, + "verified_by_identity_id" => client.verified_by_identity_id + } + } + + assert client = Repo.get(Client, client.id) + assert client.deleted_at + end + end +end diff --git a/elixir/apps/domain/lib/domain/clients/authorizer.ex b/elixir/apps/domain/lib/domain/clients/authorizer.ex index 339b42fc4..dffb563be 100644 --- a/elixir/apps/domain/lib/domain/clients/authorizer.ex +++ b/elixir/apps/domain/lib/domain/clients/authorizer.ex @@ -19,7 +19,8 @@ defmodule Domain.Clients.Authorizer do def list_permissions_for_role(:api_client) do [ manage_own_clients_permission(), - manage_clients_permission() + manage_clients_permission(), + verify_clients_permission() ] end