diff --git a/apps/andi/lib/andi_web/controllers/api/audit_log_controller.ex b/apps/andi/lib/andi_web/controllers/api/audit_log_controller.ex new file mode 100644 index 000000000..659160627 --- /dev/null +++ b/apps/andi/lib/andi_web/controllers/api/audit_log_controller.ex @@ -0,0 +1,60 @@ +defmodule AndiWeb.API.AuditLogController do + @moduledoc """ + Module returns audit logs from postgres. + """ + use AndiWeb, :controller + alias Andi.Schemas.AuditEvents + + access_levels(get: [:private]) + + @generic_error_text "Unsupported request. Only one filter can be used at a time - 'user_id', 'audit_id', 'type', 'event_id.'" <> + "For time, exactly 'start_date' and 'end_date' must be used and formatted in ISO-8601. ex. /start_date=2020-12-31&end-date=2021-01-01" + + @spec get(Plug.Conn.t(), any()) :: Plug.Conn.t() + def get(conn, params) do + with {:ok, audit_events} <- get_audit_events(params) do + text(conn, format_events(audit_events)) + else + {:error, error} -> respond_error(conn, error) + end + end + + defp get_audit_events(params) do + case Map.keys(params) do + ["user_id"] -> {:ok, AuditEvents.get_all_for_user(params["user_id"])} + ["audit_id"] -> {:ok, AuditEvents.get(params["audit_id"])} + ["type"] -> {:ok, AuditEvents.get_all_of_type(params["type"])} + ["event_id"] -> {:ok, AuditEvents.get_all_by_event_id(params["event_id"])} + ["start_date", "end_date"] -> get_range(params["start_date"], params["end_date"]) + ["end_date", "start_date"] -> get_range(params["start_date"], params["end_date"]) + [] -> {:ok, AuditEvents.get_all()} + _ -> {:error, @generic_error_text} + end + end + + defp get_range(start_date, end_date) do + with {:ok, start_struct} <- Date.from_iso8601(start_date), + {:ok, end_struct} <- Date.from_iso8601(end_date) do + {:ok, AuditEvents.get_all_in_range(start_struct, end_struct)} + else + {:error, _error} -> {:error, "Inproperly formatted dates. Use ISO-8601, ex. 2021-01-01"} + end + end + + defp format_events(audit_events) do + for event <- audit_events do + trimmed_event = + Map.from_struct(event) + |> Map.delete(:__meta__) + |> Kernel.inspect() + + "#{trimmed_event}\n" + end + end + + defp respond_error(conn, error) do + conn + |> put_status(:bad_request) + |> text(error) + end +end diff --git a/apps/andi/lib/andi_web/controllers/api/ingestion_controller.ex b/apps/andi/lib/andi_web/controllers/api/ingestion_controller.ex index 7cfc54412..3e719d540 100644 --- a/apps/andi/lib/andi_web/controllers/api/ingestion_controller.ex +++ b/apps/andi/lib/andi_web/controllers/api/ingestion_controller.ex @@ -71,7 +71,7 @@ defmodule AndiWeb.API.IngestionController do defp dataset_exists?(id) do case DatasetStore.get(id) do {:ok, nil} -> {:ok, false} - {:ok, dataset} -> {:ok, true} + {:ok, _dataset} -> {:ok, true} {:error, error} -> {:error, error} end end diff --git a/apps/andi/lib/andi_web/router.ex b/apps/andi/lib/andi_web/router.ex index 8e43ad72b..4ef55c415 100644 --- a/apps/andi/lib/andi_web/router.ex +++ b/apps/andi/lib/andi_web/router.ex @@ -83,17 +83,21 @@ defmodule AndiWeb.Router do get "/v1/datasets", DatasetController, :get_all get "/v1/dataset/:dataset_id", DatasetController, :get + put "/v1/dataset", DatasetController, :create + post "/v1/dataset/disable", DatasetController, :disable + post "/v1/dataset/delete", DatasetController, :delete + get "/v1/ingestions", IngestionController, :get_all get "/v1/ingestion/:ingestion_id", IngestionController, :get - put "/v1/dataset", DatasetController, :create put "/v1/ingestion", IngestionController, :create post "/v1/ingestion/publish", IngestionController, :publish - post "/v1/dataset/disable", DatasetController, :disable - post "/v1/dataset/delete", DatasetController, :delete post "/v1/ingestion/delete", IngestionController, :delete + get "/v1/organizations", OrganizationController, :get_all post "/v1/organization/:org_id/users/add", OrganizationController, :add_users_to_organization post "/v1/organization", OrganizationController, :create + + get "/v1/audit", AuditLogController, :get end scope "/auth", AndiWeb do diff --git a/apps/andi/mix.exs b/apps/andi/mix.exs index 18a6cbb75..d91e0b5d8 100644 --- a/apps/andi/mix.exs +++ b/apps/andi/mix.exs @@ -4,7 +4,7 @@ defmodule Andi.MixProject do def project do [ app: :andi, - version: "2.6.65", + version: "2.6.66", build_path: "../../_build", config_path: "../../config/config.exs", deps_path: "../../deps", diff --git a/apps/andi/test/integration/andi_web/access_level_test.exs b/apps/andi/test/integration/andi_web/access_level_test.exs index 5dea60927..78315301b 100644 --- a/apps/andi/test/integration/andi_web/access_level_test.exs +++ b/apps/andi/test/integration/andi_web/access_level_test.exs @@ -103,6 +103,13 @@ defmodule AndiWeb.AccessLevelTest do end end + describe "audit logs" do + test "does not allow access to audit logs without curator role", %{curator_conn: conn} do + assert get(conn, "/audit") + |> response(404) + end + end + defp andi_children() do Supervisor.which_children(Andi.Supervisor) |> Enum.map(&elem(&1, 0)) diff --git a/apps/andi/test/integration/andi_web/controllers/audit_log_controller_test.exs b/apps/andi/test/integration/andi_web/controllers/audit_log_controller_test.exs new file mode 100644 index 000000000..61e14d2f5 --- /dev/null +++ b/apps/andi/test/integration/andi_web/controllers/audit_log_controller_test.exs @@ -0,0 +1,30 @@ +defmodule AndiWeb.EditControllerTest do + use ExUnit.Case + use Andi.DataCase + use AndiWeb.Test.AuthConnCase.IntegrationCase + + @moduletag shared_data_connection: true + + alias Andi.Schemas.AuditEvents + alias SmartCity.TestDataGenerator, as: TDG + + @url_path "/api/v1/audit" + + describe "Audit Log controller" do + setup do + AuditEvents.log_audit_event(:api, :event_type, %{data: "data"}) + end + + test "Returns 200 when for users with curator role", %{curator_conn: curator_conn, andi_dataset: andi_dataset} do + conn = get(curator_conn, "#{@url_path}/") + + assert response(conn, 200) + assert redirected_to(conn) =~ "data" + end + + test "displays error for users without a curator role", %{public_conn: public_conn, andi_dataset: andi_dataset} do + conn = get(public_conn, "#{@url_path}/") + assert response(conn, 403) + end + end +end diff --git a/apps/andi/test/unit/andi_web/controllers/api/audit_log_controller_test.exs b/apps/andi/test/unit/andi_web/controllers/api/audit_log_controller_test.exs new file mode 100644 index 000000000..30cb9acda --- /dev/null +++ b/apps/andi/test/unit/andi_web/controllers/api/audit_log_controller_test.exs @@ -0,0 +1,117 @@ +defmodule AndiWeb.API.AuditLogControllerTest do + use AndiWeb.Test.AuthConnCase.UnitCase + use Placebo + + alias Andi.Schemas.AuditEvents + alias Andi.Schemas.AuditEvent + @route "/api/v1/audit" + @error_text "Unsupported request. Only one filter can be used at a time - 'user_id', 'audit_id', 'type', 'event_id.'" <> + "For time, exactly 'start_date' and 'end_date' must be used and formatted in ISO-8601. ex. /start_date=2020-12-31&end-date=2021-01-01" + + setup %{} do + logs = [ + struct(AuditEvent, %{ + id: "id", + user_id: "user_id", + event_type: "event_type", + event: %{ + id: "event id", + list: [ + deeply_nested_data: %{ + deep_data: "deep" + }, + list_data: "list" + ] + } + }), + struct(AuditEvent, %{ + id: "id", + user_id: "user_id", + event_type: "event_type", + event: %{ + data: "some data", + other_data: "some other_data" + } + }) + ] + + logs_as_text = """ + %{event: %{id: \"event id\", list: [deeply_nested_data: %{deep_data: \"deep\"}, list_data: \"list\"]}, event_type: \"event_type\", id: \"id\", inserted_at: nil, updated_at: nil, user_id: \"user_id\"} + %{event: %{data: \"some data\", other_data: \"some other_data\"}, event_type: \"event_type\", id: \"id\", inserted_at: nil, updated_at: nil, user_id: \"user_id\"} + """ + + [logs: logs, logs_as_text: logs_as_text] + end + + test "get all audit logs", %{conn: conn, logs: logs, logs_as_text: logs_as_text} do + allow(AuditEvents.get_all(), return: logs) + conn = get(conn, "#{@route}") + + assert_called(AuditEvents.get_all()) + + assert response(conn, 200) =~ logs_as_text + end + + test "get by audit id", %{conn: conn, logs: logs, logs_as_text: logs_as_text} do + audit_id = "12345" + allow(AuditEvents.get(audit_id), return: logs) + conn = get(conn, "#{@route}?audit_id=#{audit_id}") + + assert_called(AuditEvents.get(audit_id)) + + assert response(conn, 200) =~ logs_as_text + end + + test "get by user id", %{conn: conn, logs: logs, logs_as_text: logs_as_text} do + user_id = "user_id" + allow(AuditEvents.get_all_for_user(user_id), return: logs) + conn = get(conn, "#{@route}?user_id=#{user_id}") + + assert_called(AuditEvents.get_all_for_user(user_id)) + + assert response(conn, 200) =~ logs_as_text + end + + test "get by type", %{conn: conn, logs: logs, logs_as_text: logs_as_text} do + type = "some_type" + allow(AuditEvents.get_all_of_type(type), return: logs) + conn = get(conn, "#{@route}?type=#{type}") + + assert_called(AuditEvents.get_all_of_type(type)) + + assert response(conn, 200) =~ logs_as_text + end + + test "get by event id", %{conn: conn, logs: logs, logs_as_text: logs_as_text} do + event_id = "54321" + allow(AuditEvents.get_all_by_event_id(event_id), return: logs) + conn = get(conn, "#{@route}?event_id=#{event_id}") + + assert_called(AuditEvents.get_all_by_event_id(event_id)) + + assert response(conn, 200) =~ logs_as_text + end + + test "filter by dates", %{conn: conn, logs: logs, logs_as_text: logs_as_text} do + {:ok, start_date_struct} = Date.new(2020, 6, 5) + {:ok, end_date_struct} = Date.new(2020, 10, 9) + allow(AuditEvents.get_all_in_range(start_date_struct, end_date_struct), return: logs) + + conn = get(conn, "#{@route}?start_date=2020-06-05&end_date=2020-10-09") + + assert_called(AuditEvents.get_all_in_range(start_date_struct, end_date_struct)) + assert response(conn, 200) =~ logs_as_text + end + + test "returns error when given multiple arguments", %{conn: conn} do + conn = get(conn, "#{@route}?event_id=event&type=type") + + assert response(conn, 400) =~ @error_text + end + + test "returns error when given unsupported argument", %{conn: conn} do + conn = get(conn, "#{@route}?foobar=foo") + + assert response(conn, 400) =~ @error_text + end +end diff --git a/apps/andi/test/unit/andi_web/controllers/api/dataset_controller_test.exs b/apps/andi/test/unit/andi_web/controllers/api/dataset_controller_test.exs index bc751be7f..d97e9c55d 100644 --- a/apps/andi/test/unit/andi_web/controllers/api/dataset_controller_test.exs +++ b/apps/andi/test/unit/andi_web/controllers/api/dataset_controller_test.exs @@ -39,60 +39,7 @@ defmodule AndiWeb.API.DatasetControllerTest do meck_options: [:passthrough] ) - uuid = Faker.UUID.v4() - - request = %{ - "id" => uuid, - "technical" => %{ - "dataName" => "dataset", - "orgId" => "org-123-456", - "orgName" => "org", - "stream" => false, - "sourceUrl" => "https://example.com", - "sourceType" => "stream", - "sourceFormat" => "gtfs", - "cadence" => "9000", - "schema" => [%{name: "billy", type: "writer"}], - "private" => false, - "headers" => %{ - "accepts" => "application/foobar" - }, - "sourceQueryParams" => %{ - "apiKey" => "foobar" - }, - "systemName" => "org__dataset", - "transformations" => [], - "validations" => [] - }, - "business" => %{ - "benefitRating" => 0.5, - "dataTitle" => "dataset title", - "description" => "description", - "modifiedDate" => "", - "orgTitle" => "org title", - "contactName" => "contact name", - "contactEmail" => "contact@email.com", - "license" => "https://www.test.net", - "rights" => "rights information", - "homepage" => "", - "keywords" => [], - "issuedDate" => "2020-01-01T00:00:00Z", - "publishFrequency" => "all day, ey'r day", - "riskRating" => 1.0 - }, - "_metadata" => %{ - "intendedUse" => [], - "expectedBenefit" => [] - } - } - - message = - request - |> SmartCity.Helpers.to_atom_keys() - |> TDG.create_dataset() - |> struct_to_map_with_string_keys() - - {:ok, request: request, message: message, example_datasets: example_datasets} + {:ok, example_datasets: example_datasets} end describe "POST /dataset/disable" do @@ -225,7 +172,7 @@ defmodule AndiWeb.API.DatasetControllerTest do end @tag capture_log: true - test "PUT /api/ creating a dataset with a set id returns a 400", %{conn: conn, example_datasets: example_datasets} do + test "PUT /api/ creating a dataset with a set id returns a 400", %{conn: conn} do dataset = TDG.create_dataset(%{}) |> struct_to_map_with_string_keys() allow(Brook.Event.send(@instance_name, any(), any(), any()), return: :ok)