From b8b3c5dce04cc11ebb76381d4f82138c9b2c55eb Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 19 Jan 2026 14:17:05 -0600 Subject: [PATCH] add parallel build --- Dockerfile | 23 +- k8s/Dockerfile | 19 +- test/towerops/api_tokens/api_token_test.exs | 154 ++++++ test/towerops/api_tokens_test.exs | 471 ++++++++++++++++ test/towerops/proto/agent_test.exs | 569 ++++++++++++++++++++ 5 files changed, 1214 insertions(+), 22 deletions(-) create mode 100644 test/towerops/api_tokens/api_token_test.exs create mode 100644 test/towerops/api_tokens_test.exs create mode 100644 test/towerops/proto/agent_test.exs diff --git a/Dockerfile b/Dockerfile index f0825cd3..194f8f57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,19 +21,20 @@ ARG BUILDER_IMAGE="docker.io/hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION ARG RUNNER_IMAGE="docker.io/debian:${DEBIAN_VERSION}" FROM ${BUILDER_IMAGE} AS builder +ENV MIX_OS_DEPS_COMPILE_PARTITION_COUNT=6 # install build dependencies RUN apt-get update \ - && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \ - && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential git \ - && rm -rf /var/lib/apt/lists/* + && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential git \ + && rm -rf /var/lib/apt/lists/* # prepare build dir WORKDIR /app # install hex + rebar RUN mix local.hex --force \ - && mix local.rebar --force + && mix local.rebar --force # set build ENV ENV MIX_ENV="prod" @@ -80,13 +81,13 @@ RUN mix release FROM ${RUNNER_IMAGE} AS final RUN apt-get update \ - && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \ - && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends libstdc++6 openssl libncurses6 locales ca-certificates snmp snmp-mibs-downloader \ - && rm -rf /var/lib/apt/lists/* + && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends libstdc++6 openssl libncurses6 locales ca-certificates \ + && rm -rf /var/lib/apt/lists/* # Set the locale RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen \ - && locale-gen + && locale-gen ENV LANG=en_US.UTF-8 ENV LANGUAGE=en_US:en @@ -101,10 +102,6 @@ ENV MIX_ENV="prod" # Only copy the final release from the build stage COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/towerops ./ -# Copy MIB files for SNMP MIB name resolution -# LibreNMS MIBs will be mounted or copied separately -RUN mkdir -p /app/mibs - USER nobody # If using an environment that doesn't automatically reap zombie processes, it is @@ -114,6 +111,6 @@ USER nobody # Health check for container orchestration HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ - CMD ["/app/bin/towerops", "rpc", "1 + 1"] + CMD ["/app/bin/towerops", "rpc", "1 + 1"] CMD ["/app/bin/server"] diff --git a/k8s/Dockerfile b/k8s/Dockerfile index c28436bb..194f8f57 100644 --- a/k8s/Dockerfile +++ b/k8s/Dockerfile @@ -21,19 +21,20 @@ ARG BUILDER_IMAGE="docker.io/hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION ARG RUNNER_IMAGE="docker.io/debian:${DEBIAN_VERSION}" FROM ${BUILDER_IMAGE} AS builder +ENV MIX_OS_DEPS_COMPILE_PARTITION_COUNT=6 # install build dependencies RUN apt-get update \ - && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \ - && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential git \ - && rm -rf /var/lib/apt/lists/* + && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential git \ + && rm -rf /var/lib/apt/lists/* # prepare build dir WORKDIR /app # install hex + rebar RUN mix local.hex --force \ - && mix local.rebar --force + && mix local.rebar --force # set build ENV ENV MIX_ENV="prod" @@ -80,13 +81,13 @@ RUN mix release FROM ${RUNNER_IMAGE} AS final RUN apt-get update \ - && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \ - && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends libstdc++6 openssl libncurses6 locales ca-certificates \ - && rm -rf /var/lib/apt/lists/* + && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends libstdc++6 openssl libncurses6 locales ca-certificates \ + && rm -rf /var/lib/apt/lists/* # Set the locale RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen \ - && locale-gen + && locale-gen ENV LANG=en_US.UTF-8 ENV LANGUAGE=en_US:en @@ -110,6 +111,6 @@ USER nobody # Health check for container orchestration HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ - CMD ["/app/bin/towerops", "rpc", "1 + 1"] + CMD ["/app/bin/towerops", "rpc", "1 + 1"] CMD ["/app/bin/server"] diff --git a/test/towerops/api_tokens/api_token_test.exs b/test/towerops/api_tokens/api_token_test.exs new file mode 100644 index 00000000..b209e626 --- /dev/null +++ b/test/towerops/api_tokens/api_token_test.exs @@ -0,0 +1,154 @@ +defmodule Towerops.ApiTokens.ApiTokenTest do + use Towerops.DataCase + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.ApiTokens.ApiToken + + describe "changeset/2" do + setup do + user = user_fixture() + organization = organization_fixture(user.id) + %{user: user, organization: organization} + end + + test "valid changeset with all required fields", %{ + user: user, + organization: organization + } do + attrs = %{ + organization_id: organization.id, + user_id: user.id, + name: "Test Token", + token_hash: "abc123" + } + + changeset = ApiToken.changeset(%ApiToken{}, attrs) + assert changeset.valid? + end + + test "valid changeset without user_id", %{organization: organization} do + attrs = %{ + organization_id: organization.id, + name: "Test Token", + token_hash: "abc123" + } + + changeset = ApiToken.changeset(%ApiToken{}, attrs) + assert changeset.valid? + end + + test "valid changeset with expires_at", %{organization: organization} do + expires_at = DateTime.add(DateTime.utc_now(), 30, :day) + + attrs = %{ + organization_id: organization.id, + name: "Test Token", + token_hash: "abc123", + expires_at: expires_at + } + + changeset = ApiToken.changeset(%ApiToken{}, attrs) + assert changeset.valid? + end + + test "valid changeset with last_used_at", %{organization: organization} do + last_used_at = DateTime.utc_now() + + attrs = %{ + organization_id: organization.id, + name: "Test Token", + token_hash: "abc123", + last_used_at: last_used_at + } + + changeset = ApiToken.changeset(%ApiToken{}, attrs) + assert changeset.valid? + end + + test "invalid changeset without organization_id", %{user: user} do + attrs = %{ + user_id: user.id, + name: "Test Token", + token_hash: "abc123" + } + + changeset = ApiToken.changeset(%ApiToken{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).organization_id + end + + test "invalid changeset without name", %{organization: organization} do + attrs = %{ + organization_id: organization.id, + token_hash: "abc123" + } + + changeset = ApiToken.changeset(%ApiToken{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).name + end + + test "invalid changeset without token_hash", %{organization: organization} do + attrs = %{ + organization_id: organization.id, + name: "Test Token" + } + + changeset = ApiToken.changeset(%ApiToken{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).token_hash + end + + test "invalid changeset with blank name", %{organization: organization} do + attrs = %{ + organization_id: organization.id, + name: "", + token_hash: "abc123" + } + + changeset = ApiToken.changeset(%ApiToken{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).name + end + + test "invalid changeset with name too short", %{organization: organization} do + attrs = %{ + organization_id: organization.id, + name: "", + token_hash: "abc123" + } + + changeset = ApiToken.changeset(%ApiToken{}, attrs) + refute changeset.valid? + end + + test "invalid changeset with name too long", %{organization: organization} do + long_name = String.duplicate("a", 256) + + attrs = %{ + organization_id: organization.id, + name: long_name, + token_hash: "abc123" + } + + changeset = ApiToken.changeset(%ApiToken{}, attrs) + refute changeset.valid? + assert "should be at most 255 character(s)" in errors_on(changeset).name + end + + test "changeset with maximum valid name length", %{organization: organization} do + max_name = String.duplicate("a", 255) + + attrs = %{ + organization_id: organization.id, + name: max_name, + token_hash: "abc123" + } + + changeset = ApiToken.changeset(%ApiToken{}, attrs) + assert changeset.valid? + end + end +end diff --git a/test/towerops/api_tokens_test.exs b/test/towerops/api_tokens_test.exs new file mode 100644 index 00000000..167aff25 --- /dev/null +++ b/test/towerops/api_tokens_test.exs @@ -0,0 +1,471 @@ +defmodule Towerops.ApiTokensTest do + use Towerops.DataCase + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.ApiTokens + alias Towerops.ApiTokens.ApiToken + + describe "create_api_token/1" do + setup do + user = user_fixture() + organization = organization_fixture(user.id) + %{user: user, organization: organization} + end + + test "creates an API token with valid attributes", %{ + user: user, + organization: organization + } do + attrs = %{ + organization_id: organization.id, + user_id: user.id, + name: "Test Token" + } + + assert {:ok, {api_token, raw_token}} = ApiTokens.create_api_token(attrs) + assert api_token.name == "Test Token" + assert api_token.organization_id == organization.id + assert api_token.user_id == user.id + assert is_binary(api_token.token_hash) + assert String.starts_with?(raw_token, "towerops_") + assert is_nil(api_token.last_used_at) + assert is_nil(api_token.expires_at) + end + + test "creates an API token without user_id", %{organization: organization} do + attrs = %{ + organization_id: organization.id, + name: "Test Token" + } + + assert {:ok, {api_token, raw_token}} = ApiTokens.create_api_token(attrs) + assert api_token.name == "Test Token" + assert api_token.organization_id == organization.id + assert is_nil(api_token.user_id) + assert is_binary(api_token.token_hash) + assert String.starts_with?(raw_token, "towerops_") + end + + test "creates an API token with expiration", %{ + user: user, + organization: organization + } do + expires_at = DateTime.add(DateTime.utc_now(), 30, :day) + + attrs = %{ + organization_id: organization.id, + user_id: user.id, + name: "Expiring Token", + expires_at: expires_at + } + + assert {:ok, {api_token, _raw_token}} = ApiTokens.create_api_token(attrs) + assert api_token.expires_at == DateTime.truncate(expires_at, :second) + end + + test "returns error with invalid attributes" do + assert {:error, %Ecto.Changeset{}} = ApiTokens.create_api_token(%{}) + end + + test "returns error with blank name", %{organization: organization} do + attrs = %{ + organization_id: organization.id, + name: "" + } + + assert {:error, changeset} = ApiTokens.create_api_token(attrs) + assert "can't be blank" in errors_on(changeset).name + end + + test "returns error without organization_id" do + attrs = %{ + name: "Test Token" + } + + assert {:error, changeset} = ApiTokens.create_api_token(attrs) + assert "can't be blank" in errors_on(changeset).organization_id + end + + test "generates unique tokens", %{user: user, organization: organization} do + attrs = %{ + organization_id: organization.id, + user_id: user.id, + name: "Test Token" + } + + {:ok, {_token1, raw_token1}} = ApiTokens.create_api_token(attrs) + {:ok, {_token2, raw_token2}} = ApiTokens.create_api_token(attrs) + + assert raw_token1 != raw_token2 + end + end + + describe "list_organization_api_tokens/1" do + setup do + user = user_fixture() + organization = organization_fixture(user.id) + %{user: user, organization: organization} + end + + test "returns all tokens for an organization", %{ + user: user, + organization: organization + } do + {:ok, {token1, _}} = + ApiTokens.create_api_token(%{ + organization_id: organization.id, + user_id: user.id, + name: "Token 1" + }) + + {:ok, {token2, _}} = + ApiTokens.create_api_token(%{ + organization_id: organization.id, + user_id: user.id, + name: "Token 2" + }) + + tokens = ApiTokens.list_organization_api_tokens(organization.id) + + assert length(tokens) == 2 + assert Enum.any?(tokens, fn t -> t.id == token1.id end) + assert Enum.any?(tokens, fn t -> t.id == token2.id end) + end + + test "returns tokens ordered by creation date", %{ + user: user, + organization: organization + } do + {:ok, {token1, _}} = + ApiTokens.create_api_token(%{ + organization_id: organization.id, + user_id: user.id, + name: "Token 1" + }) + + {:ok, {token2, _}} = + ApiTokens.create_api_token(%{ + organization_id: organization.id, + user_id: user.id, + name: "Token 2" + }) + + tokens = ApiTokens.list_organization_api_tokens(organization.id) + + assert length(tokens) == 2 + # Verify tokens are ordered by inserted_at (descending) + [first, second] = tokens + + assert DateTime.compare(first.inserted_at, second.inserted_at) in [:gt, :eq] + # Verify both tokens are present + token_ids = Enum.map(tokens, & &1.id) + assert token1.id in token_ids + assert token2.id in token_ids + end + + test "returns empty list for organization with no tokens", %{organization: organization} do + assert [] = ApiTokens.list_organization_api_tokens(organization.id) + end + + test "does not return tokens from other organizations", %{user: user, organization: org1} do + org2 = organization_fixture(user.id) + + {:ok, {_token1, _}} = + ApiTokens.create_api_token(%{ + organization_id: org1.id, + user_id: user.id, + name: "Org 1 Token" + }) + + {:ok, {_token2, _}} = + ApiTokens.create_api_token(%{ + organization_id: org2.id, + user_id: user.id, + name: "Org 2 Token" + }) + + tokens = ApiTokens.list_organization_api_tokens(org1.id) + + assert length(tokens) == 1 + assert hd(tokens).name == "Org 1 Token" + end + end + + describe "list_user_api_tokens/1" do + setup do + user = user_fixture() + organization = organization_fixture(user.id) + %{user: user, organization: organization} + end + + test "returns all tokens created by a user", %{user: user, organization: organization} do + {:ok, {token1, _}} = + ApiTokens.create_api_token(%{ + organization_id: organization.id, + user_id: user.id, + name: "Token 1" + }) + + {:ok, {token2, _}} = + ApiTokens.create_api_token(%{ + organization_id: organization.id, + user_id: user.id, + name: "Token 2" + }) + + tokens = ApiTokens.list_user_api_tokens(user.id) + + assert length(tokens) == 2 + assert Enum.any?(tokens, fn t -> t.id == token1.id end) + assert Enum.any?(tokens, fn t -> t.id == token2.id end) + end + + test "preloads organization", %{user: user, organization: organization} do + {:ok, {_token, _}} = + ApiTokens.create_api_token(%{ + organization_id: organization.id, + user_id: user.id, + name: "Token 1" + }) + + tokens = ApiTokens.list_user_api_tokens(user.id) + + assert [token] = tokens + assert %Ecto.Association.NotLoaded{} != token.organization + assert token.organization.id == organization.id + end + + test "returns tokens ordered by creation date", %{ + user: user, + organization: organization + } do + {:ok, {token1, _}} = + ApiTokens.create_api_token(%{ + organization_id: organization.id, + user_id: user.id, + name: "Token 1" + }) + + {:ok, {token2, _}} = + ApiTokens.create_api_token(%{ + organization_id: organization.id, + user_id: user.id, + name: "Token 2" + }) + + tokens = ApiTokens.list_user_api_tokens(user.id) + + assert length(tokens) == 2 + # Verify tokens are ordered by inserted_at (descending) + [first, second] = tokens + + assert DateTime.compare(first.inserted_at, second.inserted_at) in [:gt, :eq] + # Verify both tokens are present + token_ids = Enum.map(tokens, & &1.id) + assert token1.id in token_ids + assert token2.id in token_ids + end + + test "returns empty list for user with no tokens", %{user: user} do + assert [] = ApiTokens.list_user_api_tokens(user.id) + end + + test "does not return tokens created by other users", %{organization: organization} do + user1 = user_fixture() + user2 = user_fixture() + + {:ok, {_token1, _}} = + ApiTokens.create_api_token(%{ + organization_id: organization.id, + user_id: user1.id, + name: "User 1 Token" + }) + + {:ok, {_token2, _}} = + ApiTokens.create_api_token(%{ + organization_id: organization.id, + user_id: user2.id, + name: "User 2 Token" + }) + + tokens = ApiTokens.list_user_api_tokens(user1.id) + + assert length(tokens) == 1 + assert hd(tokens).name == "User 1 Token" + end + end + + describe "get_api_token!/1" do + setup do + user = user_fixture() + organization = organization_fixture(user.id) + + {:ok, {token, _}} = + ApiTokens.create_api_token(%{ + organization_id: organization.id, + user_id: user.id, + name: "Test Token" + }) + + %{token: token} + end + + test "returns the token with the given id", %{token: token} do + fetched_token = ApiTokens.get_api_token!(token.id) + assert fetched_token.id == token.id + assert fetched_token.name == token.name + end + + test "raises if the token does not exist" do + assert_raise Ecto.NoResultsError, fn -> + ApiTokens.get_api_token!(Ecto.UUID.generate()) + end + end + end + + describe "verify_token/1" do + setup do + user = user_fixture() + organization = organization_fixture(user.id) + + {:ok, {token, raw_token}} = + ApiTokens.create_api_token(%{ + organization_id: organization.id, + user_id: user.id, + name: "Test Token" + }) + + %{token: token, raw_token: raw_token, organization: organization, user: user} + end + + test "returns organization_id and user for valid token", %{ + raw_token: raw_token, + organization: organization, + user: user + } do + assert {:ok, org_id, returned_user} = ApiTokens.verify_token(raw_token) + assert org_id == organization.id + assert returned_user.id == user.id + end + + test "updates last_used_at timestamp", %{token: token, raw_token: raw_token} do + assert is_nil(token.last_used_at) + + {:ok, _org_id, _user} = ApiTokens.verify_token(raw_token) + + # Give the async Task time to complete + Process.sleep(50) + + updated_token = ApiTokens.get_api_token!(token.id) + assert updated_token.last_used_at + assert DateTime.diff(updated_token.last_used_at, DateTime.utc_now(), :second) <= 1 + end + + test "returns error for invalid token" do + assert {:error, :invalid_token} = ApiTokens.verify_token("invalid_token") + end + + test "returns error for expired token", %{ + user: user, + organization: organization + } do + expires_at = DateTime.add(DateTime.utc_now(), -1, :day) + + {:ok, {_token, raw_token}} = + ApiTokens.create_api_token(%{ + organization_id: organization.id, + user_id: user.id, + name: "Expired Token", + expires_at: expires_at + }) + + assert {:error, :invalid_token} = ApiTokens.verify_token(raw_token) + end + + test "works for token without expiration", %{ + raw_token: raw_token, + organization: organization + } do + assert {:ok, org_id, _user} = ApiTokens.verify_token(raw_token) + assert org_id == organization.id + end + + test "works for token without user_id", %{organization: organization} do + {:ok, {_token, raw_token}} = + ApiTokens.create_api_token(%{ + organization_id: organization.id, + name: "No User Token" + }) + + assert {:ok, org_id, nil} = ApiTokens.verify_token(raw_token) + assert org_id == organization.id + end + end + + describe "delete_api_token/1" do + setup do + user = user_fixture() + organization = organization_fixture(user.id) + + {:ok, {token, _}} = + ApiTokens.create_api_token(%{ + organization_id: organization.id, + user_id: user.id, + name: "Test Token" + }) + + %{token: token} + end + + test "deletes the token", %{token: token} do + assert {:ok, %ApiToken{}} = ApiTokens.delete_api_token(token) + + assert_raise Ecto.NoResultsError, fn -> + ApiTokens.get_api_token!(token.id) + end + end + end + + describe "update_api_token/2" do + setup do + user = user_fixture() + organization = organization_fixture(user.id) + + {:ok, {token, _}} = + ApiTokens.create_api_token(%{ + organization_id: organization.id, + user_id: user.id, + name: "Test Token" + }) + + %{token: token} + end + + test "updates the token name", %{token: token} do + assert {:ok, updated_token} = ApiTokens.update_api_token(token, %{name: "New Name"}) + assert updated_token.name == "New Name" + end + + test "updates the expiration", %{token: token} do + expires_at = DateTime.add(DateTime.utc_now(), 30, :day) + + assert {:ok, updated_token} = + ApiTokens.update_api_token(token, %{expires_at: expires_at}) + + assert updated_token.expires_at == DateTime.truncate(expires_at, :second) + end + + test "returns error with invalid name", %{token: token} do + assert {:error, changeset} = ApiTokens.update_api_token(token, %{name: ""}) + assert "can't be blank" in errors_on(changeset).name + end + + test "returns error with too long name", %{token: token} do + long_name = String.duplicate("a", 256) + assert {:error, changeset} = ApiTokens.update_api_token(token, %{name: long_name}) + assert "should be at most 255 character(s)" in errors_on(changeset).name + end + end +end diff --git a/test/towerops/proto/agent_test.exs b/test/towerops/proto/agent_test.exs new file mode 100644 index 00000000..dbe12371 --- /dev/null +++ b/test/towerops/proto/agent_test.exs @@ -0,0 +1,569 @@ +defmodule Towerops.Agent.ProtoTest do + @moduledoc """ + Tests for Protocol Buffer generated modules. + + These tests verify that encoding and decoding works correctly for all + protobuf message types used in agent communication. + """ + use ExUnit.Case, async: true + + alias Towerops.Agent.AgentError + alias Towerops.Agent.AgentHeartbeat + alias Towerops.Agent.AgentJob + alias Towerops.Agent.AgentJobList + alias Towerops.Agent.MonitoringCheck + alias Towerops.Agent.NeighborDiscovery + alias Towerops.Agent.Sensor + alias Towerops.Agent.SnmpDevice + alias Towerops.Agent.SnmpQuery + alias Towerops.Agent.SnmpResult + + describe "AgentError" do + test "encodes and decodes correctly" do + error = %AgentError{ + device_id: "device-123", + job_id: "job-456", + message: "SNMP timeout", + timestamp: 1_234_567_890 + } + + encoded = AgentError.encode(error) + decoded = AgentError.decode(encoded) + + assert decoded.device_id == error.device_id + assert decoded.job_id == error.job_id + assert decoded.message == error.message + assert decoded.timestamp == error.timestamp + end + + test "encodes empty message" do + error = %AgentError{} + encoded = AgentError.encode(error) + decoded = AgentError.decode(encoded) + + assert decoded.device_id == "" + assert decoded.job_id == "" + assert decoded.message == "" + assert decoded.timestamp == 0 + end + end + + describe "AgentHeartbeat" do + test "encodes and decodes correctly" do + heartbeat = %AgentHeartbeat{ + version: "1.0.0", + hostname: "agent-host", + uptime_seconds: 3600, + ip_address: "192.168.1.100" + } + + encoded = AgentHeartbeat.encode(heartbeat) + decoded = AgentHeartbeat.decode(encoded) + + assert decoded.version == heartbeat.version + assert decoded.hostname == heartbeat.hostname + assert decoded.uptime_seconds == heartbeat.uptime_seconds + assert decoded.ip_address == heartbeat.ip_address + end + + test "encodes empty message" do + heartbeat = %AgentHeartbeat{} + encoded = AgentHeartbeat.encode(heartbeat) + decoded = AgentHeartbeat.decode(encoded) + + assert decoded.version == "" + assert decoded.hostname == "" + assert decoded.uptime_seconds == 0 + assert decoded.ip_address == "" + end + end + + describe "JobType enum" do + test "encodes DISCOVER constant" do + # Enums are represented as atoms in structs but integers in the wire format + job = %AgentJob{job_type: :DISCOVER} + encoded = AgentJob.encode(job) + decoded = AgentJob.decode(encoded) + assert decoded.job_type == :DISCOVER + end + + test "encodes POLL constant" do + job = %AgentJob{job_type: :POLL} + encoded = AgentJob.encode(job) + decoded = AgentJob.decode(encoded) + assert decoded.job_type == :POLL + end + end + + describe "QueryType enum" do + test "encodes GET constant" do + query = %SnmpQuery{query_type: :GET} + encoded = SnmpQuery.encode(query) + decoded = SnmpQuery.decode(encoded) + assert decoded.query_type == :GET + end + + test "encodes WALK constant" do + query = %SnmpQuery{query_type: :WALK} + encoded = SnmpQuery.encode(query) + decoded = SnmpQuery.decode(encoded) + assert decoded.query_type == :WALK + end + end + + describe "SnmpDevice" do + test "encodes and decodes correctly" do + device = %SnmpDevice{ + ip: "192.168.1.1", + community: "public", + version: "2c", + port: 161, + monitoring_enabled: true, + check_interval_seconds: 60 + } + + encoded = SnmpDevice.encode(device) + decoded = SnmpDevice.decode(encoded) + + assert decoded.ip == device.ip + assert decoded.community == device.community + assert decoded.version == device.version + assert decoded.port == device.port + assert decoded.monitoring_enabled == device.monitoring_enabled + assert decoded.check_interval_seconds == device.check_interval_seconds + end + + test "encodes empty message" do + device = %SnmpDevice{} + encoded = SnmpDevice.encode(device) + decoded = SnmpDevice.decode(encoded) + + assert decoded.ip == "" + assert decoded.community == "" + assert decoded.version == "" + assert decoded.port == 0 + assert decoded.monitoring_enabled == false + assert decoded.check_interval_seconds == 0 + end + end + + describe "SnmpQuery" do + test "encodes and decodes GET query" do + query = %SnmpQuery{ + query_type: :GET, + oids: ["1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.1.2.0"] + } + + encoded = SnmpQuery.encode(query) + decoded = SnmpQuery.decode(encoded) + + assert decoded.query_type == :GET + assert decoded.oids == query.oids + end + + test "encodes and decodes WALK query" do + query = %SnmpQuery{ + query_type: :WALK, + oids: ["1.3.6.1.2.1.2.2.1"] + } + + encoded = SnmpQuery.encode(query) + decoded = SnmpQuery.decode(encoded) + + assert decoded.query_type == :WALK + assert decoded.oids == query.oids + end + + test "encodes empty message" do + query = %SnmpQuery{} + encoded = SnmpQuery.encode(query) + decoded = SnmpQuery.decode(encoded) + + assert decoded.query_type == :GET + assert decoded.oids == [] + end + end + + describe "AgentJob" do + test "encodes and decodes DISCOVER job" do + job = %AgentJob{ + job_id: "discover:device-123", + job_type: :DISCOVER, + device_id: "device-123", + snmp_device: %SnmpDevice{ + ip: "192.168.1.1", + community: "public", + version: "2c", + port: 161, + monitoring_enabled: true, + check_interval_seconds: 60 + }, + queries: [ + %SnmpQuery{ + query_type: :GET, + oids: ["1.3.6.1.2.1.1.1.0"] + } + ] + } + + encoded = AgentJob.encode(job) + decoded = AgentJob.decode(encoded) + + assert decoded.job_id == job.job_id + assert decoded.job_type == :DISCOVER + assert decoded.device_id == job.device_id + assert decoded.snmp_device.ip == job.snmp_device.ip + assert decoded.snmp_device.community == job.snmp_device.community + assert decoded.snmp_device.version == job.snmp_device.version + assert decoded.snmp_device.port == job.snmp_device.port + assert decoded.snmp_device.monitoring_enabled == job.snmp_device.monitoring_enabled + + assert decoded.snmp_device.check_interval_seconds == + job.snmp_device.check_interval_seconds + + assert length(decoded.queries) == 1 + assert hd(decoded.queries).query_type == :GET + assert hd(decoded.queries).oids == ["1.3.6.1.2.1.1.1.0"] + end + + test "encodes and decodes POLL job" do + job = %AgentJob{ + job_id: "poll:device-123", + job_type: :POLL, + device_id: "device-123", + snmp_device: %SnmpDevice{ + ip: "192.168.1.1", + community: "public", + version: "2c", + port: 161 + }, + queries: [ + %SnmpQuery{ + query_type: :GET, + oids: ["1.3.6.1.4.1.14988.1.1.3.8.0"] + } + ] + } + + encoded = AgentJob.encode(job) + decoded = AgentJob.decode(encoded) + + assert decoded.job_id == job.job_id + assert decoded.job_type == :POLL + assert decoded.device_id == job.device_id + assert decoded.snmp_device.ip == job.snmp_device.ip + assert length(decoded.queries) == 1 + end + + test "encodes empty message" do + job = %AgentJob{} + encoded = AgentJob.encode(job) + decoded = AgentJob.decode(encoded) + + assert decoded.job_id == "" + assert decoded.job_type == :DISCOVER + assert decoded.device_id == "" + assert decoded.snmp_device == nil + assert decoded.queries == [] + end + end + + describe "AgentJobList" do + test "encodes and decodes list of jobs" do + job_list = %AgentJobList{ + jobs: [ + %AgentJob{ + job_id: "poll:device-1", + job_type: :POLL, + device_id: "device-1", + snmp_device: %SnmpDevice{ip: "192.168.1.1"}, + queries: [] + }, + %AgentJob{ + job_id: "discover:device-2", + job_type: :DISCOVER, + device_id: "device-2", + snmp_device: %SnmpDevice{ip: "192.168.1.2"}, + queries: [] + } + ] + } + + encoded = AgentJobList.encode(job_list) + decoded = AgentJobList.decode(encoded) + + assert length(decoded.jobs) == 2 + assert Enum.at(decoded.jobs, 0).job_id == "poll:device-1" + assert Enum.at(decoded.jobs, 0).job_type == :POLL + assert Enum.at(decoded.jobs, 1).job_id == "discover:device-2" + assert Enum.at(decoded.jobs, 1).job_type == :DISCOVER + end + + test "encodes empty job list" do + job_list = %AgentJobList{jobs: []} + encoded = AgentJobList.encode(job_list) + decoded = AgentJobList.decode(encoded) + + assert decoded.jobs == [] + end + end + + describe "SnmpResult" do + test "encodes and decodes with oid_values map" do + result = %SnmpResult{ + device_id: "device-123", + job_type: :POLL, + oid_values: %{ + "1.3.6.1.2.1.1.1.0" => "Cisco IOS", + "1.3.6.1.2.1.1.3.0" => "12345" + }, + timestamp: 1_234_567_890 + } + + encoded = SnmpResult.encode(result) + decoded = SnmpResult.decode(encoded) + + assert decoded.device_id == result.device_id + assert decoded.job_type == :POLL + assert decoded.oid_values == result.oid_values + assert decoded.timestamp == result.timestamp + end + + test "encodes and decodes DISCOVER result" do + result = %SnmpResult{ + device_id: "device-123", + job_type: :DISCOVER, + oid_values: %{}, + timestamp: 1_234_567_890 + } + + encoded = SnmpResult.encode(result) + decoded = SnmpResult.decode(encoded) + + assert decoded.device_id == result.device_id + assert decoded.job_type == :DISCOVER + assert decoded.timestamp == result.timestamp + end + + test "encodes empty message" do + result = %SnmpResult{} + encoded = SnmpResult.encode(result) + decoded = SnmpResult.decode(encoded) + + assert decoded.device_id == "" + assert decoded.job_type == :DISCOVER + assert decoded.oid_values == %{} + assert decoded.timestamp == 0 + end + end + + describe "SnmpResult.OidValuesEntry" do + test "encodes and decodes map entry" do + entry = %SnmpResult.OidValuesEntry{ + key: "1.3.6.1.2.1.1.1.0", + value: "System Description" + } + + encoded = SnmpResult.OidValuesEntry.encode(entry) + decoded = SnmpResult.OidValuesEntry.decode(encoded) + + assert decoded.key == entry.key + assert decoded.value == entry.value + end + end + + describe "MonitoringCheck" do + test "encodes and decodes success check" do + check = %MonitoringCheck{ + device_id: "device-123", + status: "success", + response_time_ms: 45.2, + timestamp: 1_234_567_890 + } + + encoded = MonitoringCheck.encode(check) + decoded = MonitoringCheck.decode(encoded) + + assert decoded.device_id == check.device_id + assert decoded.status == check.status + assert decoded.response_time_ms == check.response_time_ms + assert decoded.timestamp == check.timestamp + end + + test "encodes and decodes failure check" do + check = %MonitoringCheck{ + device_id: "device-123", + status: "failure", + response_time_ms: 0.0, + timestamp: 1_234_567_890 + } + + encoded = MonitoringCheck.encode(check) + decoded = MonitoringCheck.decode(encoded) + + assert decoded.device_id == check.device_id + assert decoded.status == check.status + assert decoded.response_time_ms == check.response_time_ms + assert decoded.timestamp == check.timestamp + end + + test "encodes empty message" do + check = %MonitoringCheck{} + encoded = MonitoringCheck.encode(check) + decoded = MonitoringCheck.decode(encoded) + + assert decoded.device_id == "" + assert decoded.status == "" + assert decoded.response_time_ms == 0.0 + assert decoded.timestamp == 0 + end + end + + describe "NeighborDiscovery" do + test "encodes and decodes LLDP neighbor" do + neighbor = %NeighborDiscovery{ + interface_id: "interface-123", + protocol: "LLDP", + remote_chassis_id: "00:11:22:33:44:55", + remote_system_name: "neighbor-switch", + remote_system_description: "Cisco IOS", + remote_platform: "cisco", + remote_port_id: "GigabitEthernet1/0/1", + remote_port_description: "Uplink to Core", + remote_address: "192.168.1.2", + remote_capabilities: ["bridge", "router"], + timestamp: 1_234_567_890 + } + + encoded = NeighborDiscovery.encode(neighbor) + decoded = NeighborDiscovery.decode(encoded) + + assert decoded.interface_id == neighbor.interface_id + assert decoded.protocol == neighbor.protocol + assert decoded.remote_chassis_id == neighbor.remote_chassis_id + assert decoded.remote_system_name == neighbor.remote_system_name + assert decoded.remote_system_description == neighbor.remote_system_description + assert decoded.remote_platform == neighbor.remote_platform + assert decoded.remote_port_id == neighbor.remote_port_id + assert decoded.remote_port_description == neighbor.remote_port_description + assert decoded.remote_address == neighbor.remote_address + assert decoded.remote_capabilities == neighbor.remote_capabilities + assert decoded.timestamp == neighbor.timestamp + end + + test "encodes and decodes CDP neighbor" do + neighbor = %NeighborDiscovery{ + interface_id: "interface-456", + protocol: "CDP", + remote_chassis_id: "Router-2", + remote_system_name: "core-router", + remote_platform: "Cisco 2960", + remote_port_id: "FastEthernet0/1", + timestamp: 1_234_567_890 + } + + encoded = NeighborDiscovery.encode(neighbor) + decoded = NeighborDiscovery.decode(encoded) + + assert decoded.interface_id == neighbor.interface_id + assert decoded.protocol == neighbor.protocol + assert decoded.remote_chassis_id == neighbor.remote_chassis_id + assert decoded.timestamp == neighbor.timestamp + end + + test "encodes empty message" do + neighbor = %NeighborDiscovery{} + encoded = NeighborDiscovery.encode(neighbor) + decoded = NeighborDiscovery.decode(encoded) + + assert decoded.interface_id == "" + assert decoded.protocol == "" + assert decoded.remote_chassis_id == "" + assert decoded.remote_system_name == "" + assert decoded.remote_capabilities == [] + assert decoded.timestamp == 0 + end + end + + describe "Sensor.MetadataEntry" do + test "encodes and decodes map entry" do + entry = %Sensor.MetadataEntry{ + key: "location", + value: "CPU Core 1" + } + + encoded = Sensor.MetadataEntry.encode(entry) + decoded = Sensor.MetadataEntry.decode(encoded) + + assert decoded.key == entry.key + assert decoded.value == entry.value + end + + test "encodes empty entry" do + entry = %Sensor.MetadataEntry{} + encoded = Sensor.MetadataEntry.encode(entry) + decoded = Sensor.MetadataEntry.decode(encoded) + + assert decoded.key == "" + assert decoded.value == "" + end + end + + describe "Sensor (protobuf)" do + test "encodes and decodes sensor with metadata" do + sensor = %Sensor{ + id: "sensor-123", + type: "temperature", + oid: "1.3.6.1.4.1.14988.1.1.3.10.0", + divisor: 10.0, + unit: "celsius", + metadata: %{ + "location" => "CPU Core 1", + "threshold" => "80" + } + } + + encoded = Sensor.encode(sensor) + decoded = Sensor.decode(encoded) + + assert decoded.id == sensor.id + assert decoded.type == sensor.type + assert decoded.oid == sensor.oid + assert decoded.divisor == sensor.divisor + assert decoded.unit == sensor.unit + assert decoded.metadata == sensor.metadata + end + + test "encodes and decodes sensor without metadata" do + sensor = %Sensor{ + id: "sensor-456", + type: "voltage", + oid: "1.3.6.1.4.1.14988.1.1.3.8.0", + divisor: 10.0, + unit: "volts" + } + + encoded = Sensor.encode(sensor) + decoded = Sensor.decode(encoded) + + assert decoded.id == sensor.id + assert decoded.type == sensor.type + assert decoded.oid == sensor.oid + assert decoded.divisor == sensor.divisor + assert decoded.unit == sensor.unit + end + + test "encodes empty message" do + sensor = %Sensor{} + encoded = Sensor.encode(sensor) + decoded = Sensor.decode(encoded) + + assert decoded.id == "" + assert decoded.type == "" + assert decoded.oid == "" + assert decoded.divisor == 0.0 + assert decoded.unit == "" + assert decoded.metadata == %{} + end + end +end