7.9 KiB · text History dc2edab
defmodule DiogramosWeb.Api.V1.CanvasApiTest do
  use DiogramosWeb.ConnCase, async: true

  import Diogramos.AccountsFixtures
  import Diogramos.DiagramsFixtures

  # Reads and ops go through the per-canvas Authority GenServer, which is
  # a separate process and so needs its own sandbox allowance.
  import Diogramos.DataCase, only: [allow_authority: 1]

  alias Diogramos.Accounts
  alias Diogramos.Accounts.Scope
  alias Diogramos.Diagrams

  setup %{conn: conn} do
    user = user_fixture()
    scope = Scope.for_user(user)
    token = Accounts.create_api_token(user)

    conn =
      conn
      |> put_req_header("accept", "application/json")
      |> put_req_header("authorization", "Bearer #{token}")

    %{conn: conn, user: user, scope: scope, token: token}
  end

  describe "authentication" do
    test "rejects a request with no token", %{conn: conn} do
      conn = conn |> delete_req_header("authorization") |> get(~p"/api/v1/users/me")
      assert %{"error" => %{"code" => "unauthenticated"}} = json_response(conn, 401)
    end

    test "rejects a malformed header and a bogus token", %{conn: conn} do
      for header <- ["Bearer", "Basic abc", "Bearer nope"] do
        conn =
          conn
          |> recycle()
          |> put_req_header("accept", "application/json")
          |> put_req_header("authorization", header)
          |> get(~p"/api/v1/users/me")

        assert json_response(conn, 401)
      end
    end

    test "a revoked token stops working", %{conn: conn, user: user, token: token} do
      {_user, token_id} = Accounts.fetch_user_by_api_token(token)
      :ok = Accounts.delete_api_token(user, token_id)

      assert json_response(get(conn, ~p"/api/v1/users/me"), 401)
    end

    test "GET /users/me returns the identity", %{conn: conn, user: user} do
      assert %{"id" => id, "email" => email} = json_response(get(conn, ~p"/api/v1/users/me"), 200)
      assert id == user.id
      assert email == user.email
    end
  end

  describe "GET /canvases" do
    test "lists own canvases without documents", %{conn: conn, scope: scope} do
      canvas = canvas_fixture(scope)

      assert %{"data" => [entry]} = json_response(get(conn, ~p"/api/v1/canvases"), 200)
      assert entry["slug"] == canvas.slug
      assert entry["role"] == "owner"
      refute Map.has_key?(entry, "document")
    end

    test "does not list other people's canvases", %{conn: conn} do
      canvas_fixture(Scope.for_user(user_fixture()))

      assert %{"data" => []} = json_response(get(conn, ~p"/api/v1/canvases"), 200)
    end

    test "filters by folder", %{conn: conn, scope: scope} do
      folder = folder_fixture(scope)
      in_folder = canvas_fixture(scope, %{folder_id: folder.id})
      _loose = canvas_fixture(scope)

      conn = get(conn, ~p"/api/v1/canvases?folder_id=#{folder.id}")
      assert %{"data" => [entry]} = json_response(conn, 200)
      assert entry["slug"] == in_folder.slug
    end
  end

  describe "GET /canvases/:slug" do
    test "returns the document verbatim", %{conn: conn, scope: scope} do
      canvas = canvas_fixture(scope)
      allow_authority(canvas.id)

      assert %{"data" => data} =
               json_response(get(conn, ~p"/api/v1/canvases/#{canvas.slug}"), 200)

      assert data["document"] == %{"elements" => %{}, "order" => [], "connectors" => %{}}
      assert data["version"] == 0
      assert data["role"] == "owner"
    end

    test "reads through the authority, not the stale row", %{conn: conn, scope: scope} do
      canvas = canvas_fixture(scope)
      allow_authority(canvas.id)

      op = %{
        "type" => "insert_element",
        "element" => %{"id" => "e1", "type" => "rect", "x" => 0, "y" => 0, "w" => 10, "h" => 10}
      }

      {:ok, _} = Diagrams.Authority.apply_op(canvas.id, scope, op, "test")

      # The canvases row still says version 0 — the authority snapshots
      # only every 25 ops — so a DB read here would hand back an empty
      # document.
      assert %{"data" => data} =
               json_response(get(conn, ~p"/api/v1/canvases/#{canvas.slug}"), 200)

      assert data["version"] == 1
      assert %{"elements" => %{"e1" => _}} = data["document"]
    end

    test "404s for someone else's canvas rather than 403", %{conn: conn} do
      canvas = canvas_fixture(Scope.for_user(user_fixture()))

      assert %{"error" => %{"code" => "not_found"}} =
               json_response(get(conn, ~p"/api/v1/canvases/#{canvas.slug}"), 404)
    end
  end

  describe "writes" do
    test "creates a canvas", %{conn: conn} do
      params = %{slug: "from-android", title: "From Android", theme: "forest"}

      assert %{"data" => data} = json_response(post(conn, ~p"/api/v1/canvases", params), 201)
      assert data["slug"] == "from-android"
      assert data["theme"] == "forest"
    end

    test "reports validation errors with details", %{conn: conn} do
      params = %{slug: "Not A Slug", title: ""}

      assert %{"error" => %{"code" => "unprocessable", "details" => details}} =
               json_response(post(conn, ~p"/api/v1/canvases", params), 422)

      assert details["slug"]
      assert details["title"]
    end

    test "updates metadata but ignores document and version", %{conn: conn, scope: scope} do
      canvas = canvas_fixture(scope)

      params = %{title: "Renamed", version: 99, document: %{"elements" => %{"x" => %{}}}}

      assert %{"data" => data} =
               json_response(patch(conn, ~p"/api/v1/canvases/#{canvas.slug}", params), 200)

      assert data["title"] == "Renamed"
      assert data["version"] == 0
      assert data["document"] == %{"elements" => %{}, "order" => [], "connectors" => %{}}
    end

    test "a viewer may read but not write", %{conn: conn, user: user} do
      owner = user_fixture()
      canvas = canvas_fixture(Scope.for_user(owner))
      allow_authority(canvas.id)
      {:ok, _} = Diagrams.grant_permission("canvas", canvas.id, "user", user.id, "viewer")

      assert json_response(get(conn, ~p"/api/v1/canvases/#{canvas.slug}"), 200)

      conn = recycle_authed(conn)

      assert %{"error" => %{"code" => "forbidden"}} =
               json_response(
                 patch(conn, ~p"/api/v1/canvases/#{canvas.slug}", %{title: "Nope"}),
                 403
               )
    end

    test "an editor may not delete", %{conn: conn, user: user} do
      owner = user_fixture()
      canvas = canvas_fixture(Scope.for_user(owner))
      {:ok, _} = Diagrams.grant_permission("canvas", canvas.id, "user", user.id, "editor")

      assert %{"error" => %{"code" => "forbidden"}} =
               json_response(delete(conn, ~p"/api/v1/canvases/#{canvas.slug}"), 403)
    end

    test "the owner may delete", %{conn: conn, scope: scope} do
      canvas = canvas_fixture(scope)

      assert response(delete(conn, ~p"/api/v1/canvases/#{canvas.slug}"), 204)
      refute Diagrams.get_canvas_by_slug(scope, canvas.slug)
    end
  end

  describe "exports and metadata" do
    test "exports json and mermaid", %{conn: conn, scope: scope} do
      canvas = canvas_fixture(scope)
      allow_authority(canvas.id)

      assert %{"format" => _} =
               json_response(get(conn, ~p"/api/v1/canvases/#{canvas.slug}/export.json"), 200)

      assert response(
               get(recycle_authed(conn), ~p"/api/v1/canvases/#{canvas.slug}/export.mermaid"),
               200
             )
    end

    test "serves the theme and token whitelists", %{conn: conn} do
      assert %{"themes" => themes, "color_tokens" => tokens} =
               json_response(get(conn, ~p"/api/v1/themes"), 200)

      assert "afterdark" in themes
      assert "base-100" in tokens
    end

    test "lists folders", %{conn: conn, scope: scope} do
      folder = folder_fixture(scope)

      assert %{"data" => [entry]} = json_response(get(conn, ~p"/api/v1/folders"), 200)
      assert entry["id"] == folder.id
    end
  end

  defp recycle_authed(conn) do
    [auth] = Plug.Conn.get_req_header(conn, "authorization")

    conn
    |> recycle()
    |> put_req_header("accept", "application/json")
    |> put_req_header("authorization", auth)
  end
end