diff --git a/config/prod.exs b/config/prod.exs index c41ba0cb1..c6f38d24f 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -3,6 +3,17 @@ import Config # Do not print debug messages in production config :logger, level: :info +# Add the CloudWatch logger backend in production +config :logger, backends: [:console, {Cadet.Logger.CloudWatchLogger, :cloudwatch_logger}] + +# Configure CloudWatch Logger +config :logger, :cloudwatch_logger, + level: :info, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id], + log_group: "cadet-logs", + log_stream: "#{node()}-#{:os.system_time(:second)}" + # ## SSL Support # # To get SSL working, you will need to add the `https` key diff --git a/config/test.exs b/config/test.exs index 73c0e3451..30a8f8cc0 100644 --- a/config/test.exs +++ b/config/test.exs @@ -9,7 +9,7 @@ config :cadet, CadetWeb.Endpoint, config :cadet, environment: :test # Print only warnings and errors during test -config :logger, level: :warn, compile_time_purge_matching: [[level_lower_than: :warn]] +config :logger, level: :warning, compile_time_purge_matching: [[level_lower_than: :warning]] config :ex_aws, access_key_id: "hello", diff --git a/lib/cadet/accounts/teams.ex b/lib/cadet/accounts/teams.ex index 131b9ca24..12f1e0e7e 100644 --- a/lib/cadet/accounts/teams.ex +++ b/lib/cadet/accounts/teams.ex @@ -12,6 +12,26 @@ defmodule Cadet.Accounts.Teams do alias Cadet.Accounts.{Team, TeamMember, Notification} alias Cadet.Assessments.{Answer, Submission} + @doc """ + Returns all teams for a given course. + + ## Parameters + + * `course_id` - The ID of the course. + + ## Returns + + Returns a list of teams. + + """ + def all_teams_for_course(course_id) do + Team + |> join(:inner, [t], a in assoc(t, :assessment)) + |> where([t, a], a.course_id == ^course_id) + |> Repo.all() + |> Repo.preload(assessment: [:config], team_members: [student: [:user]]) + end + @doc """ Creates a new team and assigns an assessment and team members to it. @@ -44,8 +64,6 @@ defmodule Cadet.Accounts.Teams do true -> Enum.reduce_while(attrs["student_ids"], {:ok, nil}, fn team_attrs, {:ok, _} -> - student_ids = Enum.map(team_attrs, &Map.get(&1, "userId")) - {:ok, team} = %Team{} |> Team.changeset(attrs) @@ -85,7 +103,6 @@ defmodule Cadet.Accounts.Teams do ids = Enum.map(team, &Map.get(&1, "userId")) unique_ids_count = ids |> Enum.uniq() |> Enum.count() - all_ids_distinct = unique_ids_count == Enum.count(ids) student_already_in_team?(-1, ids, assessment_id) end) @@ -209,7 +226,6 @@ defmodule Cadet.Accounts.Teams do """ def update_team(team = %Team{}, new_assessment_id, student_ids) do - old_assessment_id = team.assessment_id team_id = team.id new_student_ids = Enum.map(hd(student_ids), fn student -> Map.get(student, "userId") end) diff --git a/lib/cadet/devices/devices.ex b/lib/cadet/devices/devices.ex index daaf117be..bde47eb09 100644 --- a/lib/cadet/devices/devices.ex +++ b/lib/cadet/devices/devices.ex @@ -212,7 +212,7 @@ defmodule Cadet.Devices do }, 300, [], - '' + "" ) # ExAws includes the session token in the signed payload and doesn't allow diff --git a/lib/cadet/jobs/autograder/lambda_worker.ex b/lib/cadet/jobs/autograder/lambda_worker.ex index a2a6d3f7a..00a6b266c 100644 --- a/lib/cadet/jobs/autograder/lambda_worker.ex +++ b/lib/cadet/jobs/autograder/lambda_worker.ex @@ -21,7 +21,7 @@ defmodule Cadet.Autograder.LambdaWorker do lambda_params = build_request_params(params) if Enum.empty?(lambda_params.testcases) do - Logger.warn("No testcases found. Skipping autograding for answer_id: #{answer.id}") + Logger.warning("No testcases found. Skipping autograding for answer_id: #{answer.id}") # Fix for https://github.com/source-academy/backend/issues/472 Process.sleep(1000) else diff --git a/lib/cadet/jobs/xml_parser.ex b/lib/cadet/jobs/xml_parser.ex index b49128506..bbdb651f3 100644 --- a/lib/cadet/jobs/xml_parser.ex +++ b/lib/cadet/jobs/xml_parser.ex @@ -34,7 +34,7 @@ defmodule Cadet.Updater.XMLParser do :ok else {:error, stage, %{errors: [assessment: {"has submissions", []}]}, _} when is_atom(stage) -> - Logger.warn("Assessment has submissions, ignoring...") + Logger.warning("Assessment has submissions, ignoring...") {:ok, "Assessment has submissions, ignoring..."} {:error, error_message} -> diff --git a/lib/cadet/logger/cloudwatch_logger.ex b/lib/cadet/logger/cloudwatch_logger.ex new file mode 100644 index 000000000..6c828582c --- /dev/null +++ b/lib/cadet/logger/cloudwatch_logger.ex @@ -0,0 +1,301 @@ +defmodule Cadet.Logger.CloudWatchLogger do + @moduledoc """ + A custom Logger backend that sends logs to AWS CloudWatch. + This backend can be configured to log at different levels and formats, + and can include specific metadata in the logs. + """ + + @behaviour :gen_event + require Logger + + defstruct [ + :level, + :format, + :metadata, + :log_group, + :log_stream, + :buffer, + :timer_ref + ] + + @max_buffer_size 1000 + @max_retries 3 + @retry_delay 200 + @flush_interval 5000 + @failed_message "Failed to send log to CloudWatch." + + @impl true + def init({__MODULE__, opts}) when is_list(opts) do + config = configure_merge(read_env(), opts) + {:ok, init(config, %__MODULE__{})} + end + + @impl true + def init({__MODULE__, name}) when is_atom(name) do + config = read_env() + {:ok, init(config, %__MODULE__{})} + end + + @impl true + def handle_call({:configure, options}, state) do + {:ok, :ok, configure(options, state)} + end + + @impl true + def handle_event({level, _gl, {Logger, msg, ts, md}}, state) do + %{ + format: format, + metadata: metadata, + buffer: buffer, + log_stream: log_stream, + log_group: log_group + } = state + + if meet_level?(level, state.level) and not meet_cloudwatch_error?(msg) do + formatted_msg = Logger.Formatter.format(format, level, msg, ts, take_metadata(md, metadata)) + timestamp = timestamp_from_logger_ts(ts) + + log_event = %{ + "timestamp" => timestamp, + "message" => IO.chardata_to_string(formatted_msg) + } + + new_buffer = [log_event | buffer] + + new_buffer = + if length(new_buffer) >= @max_buffer_size do + flush_buffer_async(log_stream, log_group, new_buffer) + [] + else + new_buffer + end + + {:ok, %{state | buffer: new_buffer}} + else + {:ok, state} + end + end + + @impl true + def handle_info(:flush_buffer, state) do + %{buffer: buffer, timer_ref: timer_ref, log_stream: log_stream, log_group: log_group} = state + + if timer_ref, do: Process.cancel_timer(timer_ref) + + new_state = + if length(buffer) > 0 do + flush_buffer_sync(log_stream, log_group, buffer) + %{state | buffer: []} + else + state + end + + new_timer_ref = schedule_flush(@flush_interval) + {:ok, %{new_state | timer_ref: new_timer_ref}} + end + + @impl true + def terminate(_reason, state) do + %{log_stream: log_stream, log_group: log_group, buffer: buffer, timer_ref: timer_ref} = state + + if timer_ref, do: Process.cancel_timer(timer_ref) + flush_buffer_sync(log_stream, log_group, buffer) + :ok + end + + def handle_event(_, state), do: {:ok, state} + def handle_call(_, state), do: {:ok, :ok, state} + def handle_info(_, state), do: {:ok, state} + + # Helpers + defp configure(options, state) do + config = configure_merge(read_env(), options) + Application.put_env(:logger, __MODULE__, config) + init(config, state) + end + + defp normalize_level(lvl) when lvl in [:warn, :warning], do: :warning + defp normalize_level(lvl), do: lvl + + defp meet_level?(_lvl, nil), do: true + + defp meet_level?(lvl, min) do + lvl = normalize_level(lvl) + min = normalize_level(min) + Logger.compare_levels(lvl, min) != :lt + end + + defp meet_cloudwatch_error?(msg) when is_binary(msg) do + String.contains?(msg, @failed_message) + end + + defp meet_cloudwatch_error?(_) do + false + end + + defp flush_buffer_async(log_stream, log_group, buffer) do + if length(buffer) > 0 do + Task.start(fn -> send_to_cloudwatch(log_stream, log_group, buffer) end) + end + end + + defp flush_buffer_sync(log_stream, log_group, buffer) do + if length(buffer) > 0 do + send_to_cloudwatch(log_stream, log_group, buffer) + end + end + + defp schedule_flush(interval) do + Process.send_after(self(), :flush_buffer, interval) + end + + defp send_to_cloudwatch(log_stream, log_group, buffer) do + # Ensure that the already have ExAws authentication configured + with :ok <- check_exaws_config() do + operation = build_log_operation(log_stream, log_group, buffer) + + operation + |> send_with_retry() + end + end + + defp build_log_operation(log_stream, log_group, buffer) do + # The headers and body structure can be found in the AWS API documentation: + # https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_PutLogEvents.html + %ExAws.Operation.JSON{ + http_method: :post, + service: :logs, + headers: [ + {"x-amz-target", "Logs_20140328.PutLogEvents"}, + {"content-type", "application/x-amz-json-1.1"} + ], + data: %{ + "logGroupName" => log_group, + "logStreamName" => log_stream, + "logEvents" => Enum.reverse(buffer) + } + } + end + + defp check_exaws_config do + id = Application.get_env(:ex_aws, :access_key_id) || System.get_env("AWS_ACCESS_KEY_ID") + + secret = + Application.get_env(:ex_aws, :secret_access_key) || System.get_env("AWS_SECRET_ACCESS_KEY") + + region = Application.get_env(:ex_aws, :region) || System.get_env("AWS_REGION") + + cond do + is_nil(id) or id == "" or is_nil(secret) or secret == "" -> + Logger.error( + "#{@failed_message} AWS credentials missing. Ensure AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are set or configured in ex_aws." + ) + + :error + + region in [nil, ""] -> + Logger.error( + "#{@failed_message} AWS region not configured. Ensure AWS_REGION is set or configured in ex_aws." + ) + + :error + + true -> + :ok + end + end + + defp send_with_retry(operation, retries \\ @max_retries) + + defp send_with_retry(operation, retries) when retries > 0 do + case request(operation) do + {:ok, _response} -> + :ok + + {:error, reason} -> + Logger.error("#{@failed_message} #{inspect(reason)}. Retrying...") + # Wait before retrying + :timer.sleep(@retry_delay) + send_with_retry(operation, retries - 1) + end + end + + defp send_with_retry(_, 0) do + Logger.error("#{@failed_message} After multiple retries.") + end + + defp init(config, state) do + level = Keyword.get(config, :level) + format = Logger.Formatter.compile(Keyword.get(config, :format)) + raw_metadata = Keyword.get(config, :metadata, []) + metadata = configure_metadata(raw_metadata) + log_group = Keyword.get(config, :log_group, "cadet-logs") + log_stream = Keyword.get(config, :log_stream, "#{node()}-#{:os.system_time(:second)}") + timer_ref = schedule_flush(@flush_interval) + + %{ + state + | level: level, + format: format, + metadata: metadata, + log_group: log_group, + log_stream: log_stream, + buffer: [], + timer_ref: timer_ref + } + end + + defp configure_metadata(:all), do: :all + defp configure_metadata(metadata), do: Enum.reverse(metadata) + + defp take_metadata(metadata, :all) do + metadata + end + + defp take_metadata(metadata, keys) do + Enum.reduce(keys, [], fn key, acc -> + case Keyword.fetch(metadata, key) do + {:ok, val} -> [{key, val} | acc] + :error -> acc + end + end) + end + + defp timestamp_from_logger_ts({{year, month, day}, {hour, minute, second, microsecond}}) do + datetime = %DateTime{ + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + second: second, + microsecond: {microsecond, 6}, + time_zone: "Etc/UTC", + zone_abbr: "UTC", + utc_offset: 0, + std_offset: 0 + } + + DateTime.to_unix(datetime, :millisecond) + end + + defp read_env do + Application.get_env(:logger, __MODULE__, Application.get_env(:logger, :cloudwatch_logger, [])) + end + + """ + Merges the given options with the existing environment configuration. + If a key exists in both, the value from `options` will take precedence. + """ + + defp configure_merge(env, options) do + Keyword.merge(env, options, fn + _, _v1, v2 -> v2 + end) + end + + defp request(operation) do + client = Application.get_env(:ex_aws, :ex_aws_mock, ExAws) + client.request(operation) + end +end diff --git a/lib/cadet/notifications.ex b/lib/cadet/notifications.ex index cc65d529a..57aaedd85 100644 --- a/lib/cadet/notifications.ex +++ b/lib/cadet/notifications.ex @@ -276,15 +276,15 @@ defmodule Cadet.Notifications do |> Repo.insert() end - @doc """ - Returns the list of sent_notifications. + # @doc """ + # Returns the list of sent_notifications. - ## Examples + # ## Examples - iex> list_sent_notifications() - [%SentNotification{}, ...] + # iex> list_sent_notifications() + # [%SentNotification{}, ...] - """ + # """ # def list_sent_notifications do # Repo.all(SentNotification) diff --git a/lib/cadet_web/admin_controllers/admin_stories_controller.ex b/lib/cadet_web/admin_controllers/admin_stories_controller.ex index a6cdd46c0..dd077a983 100644 --- a/lib/cadet_web/admin_controllers/admin_stories_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_stories_controller.ex @@ -12,7 +12,7 @@ defmodule CadetWeb.AdminStoriesController do case result do {:ok, _story} -> - conn |> put_status(200) |> text('') + conn |> put_status(200) |> text("") {:error, {status, message}} -> conn @@ -29,7 +29,7 @@ defmodule CadetWeb.AdminStoriesController do case result do {:ok, _story} -> - conn |> put_status(200) |> text('') + conn |> put_status(200) |> text("") {:error, {status, message}} -> conn @@ -43,7 +43,7 @@ defmodule CadetWeb.AdminStoriesController do case result do {:ok, _nil} -> - conn |> put_status(204) |> text('') + conn |> put_status(204) |> text("") {:error, {status, message}} -> conn diff --git a/lib/cadet_web/admin_controllers/admin_teams_controller.ex b/lib/cadet_web/admin_controllers/admin_teams_controller.ex index c91974404..30349e772 100644 --- a/lib/cadet_web/admin_controllers/admin_teams_controller.ex +++ b/lib/cadet_web/admin_controllers/admin_teams_controller.ex @@ -5,11 +5,8 @@ defmodule CadetWeb.AdminTeamsController do alias Cadet.Accounts.{Teams, Team} - def index(conn, _params) do - teams = - Team - |> Repo.all() - |> Repo.preload(assessment: [:config], team_members: [student: [:user]]) + def index(conn, %{"course_id" => course_id}) do + teams = Teams.all_teams_for_course(course_id) team_formation_overviews = teams diff --git a/lib/cadet_web/controllers/answer_controller.ex b/lib/cadet_web/controllers/answer_controller.ex index 7e87ab22e..c4c99f03f 100644 --- a/lib/cadet_web/controllers/answer_controller.ex +++ b/lib/cadet_web/controllers/answer_controller.ex @@ -38,6 +38,10 @@ defmodule CadetWeb.AnswerController do end end + def submit(conn, _params) do + send_resp(conn, :bad_request, "Missing or invalid parameter(s)") + end + def check_last_modified(conn, %{ "questionid" => question_id, "lastModifiedAt" => last_modified_at @@ -79,10 +83,6 @@ defmodule CadetWeb.AnswerController do end end - def submit(conn, _params) do - send_resp(conn, :bad_request, "Missing or invalid parameter(s)") - end - swagger_path :submit do post("/assessments/question/{questionId}/answer") diff --git a/mix.exs b/mix.exs index e6921a7c8..6dc452f0e 100644 --- a/mix.exs +++ b/mix.exs @@ -109,6 +109,10 @@ defmodule Cadet.Mixfile do {:exvcr, "~> 0.10", only: :test}, {:mock, "~> 0.3.0", only: :test}, + # Dependencies for logger unit testing + {:mox, "~> 1.2", only: :test}, + {:logger_backends, "~> 1.0.0", only: :test}, + # The following are indirect dependencies, but we need to override the # versions due to conflicts {:jsx, "~> 3.1", override: true}, diff --git a/mix.lock b/mix.lock index b9b3f8d61..18e9f9655 100644 --- a/mix.lock +++ b/mix.lock @@ -20,7 +20,7 @@ "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, "crontab": {:hex, :crontab, "1.1.13", "3bad04f050b9f7f1c237809e42223999c150656a6b2afbbfef597d56df2144c5", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "d67441bec989640e3afb94e123f45a2bc42d76e02988c9613885dc3d01cf7085"}, "csv": {:hex, :csv, "3.2.2", "452f96414b39a176b7c390af6d8b78f15130dc6167fe3b836729131f515d843e", [:mix], [], "hexpm", "cbf256ff74a3fa01d9ec420d07b19c90d410ed9fe5b6d6e1bc7662edf35bc574"}, - "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, + "db_connection": {:hex, :db_connection, "2.8.0", "64fd82cfa6d8e25ec6660cea73e92a4cbc6a18b31343910427b702838c4b33b2", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "008399dae5eee1bf5caa6e86d204dcb44242c82b1ed5e22c881f2c34da201b15"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, "distillery": {:hex, :distillery, "2.1.1", "f9332afc2eec8a1a2b86f22429e068ef35f84a93ea1718265e740d90dd367814", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm", "bbc7008b0161a6f130d8d903b5b3232351fccc9c31a991f8fcbf2a12ace22995"}, @@ -60,6 +60,7 @@ "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jose": {:hex, :jose, "1.11.6", "613fda82552128aa6fb804682e3a616f4bc15565a048dabd05b1ebd5827ed965", [:mix, :rebar3], [], "hexpm", "6275cb75504f9c1e60eeacb771adfeee4905a9e182103aa59b53fed651ff9738"}, "jsx": {:hex, :jsx, "3.1.0", "d12516baa0bb23a59bb35dccaf02a1bd08243fcbb9efe24f2d9d056ccff71268", [:rebar3], [], "hexpm", "0c5cc8fdc11b53cc25cf65ac6705ad39e54ecc56d1c22e4adb8f5a53fb9427f3"}, + "logger_backends": {:hex, :logger_backends, "1.0.0", "09c4fad6202e08cb0fbd37f328282f16539aca380f512523ce9472b28edc6bdf", [:mix], [], "hexpm", "1faceb3e7ec3ef66a8f5746c5afd020e63996df6fd4eb8cdb789e5665ae6c9ce"}, "mail": {:hex, :mail, "0.2.3", "2c6bb5f8a5f74845fa50ecd0fb45ea16b164026f285f45104f1c4c078cd616d4", [:mix], [], "hexpm", "932b398fa9c69fdf290d7ff63175826e0f1e24414d5b0763bb00a2acfc6c6bf5"}, "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, "memento": {:hex, :memento, "0.3.2", "38cfc8ff9bcb1adff7cbd0f3b78a762636b86dff764729d1c82d0464c539bdd0", [:mix], [], "hexpm", "25cf691a98a0cb70262f4a7543c04bab24648cb2041d937eb64154a8d6f8012b"}, @@ -67,6 +68,7 @@ "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, "mock": {:hex, :mock, "0.3.9", "10e44ad1f5962480c5c9b9fa779c6c63de9bd31997c8e04a853ec990a9d841af", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "9e1b244c4ca2551bb17bb8415eed89e40ee1308e0fbaed0a4fdfe3ec8a4adbd3"}, + "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_ownership": {:hex, :nimble_ownership, "1.0.1", "f69fae0cdd451b1614364013544e66e4f5d25f36a2056a9698b793305c5aa3a6", [:mix], [], "hexpm", "3825e461025464f519f3f3e4a1f9b68c47dc151369611629ad08b636b73bb22d"}, "oban": {:hex, :oban, "2.18.0", "092d20bfd3d70c7ecb70960f8548d300b54bb9937c7f2e56b388f3a9ed02ec68", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aace1eff6f8227ae38d4274af967d96f051c2f0a5152f2ef9809dd1f97866745"}, @@ -75,7 +77,7 @@ "parallel_stream": {:hex, :parallel_stream, "1.1.0", "f52f73eb344bc22de335992377413138405796e0d0ad99d995d9977ac29f1ca9", [:mix], [], "hexpm", "684fd19191aedfaf387bbabbeb8ff3c752f0220c8112eb907d797f4592d6e871"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.4", "dcf3483ab45bab4c15e3a47c34451392f64e433846b08469f5d16c2a4cd70052", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "f5b8584c36ccc9b903948a696fc9b8b81102c79c7c0c751a9f00cdec55d5f2d7"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"}, "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"}, "phoenix_live_view": {:hex, :phoenix_live_view, "0.19.3", "3918c1b34df8ac71a9a636806ba5b7f053349a0392b312e16f35b0bf4d070aab", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "545626887948495fd8ea23d83b75bd7aaf9dc4221563e158d2c4b52ea1dd7e00"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, @@ -83,7 +85,7 @@ "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, "plug": {:hex, :plug, "1.18.0", "d78df36c41f7e798f2edf1f33e1727eae438e9dd5d809a9997c463a108244042", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "819f9e176d51e44dc38132e132fe0accaf6767eab7f0303431e404da8476cfa2"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.7.3", "1304d36752e8bdde213cea59ef424ca932910a91a07ef9f3874be709c4ddb94b", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "77c95524b2aa5364b247fa17089029e73b951ebc1adeef429361eab0bb55819d"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.7.4", "729c752d17cf364e2b8da5bdb34fb5804f56251e88bb602aff48ae0bd8673d11", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9b85632bd7012615bae0a5d70084deb1b25d2bcbb32cab82d1e9a1e023168aa3"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, "quantum": {:hex, :quantum, "3.5.3", "ee38838a07761663468145f489ad93e16a79440bebd7c0f90dc1ec9850776d99", [:mix], [{:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.14 or ~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.2", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "500fd3fa77dcd723ed9f766d4a175b684919ff7b6b8cfd9d7d0564d58eba8734"}, @@ -91,7 +93,7 @@ "ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"}, "recase": {:hex, :recase, "0.8.1", "ab98cd35857a86fa5ca99036f575241d71d77d9c2ab0c39aacf1c9b61f6f7d1d", [:mix], [], "hexpm", "9fd8d63e7e43bd9ea385b12364e305778b2bbd92537e95c4b2e26fc507d5e4c2"}, "samly": {:hex, :samly, "1.4.0", "58397eb6ca96bf768655723d378e6468f95f752b547aac1504f126bbd7c9fde8", [:mix], [{:esaml, "~> 4.3", [hex: :esaml, repo: "hexpm", optional: false]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "9cc53e043cb4508c3df2f9a4e13bc101a6835aded5cd49ee1c931158f7ce1dd6"}, - "sentry": {:hex, :sentry, "11.0.0", "f67e45e838c34a915c4d8e9eebc2943cd3cb5907d988d29156e180656c6b2f48", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_ownership, "~> 0.3.0 or ~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:opentelemetry, "~> 1.5", [hex: :opentelemetry, repo: "hexpm", optional: true]}, {:opentelemetry_api, "~> 1.4", [hex: :opentelemetry_api, repo: "hexpm", optional: true]}, {:opentelemetry_exporter, "~> 1.0", [hex: :opentelemetry_exporter, repo: "hexpm", optional: true]}, {:opentelemetry_semantic_conventions, "~> 1.27", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "62eb9f5fa3c4d72ce5d9740430e2118f1d44b7dde0e17eafe0259072b2e17163"}, + "sentry": {:hex, :sentry, "11.0.1", "e4e0ae12a74c59639808442a79d7ddd224fd5987fb9a14b35a1d01d9606117a3", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_ownership, "~> 0.3.0 or ~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:opentelemetry, "~> 1.5", [hex: :opentelemetry, repo: "hexpm", optional: true]}, {:opentelemetry_api, "~> 1.4", [hex: :opentelemetry_api, repo: "hexpm", optional: true]}, {:opentelemetry_exporter, "~> 1.0", [hex: :opentelemetry_exporter, repo: "hexpm", optional: true]}, {:opentelemetry_semantic_conventions, "~> 1.27", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "ef12b6ee61c24bee3807eee92563567c89f5eec954d6bb913eb797e04625a613"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, diff --git a/test/cadet/assessments/assessments_test.exs b/test/cadet/assessments/assessments_test.exs index 6953144c3..41814efe3 100644 --- a/test/cadet/assessments/assessments_test.exs +++ b/test/cadet/assessments/assessments_test.exs @@ -2223,7 +2223,7 @@ defmodule Cadet.AssessmentsTest do test "limit submisssions 2", %{ course_regs: %{avenger1_cr: avenger}, - assessments: assessments + assessments: _assessments } do {_, res} = Assessments.submissions_by_grader_for_index(avenger, %{ @@ -3142,7 +3142,7 @@ defmodule Cadet.AssessmentsTest do defp expected_top_relative_scores(top_x, token_divider) do # "return 0;" in the factory has 3 token - 10..0 + 10..0//-1 |> Enum.to_list() |> Enum.map(fn score -> 10 * score - :math.pow(2, 3 / token_divider) end) |> Enum.take(top_x) diff --git a/test/cadet/logger/cloudwatch_logger_test.exs b/test/cadet/logger/cloudwatch_logger_test.exs new file mode 100644 index 000000000..58551e623 --- /dev/null +++ b/test/cadet/logger/cloudwatch_logger_test.exs @@ -0,0 +1,131 @@ +defmodule Cadet.Logger.CloudWatchLoggerTest do + use ExUnit.Case, async: false + + require Logger + import Mox + alias Cadet.Logger.CloudWatchLogger + + setup :set_mox_from_context + setup :verify_on_exit! + + setup do + Mox.defmock(ExAwsMock, for: ExAws.Behaviour) + Application.put_env(:ex_aws, :ex_aws_mock, ExAwsMock) + + Application.put_env(:logger, :cloudwatch_logger, + level: :info, + log_group: "test_log_group", + log_stream: "test_log_stream", + format: "$time $metadata[$level] $message", + metadata: [:request_id] + ) + + LoggerBackends.add({CloudWatchLogger, :cloudwatch_logger}) + + Logger.configure_backend(:console, level: :error) + + on_exit(fn -> + LoggerBackends.remove({CloudWatchLogger, :cloudwatch_logger}) + Logger.configure_backend(:console, level: :warning) + end) + + :ok + end + + test "flushes buffered events via ExAws" do + expect(ExAwsMock, :request, fn %ExAws.Operation.JSON{} = op -> + %{data: data} = op + + assert_config(op) + + assert Enum.all?(data["logEvents"], fn event -> + is_map(event) and + Map.has_key?(event, "timestamp") and + Map.has_key?(event, "message") + end) + + assert Enum.all?( + data["logEvents"], + fn event -> + is_map(event) and + Map.has_key?(event, "message") and + (String.contains?(event["message"], "[error] this is an error") or + String.contains?(event["message"], "[warning] this is a warning") or + String.contains?(event["message"], "[warn] this is a warning")) + end + ) + + {:ok, + %{ + status_code: 200 + }} + end) + + Logger.error("this is an error") + Logger.warning("this is a warning") + + # wait for timer to flush the buffer + Process.sleep(5100) + end + + test "Force flush the buffer when the buffer size is reached" do + expect(ExAwsMock, :request, fn %ExAws.Operation.JSON{} = op -> + %{data: data} = op + + assert_config(op) + + assert Enum.all?(data["logEvents"], fn event -> + is_map(event) and + Map.has_key?(event, "timestamp") and + Map.has_key?(event, "message") + end) + + assert Enum.all?( + data["logEvents"], + fn event -> + is_map(event) and + Map.has_key?(event, "message") and + (String.contains?(event["message"], "[warning] this is a warning") or + String.contains?(event["message"], "[warn] this is a warning")) + end + ) + + {:ok, + %{ + status_code: 200 + }} + end) + + for _ <- 1..1000 do + Logger.warning("this is a warning") + end + + # don't wait for timer + Process.sleep(100) + end + + test "Failed to send log to CloudWatch" do + expect(ExAwsMock, :request, 3, fn %ExAws.Operation.JSON{} = op -> + assert_config(op) + + {:error, "Failed to send log to CloudWatch"} + end) + + Logger.warning("this is a warning") + + Process.sleep(6000) + end + + defp assert_config(%{http_method: http_method, data: data, headers: headers, service: service}) do + assert http_method == :post + assert service == :logs + + assert headers == [ + {"x-amz-target", "Logs_20140328.PutLogEvents"}, + {"content-type", "application/x-amz-json-1.1"} + ] + + assert data["logGroupName"] == "test_log_group" + assert data["logStreamName"] == "test_log_stream" + end +end diff --git a/test/cadet_web/admin_controllers/admin_assessments_controller_test.exs b/test/cadet_web/admin_controllers/admin_assessments_controller_test.exs index cfd590925..3f095dfcc 100644 --- a/test/cadet_web/admin_controllers/admin_assessments_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_assessments_controller_test.exs @@ -5,7 +5,7 @@ defmodule CadetWeb.AdminAssessmentsControllerTest do import Ecto.Query import ExUnit.CaptureLog - alias Cadet.{Assessments, Repo} + alias Cadet.Repo alias Cadet.Accounts.CourseRegistration alias Cadet.Assessments.{Assessment, Submission} alias Cadet.Test.XMLGenerator diff --git a/test/cadet_web/admin_controllers/admin_teams_controller_test.exs b/test/cadet_web/admin_controllers/admin_teams_controller_test.exs index 32d2a517e..55f68a8af 100644 --- a/test/cadet_web/admin_controllers/admin_teams_controller_test.exs +++ b/test/cadet_web/admin_controllers/admin_teams_controller_test.exs @@ -28,17 +28,39 @@ defmodule CadetWeb.AdminTeamsControllerTest do end @tag authenticate: :staff - test "returns a list of teams", %{conn: conn} do + test "returns a list of teams for the specified course only", %{conn: conn} do course_id = conn.assigns.course_id - team = insert(:team) - insert(:team_member, %{team: team}) - insert(:team_member, %{team: team}) + course = Repo.get(Course, course_id) + assessment = insert(:assessment, %{course: course, max_team_size: 2}) + team1 = insert(:team, %{assessment: assessment}) + insert(:team_member, %{team: team1}) + insert(:team_member, %{team: team1}) + team2 = insert(:team, %{assessment: assessment}) + insert(:team_member, %{team: team2}) + insert(:team_member, %{team: team2}) conn = get(conn, build_url(course_id)) assert response(conn, 200) - response_body = conn.resp_body |> Jason.decode!() - assert Enum.any?(response_body, fn team_map -> team_map["teamId"] == team.id end) + # Insert other random teams to test filtering + other_course = insert(:course) + other_assessment = insert(:assessment, %{course: other_course, max_team_size: 2}) + team3 = insert(:team, %{assessment: other_assessment}) + insert(:team_member, %{team: team3}) + insert(:team_member, %{team: team3}) + + response_body = + conn.resp_body + |> Jason.decode!() + # Sort the teams by teamId for consistent testing + |> Enum.sort_by(& &1["teamId"]) + + assert is_list(response_body) + assert length(response_body) == 2 + assert response_body |> hd() |> Map.get("teamId") == team1.id + assert response_body |> hd() |> Map.get("assessmentId") == assessment.id + assert response_body |> tl() |> hd() |> Map.get("teamId") == team2.id + assert response_body |> tl() |> hd() |> Map.get("assessmentId") == assessment.id end end diff --git a/test/cadet_web/plug/rate_limiter_test.exs b/test/cadet_web/plug/rate_limiter_test.exs index 0820295b2..d5337f711 100644 --- a/test/cadet_web/plug/rate_limiter_test.exs +++ b/test/cadet_web/plug/rate_limiter_test.exs @@ -22,8 +22,6 @@ defmodule CadetWeb.Plugs.RateLimiterTest do end test "rate limit exceeded", %{conn: conn} do - key = "user:1" - # Simulate exceeding the rate limit for _ <- 1..RateLimiter.rate_limit() do conn = RateLimiter.call(conn, %{})