test/openagents/voice/recordings_test.exs

58e6347eeb72 · 14 KB

defmodule OpenAgents.Voice.RecordingsTest do
  @moduledoc """
  Call audio is the first raw media Sarah stores, and it arrives from an
  untrusted client on an endpoint that accepts bytes. These tests cover the four
  things that keeps honest: ordering, fencing, ceilings, and sealing.
  """

  use OpenAgents.DataCase, async: false

  alias OpenAgents.Conversations
  alias OpenAgents.Repo
  alias OpenAgents.Voice
  alias OpenAgents.Voice.{Config, Recording, RecordingChunk, RecordingVault, Recordings}

  @webm "audio/webm;codecs=opus"

  describe "append_chunk/5" do
    test "commits slices in order, sealed, and idempotently on a retry" do
      session = admitted_session("recording-order")

      assert {:ok, first} = Recordings.append_chunk(session, session.generation, 1, "aaa", @webm)
      assert first.status == "recording"
      assert first.container == "webm"
      assert first.codec == "opus"
      assert first.channel_layout == "mic_left_sarah_right"
      assert first.sealed
      assert first.chunk_count == 1
      assert first.byte_size == 3

      assert {:ok, second} =
               Recordings.append_chunk(session, session.generation, 2, "bbbb", @webm)

      assert second.chunk_count == 2
      assert second.byte_size == 7

      # A dropped response leaves the browser unable to know whether the write
      # landed, so a repeat of a stored sequence is a no-op rather than a
      # duplicate slice or a refusal.
      assert {:ok, retried} =
               Recordings.append_chunk(session, session.generation, 2, "bbbb", @webm)

      assert retried.chunk_count == 2
      assert retried.byte_size == 7

      # Ciphertext at rest, and the plaintext only through the sanctioned reader.
      stored = Repo.all(from(chunk in RecordingChunk, order_by: chunk.sequence))
      assert Enum.map(stored, & &1.sequence) == [1, 2]
      refute Enum.any?(stored, &(&1.data in ["aaa", "bbbb"]))

      assert {:ok, "aaabbbb"} = Recordings.read(retried)
    end

    test "refuses a gap, because a missing slice makes the rest unplayable" do
      session = admitted_session("recording-gap")

      assert {:error, :voice_recording_sequence_gap} =
               Recordings.append_chunk(session, session.generation, 2, "aaa", @webm)

      assert {:ok, _first} = Recordings.append_chunk(session, session.generation, 1, "aaa", @webm)

      assert {:error, :voice_recording_sequence_gap} =
               Recordings.append_chunk(session, session.generation, 4, "bbb", @webm)
    end

    test "refuses a stale generation the way every other voice write does" do
      session = admitted_session("recording-generation")

      assert {:error, :stale_voice_generation} =
               Recordings.append_chunk(session, session.generation + 1, 1, "aaa", @webm)

      assert Repo.aggregate(Recording, :count) == 0
    end

    test "truncates at the chunk ceiling instead of growing without limit" do
      session = admitted_session("recording-chunk-ceiling")
      maximum = Recordings.config().maximum_chunks

      for sequence <- 1..maximum do
        assert {:ok, _recording} =
                 Recordings.append_chunk(session, session.generation, sequence, "aa", @webm)
      end

      assert {:error, :voice_recording_limit_reached} =
               Recordings.append_chunk(session, session.generation, maximum + 1, "aa", @webm)

      recording = Recordings.for_session(session)
      assert recording.status == "truncated"
      assert recording.chunk_count == maximum
      assert recording.completed_at

      # Truncated audio is still audio: the operator gets what arrived.
      assert recording.status in Recording.playable_statuses()
    end

    test "truncates at the byte ceiling and refuses an oversized single slice" do
      session = admitted_session("recording-byte-ceiling")
      settings = Recordings.config()

      assert {:error, :voice_recording_chunk_too_large} =
               Recordings.append_chunk(
                 session,
                 session.generation,
                 1,
                 :binary.copy("a", settings.maximum_chunk_bytes + 1),
                 @webm
               )

      slice = :binary.copy("a", settings.maximum_chunk_bytes)
      slices = div(settings.maximum_bytes, settings.maximum_chunk_bytes)

      for sequence <- 1..slices do
        assert {:ok, _recording} =
                 Recordings.append_chunk(session, session.generation, sequence, slice, @webm)
      end

      assert {:error, :voice_recording_limit_reached} =
               Recordings.append_chunk(session, session.generation, slices + 1, slice, @webm)

      assert Recordings.for_session(session).status == "truncated"
    end

    test "refuses a media type outside the stored container allowlist" do
      session = admitted_session("recording-media-type")

      assert {:error, :unsupported_recording_media_type} =
               Recordings.append_chunk(session, session.generation, 1, "aaa", "audio/wav")

      assert {:error, :unsupported_recording_media_type} =
               Recordings.append_chunk(session, session.generation, 1, "aaa", "text/html")
    end

    test "accepts the tail slice after the call ends, then closes the window" do
      session = admitted_session("recording-late-slice")
      {:ok, session} = Voice.end_session(session, session.generation, "user_ended")

      # Ending the call is what stops the recorder, so its last slice is always
      # late by definition.
      assert {:ok, recording} =
               Recordings.append_chunk(session, session.generation, 1, "tail", @webm)

      assert recording.chunk_count == 1

      grace = Recordings.config().late_chunk_grace_seconds

      stale =
        session
        |> Ecto.Changeset.change(%{
          ended_at: DateTime.add(DateTime.utc_now(), -grace - 5, :second)
        })
        |> Repo.update!()

      assert {:error, :voice_recording_window_closed} =
               Recordings.append_chunk(stale, stale.generation, 2, "later", @webm)
    end

    test "refuses to reopen a closed recording" do
      session = admitted_session("recording-reopen")
      assert {:ok, _first} = Recordings.append_chunk(session, session.generation, 1, "aaa", @webm)
      assert {:ok, _closed} = Recordings.finalize(session, session.generation, "complete", 1_000)

      assert {:error, :voice_recording_closed} =
               Recordings.append_chunk(session, session.generation, 2, "bbb", @webm)
    end

    test "records nothing when no key is configured, rather than storing audio in the clear" do
      session = admitted_session("recording-unsealed")

      without_recording_key(fn ->
        refute Recordings.config().enabled?

        assert {:error, :voice_recording_disabled} =
                 Recordings.append_chunk(session, session.generation, 1, "aaa", @webm)
      end)

      assert Repo.aggregate(Recording, :count) == 0
    end
  end

  describe "finalize/4" do
    test "closes with the browser's duration claim and a digest over the audio" do
      session = admitted_session("recording-finalize")
      {:ok, _first} = Recordings.append_chunk(session, session.generation, 1, "aaa", @webm)
      {:ok, _second} = Recordings.append_chunk(session, session.generation, 2, "bbb", @webm)

      assert {:ok, recording} =
               Recordings.finalize(session, session.generation, "complete", 7_500)

      assert recording.status == "complete"
      assert recording.client_duration_ms == 7_500
      assert recording.completed_at

      assert recording.content_digest ==
               :crypto.hash(:sha256, "aaabbb") |> Base.encode16(case: :lower)
    end

    test "a browser reporting failed capture closes the recording as failed" do
      session = admitted_session("recording-failed")
      {:ok, _first} = Recordings.append_chunk(session, session.generation, 1, "aaa", @webm)

      assert {:ok, recording} = Recordings.finalize(session, session.generation, "failed", nil)
      assert recording.status == "failed"
      refute recording.status in Recording.playable_statuses()
    end

    test "finalizing a call that uploaded nothing is not an error" do
      session = admitted_session("recording-nothing")

      assert {:error, :voice_recording_not_found} =
               Recordings.finalize(session, session.generation, "complete", 0)
    end
  end

  describe "lifecycle sweeps" do
    test "abort_stale/1 closes a recording the browser never finalized" do
      session = admitted_session("recording-abandoned")
      {:ok, _first} = Recordings.append_chunk(session, session.generation, 1, "aaa", @webm)
      {:ok, session} = Voice.end_session(session, session.generation, "client_disconnected")

      # Inside the grace window the upload may still be in flight.
      assert {:ok, 0} = Recordings.abort_stale()
      assert Recordings.for_session(session).status == "recording"

      grace = Recordings.config().late_chunk_grace_seconds

      session
      |> Ecto.Changeset.change(%{
        ended_at: DateTime.add(DateTime.utc_now(), -grace - 5, :second)
      })
      |> Repo.update!()

      assert {:ok, 1} = Recordings.abort_stale()

      aborted = Recordings.for_session(session)
      assert aborted.status == "aborted"
      # What arrived is still playable; the status stops claiming an upload is
      # still running.
      assert aborted.status in Recording.playable_statuses()
      assert {:ok, "aaa"} = Recordings.read(aborted)
    end

    test "purge_expired/1 deletes audio on its own shorter window" do
      session = admitted_session("recording-retention")
      {:ok, _first} = Recordings.append_chunk(session, session.generation, 1, "aaa", @webm)
      {:ok, session} = Voice.end_session(session, session.generation, "user_ended")

      assert {:ok, 0} = Recordings.purge_expired()

      retention_days = Recordings.config().retention_days

      session
      |> Ecto.Changeset.change(%{
        ended_at: DateTime.add(DateTime.utc_now(), -retention_days - 1, :day)
      })
      |> Repo.update!()

      assert {:ok, 1} = Recordings.purge_expired()
      refute Recordings.for_session(session)
      assert Repo.aggregate(RecordingChunk, :count) == 0

      # The call itself survives its audio: lifecycle evidence has a longer life.
      assert Voice.get_session!(session.id)
    end

    test "deleting the account's data removes its audio by cascade" do
      user = github_user("recording-cascade")
      {:ok, conversation} = Conversations.ensure_conversation(user)
      {:ok, session} = Voice.admit_session(conversation, enabled_config())
      {:ok, _first} = Recordings.append_chunk(session, session.generation, 1, "aaa", @webm)
      {:ok, _ended} = Voice.end_session(session, session.generation, "user_ended")

      owner = Conversations.get_conversation_owner!(conversation)

      assert {:ok, :deleted} =
               OpenAgents.DataRights.delete(
                 user,
                 owner,
                 Conversations.get_conversation_for_user(user)
               )

      assert Repo.aggregate(Recording, :count) == 0
      assert Repo.aggregate(RecordingChunk, :count) == 0
    end
  end

  describe "sealing" do
    test "a chunk cannot be opened under another recording or another sequence" do
      assert {:ok, sealed} = RecordingVault.seal("audio", Ecto.UUID.generate(), 1)

      recording_id = Ecto.UUID.generate()
      assert {:ok, bound} = RecordingVault.seal("audio", recording_id, 4)
      assert {:ok, "audio"} = RecordingVault.open(bound, recording_id, 4)

      # Moving a slice to another recording, or to another position in the same
      # one, fails to open rather than silently reordering the audio.
      assert {:error, :chunk_unsealable} =
               RecordingVault.open(bound, Ecto.UUID.generate(), 4)

      assert {:error, :chunk_unsealable} = RecordingVault.open(bound, recording_id, 5)
      assert {:error, :chunk_unsealable} = RecordingVault.open(sealed, recording_id, 4)
    end

    test "legacy Sarah version-1 chunks remain readable during migration" do
      recording_id = Ecto.UUID.generate()
      sequence = 3
      nonce = :crypto.strong_rand_bytes(12)

      {:ok, key} =
        Base.decode64(Application.fetch_env!(:openagents, :voice_recording_encryption_key))

      aad = "sarah.voice_recording_chunk.v1:#{recording_id}:#{sequence}"

      {ciphertext, tag} =
        :crypto.crypto_one_time_aead(:aes_256_gcm, key, nonce, "legacy audio", aad, true)

      sealed = <<1, nonce::binary, tag::binary, ciphertext::binary>>
      assert {:ok, "legacy audio"} = RecordingVault.open(sealed, recording_id, sequence)
    end

    test "a wrong key fails closed" do
      recording_id = Ecto.UUID.generate()
      assert {:ok, sealed} = RecordingVault.seal("audio", recording_id, 1)

      original = Application.get_env(:openagents, :voice_recording_encryption_key)

      try do
        Application.put_env(
          :openagents,
          :voice_recording_encryption_key,
          Base.encode64(String.duplicate("x", 32))
        )

        assert {:error, :chunk_unsealable} = RecordingVault.open(sealed, recording_id, 1)
      after
        Application.put_env(:openagents, :voice_recording_encryption_key, original)
      end
    end
  end

  defp admitted_session(key) do
    {:ok, conversation} = Conversations.ensure_conversation(key)
    {:ok, session} = Voice.admit_session(conversation, enabled_config())
    session
  end

  defp github_user(key) do
    digest = :crypto.hash(:sha256, key)
    github_id = digest |> binary_part(0, 7) |> :binary.decode_unsigned()

    {:ok, user} =
      OpenAgents.Accounts.upsert_github_user(%{
        github_id: github_id,
        github_login: "test-#{Base.encode16(digest, case: :lower) |> binary_part(0, 10)}",
        github_avatar_url: "https://avatars.githubusercontent.com/u/#{github_id}?v=4"
      })

    user
  end

  defp without_recording_key(function) do
    original = Application.get_env(:openagents, :voice_recording_encryption_key)

    try do
      Application.put_env(:openagents, :voice_recording_encryption_key, nil)
      function.()
    after
      Application.put_env(:openagents, :voice_recording_encryption_key, original)
    end
  end

  defp enabled_config do
    Config.build!(
      enabled: true,
      architecture: :openai_realtime,
      provider: "openai",
      model: "gpt-realtime-2.1",
      voice: "marin",
      reasoning_effort: "low",
      maximum_session_seconds: 3_000
    )
  end
end