add mobile token auth to GraphQL endpoint (#48)

Extend the GraphQL API and socket to accept mobile session tokens
alongside existing API tokens. Add OrganizationScope middleware
that extracts organization_id from query args for mobile users.
Add my_organizations query for mobile app org listing.

Reviewed-on: graham/towerops-web#48
This commit is contained in:
Graham McIntire 2026-03-16 15:23:07 -05:00
parent 0fa1fa800b
commit c5481c2cd3
10 changed files with 555 additions and 22 deletions

View file

@ -0,0 +1,37 @@
defmodule ToweropsWeb.GraphQL.Middleware.OrganizationScope do
@moduledoc """
Absinthe middleware that ensures organization_id is present in context.
For API token users: organization_id is already in context passes through.
For mobile users: extracts organization_id from args, validates access, sets in context.
"""
@behaviour Absinthe.Middleware
alias Towerops.Organizations
@impl true
def call(%{context: %{organization_id: _org_id}} = resolution, _config) do
resolution
end
def call(%{context: %{user: user}, arguments: %{organization_id: org_id}} = resolution, _config)
when is_binary(org_id) do
if Organizations.user_has_access?(user.id, org_id) do
%{resolution | context: Map.put(resolution.context, :organization_id, org_id)}
else
Absinthe.Resolution.put_result(resolution, {:error, "Access denied to this organization"})
end
end
def call(%{context: %{user: _user}} = resolution, _config) do
Absinthe.Resolution.put_result(
resolution,
{:error, "organization_id argument is required for mobile authentication"}
)
end
def call(resolution, _config) do
Absinthe.Resolution.put_result(resolution, {:error, "Authentication required"})
end
end

View file

@ -1,9 +1,29 @@
defmodule ToweropsWeb.GraphQL.Resolvers.Organization do
@moduledoc "GraphQL resolvers for organization queries and mutations."
alias Towerops.Alerts
alias Towerops.Devices
alias Towerops.Organizations
alias Towerops.Sites
alias ToweropsWeb.GraphQL.Resolvers.Helpers
def list_mine(_parent, _args, %{context: %{user: user}}) do
orgs =
user.id
|> Organizations.list_user_organizations()
|> Enum.map(fn org ->
Map.merge(org, %{
sites_count: Sites.count_organization_sites(org.id),
device_count: Devices.count_organization_devices(org.id),
active_alerts_count: Alerts.count_active_alerts(org.id)
})
end)
{:ok, orgs}
end
def list_mine(_parent, _args, _resolution), do: Helpers.authentication_error()
def get(_parent, _args, %{context: %{organization_id: org_id}}) do
org = Organizations.get_organization!(org_id)
{:ok, org}

View file

@ -4,9 +4,14 @@ defmodule ToweropsWeb.GraphQL.Schema do
Provides a complete GraphQL interface for managing devices, sites, alerts,
agents, organization settings, members, integrations, and activity feeds.
Queries that require an organization scope accept an optional `organization_id`
argument for mobile authentication. API token users can omit it (org comes from token).
"""
use Absinthe.Schema
alias ToweropsWeb.GraphQL.Middleware.OrganizationScope
import_types(ToweropsWeb.GraphQL.Types.Common)
import_types(ToweropsWeb.GraphQL.Types.Device)
import_types(ToweropsWeb.GraphQL.Types.Site)
@ -22,145 +27,200 @@ defmodule ToweropsWeb.GraphQL.Schema do
import_types(ToweropsWeb.GraphQL.Types.TimeSeries)
query do
# User's organizations (no org scope needed — for mobile org picker)
field :my_organizations, list_of(:organization) do
resolve(&ToweropsWeb.GraphQL.Resolvers.Organization.list_mine/3)
end
# Devices
field :devices, list_of(:device) do
arg(:organization_id, :id)
arg(:site_id, :id)
arg(:status, :string)
arg(:limit, :integer, default_value: 100)
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.Device.list/3)
end
field :device, :device do
arg(:organization_id, :id)
arg(:id, non_null(:id))
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.Device.get/3)
end
# Sites
field :sites, list_of(:site) do
arg(:organization_id, :id)
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.Site.list/3)
end
field :site, :site do
arg(:organization_id, :id)
arg(:id, non_null(:id))
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.Site.get/3)
end
# Alerts
field :alerts, list_of(:alert) do
arg(:organization_id, :id)
arg(:status, :string)
arg(:device_id, :id)
arg(:limit, :integer, default_value: 100)
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.Alert.list/3)
end
field :alert, :alert do
arg(:organization_id, :id)
arg(:id, non_null(:id))
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.Alert.get/3)
end
# Agents
field :agents, list_of(:agent) do
arg(:organization_id, :id)
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.Agent.list/3)
end
field :agent, :agent do
arg(:organization_id, :id)
arg(:id, non_null(:id))
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.Agent.get/3)
end
# Organization
field :organization, :organization do
arg(:organization_id, :id)
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.Organization.get/3)
end
# Members
field :members, list_of(:member) do
arg(:organization_id, :id)
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.Member.list/3)
end
# Integrations
field :integrations, list_of(:integration) do
arg(:organization_id, :id)
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.Integration.list/3)
end
# Activity feed
field :activity, list_of(:activity_item) do
arg(:organization_id, :id)
arg(:limit, :integer, default_value: 50)
arg(:type, :string)
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.Activity.list/3)
end
# Device metrics
field :device_metrics, list_of(:metric_point) do
arg(:organization_id, :id)
arg(:device_id, non_null(:id))
arg(:sensor_type, :string)
arg(:time_range, :string, default_value: "24h")
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.Device.metrics/3)
end
# Device interfaces
field :device_interfaces, list_of(:interface) do
arg(:organization_id, :id)
arg(:device_id, non_null(:id))
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.Device.interfaces/3)
end
# Time-series queries
field :device_sensors, list_of(:sensor) do
arg(:organization_id, :id)
arg(:device_id, non_null(:id))
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.TimeSeries.device_sensors/3)
end
field :sensor_readings, list_of(:sensor_reading) do
arg(:organization_id, :id)
arg(:sensor_id, non_null(:id))
arg(:time_range, :string, default_value: "24h")
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.TimeSeries.sensor_readings/3)
end
field :interface_traffic, list_of(:interface_traffic_point) do
arg(:organization_id, :id)
arg(:interface_id, non_null(:id))
arg(:time_range, :string, default_value: "24h")
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.TimeSeries.interface_traffic/3)
end
field :check_results, list_of(:check_result_point) do
arg(:organization_id, :id)
arg(:check_id, non_null(:id))
arg(:time_range, :string, default_value: "1h")
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.TimeSeries.check_results/3)
end
# Schedules
field :schedules, list_of(:schedule) do
arg(:organization_id, :id)
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.list/3)
end
field :schedule, :schedule do
arg(:organization_id, :id)
arg(:id, non_null(:id))
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.get/3)
end
field :on_call, :on_call_result do
arg(:organization_id, :id)
arg(:schedule_id, non_null(:id))
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.on_call/3)
end
# Escalation Policies
field :escalation_policies, list_of(:escalation_policy) do
arg(:organization_id, :id)
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.list/3)
end
field :escalation_policy, :escalation_policy do
arg(:organization_id, :id)
arg(:id, non_null(:id))
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.get/3)
end
# Maintenance Windows
field :maintenance_windows, list_of(:maintenance_window) do
arg(:organization_id, :id)
arg(:filter, :string)
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.MaintenanceWindow.list/3)
end
field :maintenance_window, :maintenance_window do
arg(:organization_id, :id)
arg(:id, non_null(:id))
middleware(OrganizationScope)
resolve(&ToweropsWeb.GraphQL.Resolvers.MaintenanceWindow.get/3)
end
end
@ -400,30 +460,63 @@ defmodule ToweropsWeb.GraphQL.Schema do
subscription do
field :device_status_changed, :device_status_event do
arg(:organization_id, :id)
arg(:device_id, :id)
config(fn args, %{context: %{organization_id: org_id}} ->
topic =
if args[:device_id],
do: "gql:device_status:#{org_id}:#{args.device_id}",
else: "gql:device_status:#{org_id}"
config(fn args, resolution ->
org_id = resolve_subscription_org_id(args, resolution)
{:ok, topic: topic}
if org_id do
topic =
if args[:device_id],
do: "gql:device_status:#{org_id}:#{args.device_id}",
else: "gql:device_status:#{org_id}"
{:ok, topic: topic}
else
{:error, "organization_id is required"}
end
end)
end
field :alert_event, :alert_event do
config(fn _args, %{context: %{organization_id: org_id}} ->
{:ok, topic: "gql:alerts:#{org_id}"}
arg(:organization_id, :id)
config(fn args, resolution ->
org_id = resolve_subscription_org_id(args, resolution)
if org_id do
{:ok, topic: "gql:alerts:#{org_id}"}
else
{:error, "organization_id is required"}
end
end)
end
field :sensor_readings_updated, :sensor_readings_event do
arg(:organization_id, :id)
arg(:device_id, non_null(:id))
config(fn %{device_id: device_id}, %{context: %{organization_id: org_id}} ->
{:ok, topic: "gql:sensor_readings:#{org_id}:#{device_id}"}
config(fn args, resolution ->
org_id = resolve_subscription_org_id(args, resolution)
if org_id do
{:ok, topic: "gql:sensor_readings:#{org_id}:#{args.device_id}"}
else
{:error, "organization_id is required"}
end
end)
end
end
# Resolves org_id from context (API token) or args (mobile token)
defp resolve_subscription_org_id(args, %{context: %{organization_id: org_id}}) do
args[:organization_id] || org_id
end
defp resolve_subscription_org_id(%{organization_id: org_id}, %{context: %{user: user}}) when is_binary(org_id) do
if Towerops.Organizations.user_has_access?(user.id, org_id), do: org_id
end
defp resolve_subscription_org_id(_args, _resolution), do: nil
end

View file

@ -31,6 +31,11 @@ defmodule ToweropsWeb.GraphQL.Types.Organization do
field :default_agent_token_id, :id
field :inserted_at, :string
field :updated_at, :string
# Computed fields (populated by my_organizations resolver)
field :sites_count, :integer
field :device_count, :integer
field :active_alerts_count, :integer
end
input_object :organization_input do

View file

@ -2,21 +2,28 @@ defmodule ToweropsWeb.GraphQLSocket do
@moduledoc """
WebSocket endpoint for GraphQL subscriptions.
Authenticates via API token passed as a connection parameter.
Authenticates via either:
- API token (prefixed with "towerops_") sets organization_id + user in context
- Mobile session token sets user only (no organization_id)
"""
use Phoenix.Socket
use Absinthe.Phoenix.Socket, schema: ToweropsWeb.GraphQL.Schema
alias Absinthe.Phoenix.Socket
alias Towerops.Accounts
alias Towerops.ApiTokens
alias Towerops.MobileSessions
@impl true
def connect(%{"token" => token}, socket, _connect_info) when is_binary(token) do
case Towerops.ApiTokens.verify_token(token) do
def connect(%{"token" => "towerops_" <> _ = token}, socket, _connect_info) do
case ApiTokens.verify_token(token) do
{:ok, org_id, user} ->
socket =
socket
|> assign(:organization_id, org_id)
|> assign(:user, user)
|> Absinthe.Phoenix.Socket.put_options(context: %{organization_id: org_id, user: user})
|> Socket.put_options(context: %{organization_id: org_id, user: user})
{:ok, socket}
@ -25,8 +32,26 @@ defmodule ToweropsWeb.GraphQLSocket do
end
end
def connect(%{"token" => token}, socket, _connect_info) when is_binary(token) do
with session when not is_nil(session) <- MobileSessions.get_session_by_token(token),
user when not is_nil(user) <- Accounts.get_user(session.user_id) do
_ = Task.start(fn -> MobileSessions.touch_session(session) end)
socket =
socket
|> assign(:user, user)
|> Socket.put_options(context: %{user: user})
{:ok, socket}
else
_ -> :error
end
end
def connect(_params, _socket, _connect_info), do: :error
@impl true
def id(socket), do: "graphql_socket:#{socket.assigns.organization_id}"
def id(%{assigns: %{organization_id: org_id}}), do: "graphql_socket:#{org_id}"
def id(%{assigns: %{user: user}}), do: "graphql_socket:user:#{user.id}"
def id(_socket), do: nil
end

View file

@ -0,0 +1,75 @@
defmodule ToweropsWeb.Plugs.GraphQLAuth do
@moduledoc """
Plug for authenticating GraphQL requests using either API tokens or mobile session tokens.
Accepts two token types via Authorization: Bearer <token>:
- API tokens (prefixed with "towerops_") sets current_organization_id + current_user
- Mobile session tokens sets current_user only (no org_id)
Returns 401 Unauthorized if neither token type is valid.
"""
import Phoenix.Controller, only: [json: 2]
import Plug.Conn
alias Towerops.Accounts
alias Towerops.ApiTokens
alias Towerops.MobileSessions
def init(opts), do: opts
def call(conn, _opts) do
case get_req_header(conn, "authorization") do
["Bearer " <> token] ->
authenticate(conn, token)
_ ->
unauthorized(conn, "Missing or invalid Authorization header")
end
end
defp authenticate(conn, "towerops_" <> _ = token) do
case ApiTokens.verify_token(token) do
{:ok, organization_id, user} ->
conn
|> assign(:current_organization_id, organization_id)
|> assign(:current_user, user)
{:error, :invalid_token} ->
unauthorized(conn, "Invalid or expired API token")
end
end
defp authenticate(conn, token) do
with {:ok, session} <- validate_mobile_session(token),
{:ok, user} <- get_user(session.user_id) do
_ = Task.start(fn -> MobileSessions.touch_session(session) end)
assign(conn, :current_user, user)
else
{:error, _reason} ->
unauthorized(conn, "Invalid or expired authentication token")
end
end
defp validate_mobile_session(token) do
case MobileSessions.get_session_by_token(token) do
nil -> {:error, :invalid_token}
session -> {:ok, session}
end
end
defp get_user(user_id) do
case Accounts.get_user(user_id) do
nil -> {:error, :user_not_found}
user -> {:ok, user}
end
end
defp unauthorized(conn, message) do
conn
|> put_status(:unauthorized)
|> json(%{error: message})
|> halt()
end
end

View file

@ -65,6 +65,11 @@ defmodule ToweropsWeb.Router do
plug RateLimit, type: :api
end
pipeline :graphql_api do
plug :accepts, ["json"]
plug ToweropsWeb.Plugs.GraphQLAuth
end
pipeline :graphql_context do
plug ToweropsWeb.Plugs.GraphQLIntrospection
plug ToweropsWeb.GraphQL.Context
@ -133,9 +138,9 @@ defmodule ToweropsWeb.Router do
post "/webhooks/stripe", StripeWebhookController, :create
end
# GraphQL API endpoint (requires API token authentication)
# GraphQL API endpoint (accepts API tokens or mobile session tokens)
scope "/api/graphql" do
pipe_through [:api_v1, :rate_limit_api, :graphql_context]
pipe_through [:graphql_api, :rate_limit_api, :graphql_context]
forward "/", Absinthe.Plug,
schema: ToweropsWeb.GraphQL.Schema,

View file

@ -0,0 +1,72 @@
defmodule ToweropsWeb.GraphQL.Middleware.OrganizationScopeTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias ToweropsWeb.GraphQL.Middleware.OrganizationScope
describe "call/2" do
test "passes through when context already has organization_id" do
org_id = Ecto.UUID.generate()
resolution = %Absinthe.Resolution{
context: %{organization_id: org_id, user: %{id: Ecto.UUID.generate()}},
arguments: %{}
}
result = OrganizationScope.call(resolution, [])
assert result.context.organization_id == org_id
assert result.errors == []
end
test "promotes organization_id from args for mobile user with access" do
user = user_fixture()
organization = organization_fixture(user.id)
resolution = %Absinthe.Resolution{
context: %{user: user},
arguments: %{organization_id: organization.id}
}
result = OrganizationScope.call(resolution, [])
assert result.context.organization_id == organization.id
assert result.errors == []
end
test "errors when mobile user lacks access to organization" do
user = user_fixture()
other_org_id = Ecto.UUID.generate()
resolution = %Absinthe.Resolution{
context: %{user: user},
arguments: %{organization_id: other_org_id}
}
result = OrganizationScope.call(resolution, [])
assert result.errors != []
end
test "errors when mobile user omits organization_id" do
user = user_fixture()
resolution = %Absinthe.Resolution{
context: %{user: user},
arguments: %{}
}
result = OrganizationScope.call(resolution, [])
assert result.errors != []
end
test "errors when no authentication at all" do
resolution = %Absinthe.Resolution{
context: %{},
arguments: %{}
}
result = OrganizationScope.call(resolution, [])
assert result.errors != []
end
end
end

View file

@ -5,9 +5,10 @@ defmodule ToweropsWeb.GraphQLSocketTest do
import Towerops.OrganizationsFixtures
alias Towerops.ApiTokens
alias Towerops.MobileSessions
alias ToweropsWeb.GraphQLSocket
describe "connect/3" do
describe "connect/3 with API token" do
setup do
user = user_fixture()
organization = organization_fixture(user.id)
@ -35,15 +36,61 @@ defmodule ToweropsWeb.GraphQLSocketTest do
assert socket.assigns.organization_id == organization.id
end
test "rejects invalid token" do
test "rejects invalid API token" do
socket = %Phoenix.Socket{
assigns: %{},
transport: :websocket
}
assert :error = GraphQLSocket.connect(%{"token" => "invalid_token"}, socket, %{})
assert :error = GraphQLSocket.connect(%{"token" => "towerops_invalid_xxx"}, socket, %{})
end
end
describe "connect/3 with mobile session token" do
setup do
user = user_fixture()
{:ok, session} =
MobileSessions.create_mobile_session(%{
user_id: user.id,
device_name: "Test iPhone"
})
%{user: user, session: session}
end
test "connects with valid mobile token", %{user: user, session: session} do
socket = %Phoenix.Socket{
assigns: %{},
transport: :websocket
}
assert {:ok, socket} = GraphQLSocket.connect(%{"token" => session.raw_token}, socket, %{})
assert socket.assigns.user.id == user.id
refute Map.has_key?(socket.assigns, :organization_id)
end
test "rejects expired mobile token" do
user = user_fixture()
expires_at = DateTime.add(DateTime.utc_now(), -1, :day)
{:ok, session} =
MobileSessions.create_mobile_session(%{
user_id: user.id,
device_name: "Expired iPhone",
expires_at: expires_at
})
socket = %Phoenix.Socket{
assigns: %{},
transport: :websocket
}
assert :error = GraphQLSocket.connect(%{"token" => session.raw_token}, socket, %{})
end
end
describe "connect/3 with invalid params" do
test "rejects missing token" do
socket = %Phoenix.Socket{
assigns: %{},
@ -52,15 +99,32 @@ defmodule ToweropsWeb.GraphQLSocketTest do
assert :error = GraphQLSocket.connect(%{}, socket, %{})
end
test "rejects completely invalid token" do
socket = %Phoenix.Socket{
assigns: %{},
transport: :websocket
}
assert :error = GraphQLSocket.connect(%{"token" => "totally_bogus"}, socket, %{})
end
end
describe "id/1" do
test "returns socket ID based on organization_id" do
test "returns socket ID based on organization_id for API token connections" do
socket = %Phoenix.Socket{
assigns: %{organization_id: "some-org-id"}
assigns: %{organization_id: "some-org-id", user: %{id: "some-user-id"}}
}
assert GraphQLSocket.id(socket) == "graphql_socket:some-org-id"
end
test "returns socket ID based on user_id for mobile connections" do
socket = %Phoenix.Socket{
assigns: %{user: %{id: "some-user-id"}}
}
assert GraphQLSocket.id(socket) == "graphql_socket:user:some-user-id"
end
end
end

View file

@ -0,0 +1,137 @@
defmodule ToweropsWeb.Plugs.GraphQLAuthTest do
use ToweropsWeb.ConnCase, async: true
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.ApiTokens
alias Towerops.MobileSessions
alias ToweropsWeb.Plugs.GraphQLAuth
describe "init/1" do
test "returns options unchanged" do
assert GraphQLAuth.init([]) == []
end
end
describe "call/2 with API token (towerops_ prefix)" 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: "GraphQL Auth Test Token"
})
%{user: user, organization: organization, raw_token: raw_token}
end
test "authenticates and sets org_id + user", %{
conn: conn,
raw_token: raw_token,
organization: organization,
user: user
} do
conn =
conn
|> put_req_header("authorization", "Bearer #{raw_token}")
|> GraphQLAuth.call([])
refute conn.halted
assert conn.assigns.current_organization_id == organization.id
assert conn.assigns.current_user.id == user.id
end
test "returns 401 for expired API token", %{conn: conn, organization: organization, user: user} 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
})
conn =
conn
|> put_req_header("authorization", "Bearer #{raw_token}")
|> GraphQLAuth.call([])
assert conn.halted
assert conn.status == 401
end
end
describe "call/2 with mobile session token" do
setup do
user = user_fixture()
{:ok, session} =
MobileSessions.create_mobile_session(%{
user_id: user.id,
device_name: "Test iPhone"
})
%{user: user, session: session}
end
test "authenticates and sets user but not org_id", %{
conn: conn,
user: user,
session: session
} do
conn =
conn
|> put_req_header("authorization", "Bearer #{session.raw_token}")
|> GraphQLAuth.call([])
refute conn.halted
assert conn.assigns.current_user.id == user.id
refute Map.has_key?(conn.assigns, :current_organization_id)
end
test "returns 401 for expired mobile session", %{conn: conn} do
user = user_fixture()
expires_at = DateTime.add(DateTime.utc_now(), -1, :day)
{:ok, session} =
MobileSessions.create_mobile_session(%{
user_id: user.id,
device_name: "Expired iPhone",
expires_at: expires_at
})
conn =
conn
|> put_req_header("authorization", "Bearer #{session.raw_token}")
|> GraphQLAuth.call([])
assert conn.halted
assert conn.status == 401
end
end
describe "call/2 with missing or invalid auth" do
test "returns 401 when Authorization header is missing", %{conn: conn} do
conn = GraphQLAuth.call(conn, [])
assert conn.halted
assert conn.status == 401
assert json_response(conn, 401) == %{"error" => "Missing or invalid Authorization header"}
end
test "returns 401 when token is completely invalid", %{conn: conn} do
conn =
conn
|> put_req_header("authorization", "Bearer totally_invalid_token_xyz")
|> GraphQLAuth.call([])
assert conn.halted
assert conn.status == 401
end
end
end