Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ config :spendable,

config :spendable, Oban,
repo: Spendable.Repo,
queues: [banks: 5]
queues: [banks: 5, notifications: 5]

config :spendable, Spendable.Repo,
migration_primary_key: [type: :text],
Expand Down Expand Up @@ -80,6 +80,12 @@ config :spendable, Spendable.Accounts.Clients.Apple,
base_url: "https://appleid.apple.com",
audiences: ["fiftysevenmedia.Spendable"]

# An APNs topic is the bundle id. The signing key, its id and the team id come from runtime.exs;
# without them the client answers `{:error, :not_configured}` and nothing else changes.
config :spendable, Spendable.Accounts.Clients.Apns,
base_url: "https://api.push.apple.com",
topic: "fiftysevenmedia.Spendable"

# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"
22 changes: 21 additions & 1 deletion config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ defmodule Secret do
System.get_env(name, non_prod_default)
end
end

# For credentials the app runs without. A missing file is the answer, not a failure to boot.
def read(name) do
if config_env() == :prod do
case File.read("/etc/secrets/" <> name) do
{:ok, value} -> value
{:error, :enoent} -> nil
end
else
System.get_env(name)
end
end
end

config :spendable, Spendable.Banks.Clients.Plaid,
Expand All @@ -22,9 +34,17 @@ config :ueberauth, Ueberauth.Strategy.Google.OAuth,
client_id: Secret.read!("GOOGLE_CLIENT_ID"),
client_secret: Secret.read!("GOOGLE_CLIENT_SECRET")

# Both pin values that config/test.exs sets for itself, which these would otherwise blank out.
# An OAuth client id is a public identifier, not a secret, so the iOS one is a plain env var.
# Test pins its own audience in config/test.exs, which this would otherwise blank out.
# Push notifications are optional: with no signing key the client refuses to send and everything
# else is unchanged, so a machine without a `.p8` still runs.
if config_env() != :test do
config :spendable, Spendable.Accounts.Clients.Apns,
base_url: System.get_env("APNS_BASE_URL", "https://api.push.apple.com"),
key_id: Secret.read("APNS_KEY_ID"),
private_key: Secret.read("APNS_PRIVATE_KEY"),
team_id: Secret.read("APNS_TEAM_ID")

config :spendable, Spendable.Accounts.Clients.Google,
base_url: "https://www.googleapis.com",
audiences:
Expand Down
13 changes: 13 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@ config :spendable, Spendable.Accounts.Clients.Google,
base_url: "https://www.googleapis.com",
audiences: ["spendable-ios.apps.googleusercontent.com"]

# A throwaway P-256 key, here rather than in test/support because config is read before anything
# is compiled. It signs nothing that leaves the test run - Tesla is mocked.
config :spendable, Spendable.Accounts.Clients.Apns,
key_id: "ABC1234567",
team_id: "DEF8901234",
private_key: """
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgqSgwFjvegbhHedz/
5v5WvWxfgUEv8GI5uci7vhcAJvGhRANCAATFnAuzQDfLCiZ4ei706S5hE/tCwwUw
Pb1xcx7n5+asgl4/cpbFp0N3mrIUi5Vdg/SSncErF+OL18UIlcnaUX3Y
-----END PRIVATE KEY-----
"""

config :tesla, adapter: TeslaMock

# Jobs run inline in tests so a sync is asserted on, not waited for.
Expand Down
3 changes: 3 additions & 0 deletions lib/spendable/accounts.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ defmodule Spendable.Accounts do

defdelegate authenticate_api_token(token), to: Actions.AuthenticateApiToken
defdelegate create_api_token(scope, attrs), to: Actions.CreateApiToken
defdelegate deliver_notification(user_id, notification), to: Actions.DeliverNotification
defdelegate get_identity(scope, id), to: Actions.GetIdentity
defdelegate get_user(id), to: Actions.GetUser
defdelegate link_identity(scope, provider, id_token), to: Actions.LinkIdentity
defdelegate list_identities(scope), to: Actions.ListIdentities
defdelegate notify_user(scope, notification), to: Actions.NotifyUser
defdelegate register_apns_token(scope, api_token, apns_token), to: Actions.RegisterApnsToken
defdelegate revoke_api_token(scope, api_token), to: Actions.RevokeApiToken
defdelegate sign_in_with_oauth(provider, id_token), to: Actions.SignInWithOauth
defdelegate unlink_identity(scope, identity), to: Actions.UnlinkIdentity
Expand Down
14 changes: 14 additions & 0 deletions lib/spendable/accounts/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,24 @@ _Avoid_: Customer, member, profile
How many bank connections a user is allowed to hold at once.
_Avoid_: Quota, plan, tier

**API Token**:
One signed-in device's credential. Held per device, so revoking one signs out only that device.
_Avoid_: Session, key

**Device Token**:
What APNs calls a device, held against the **API Token** it was registered with.
_Avoid_: Push token, APNs token outside the code that talks to Apple

**Notification**:
One push to a **User**, whatever produced it. A sync that found fifty charges is one of these.
_Avoid_: Alert, which is only its visible half, and message

## Relationships

- A **User** owns every **Budget**, **Transaction** and **Bank Member** in the system
- A **User**'s **Bank Limit** caps how many **Bank Members** they may hold
- A **User** holds one **API Token** per device, each carrying at most one **Device Token**
- A **Notification** goes to every **Device Token** a **User** has registered

## Flagged ambiguities

Expand Down
75 changes: 75 additions & 0 deletions lib/spendable/accounts/actions/deliver_notification.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
defmodule Spendable.Accounts.Actions.DeliverNotification do
@moduledoc false

import Ecto.Query

alias Spendable.Accounts.Clients.Apns
alias Spendable.Accounts.Schemas.ApiToken
alias Spendable.Repo

require Logger

# A token APNs names as dead will never work again, so the row is cleared instead of retried.
@dead_token_reasons ["BadDeviceToken", "DeviceTokenNotForTopic", "Unregistered"]

@doc """
Pushes to every device the user has registered. Takes an id rather than a scope because the only
caller is the job queue, which carries ids.

Every completed sync pushes, count or no count: the silent half is the only signal the app gets
that a sync it asked for has finished.
"""
def deliver_notification(user_id, %{count: count, total: total, alert: alert})
when is_binary(user_id) do
message = build_message(count, total, alert)

query =
from api_token in ApiToken,
where: api_token.user_id == ^user_id,
where: not is_nil(api_token.apns_token)

query
|> Repo.all()
|> Enum.map(&push(&1, message))
|> Enum.find(:ok, &match?({:error, _reason}, &1))
end

defp push(%ApiToken{} = api_token, {payload, headers}) do
case Apns.push(api_token.apns_token, payload, headers) do
{:ok, %Tesla.Env{status: 200}} ->
:ok

{:ok, %Tesla.Env{body: %{"reason" => reason}}} when reason in @dead_token_reasons ->
Logger.info("APNs rejected a device: #{reason}")

{:ok, _cleared} = api_token |> ApiToken.changeset(%{apns_token: nil}) |> Repo.update()

:ok

{:ok, %Tesla.Env{status: status, body: body}} ->
{:error, "APNs returned #{status}: #{inspect(body)}"}

{:error, :not_configured} ->
:ok
end
end

defp build_message(count, total, true) when count > 0 do
noun = if count == 1, do: "transaction", else: "transactions"
amount = total |> Decimal.abs() |> Decimal.round(2) |> Decimal.to_string(:normal)

payload = %{
aps: %{
alert: %{title: "Spendable", body: "#{count} new #{noun} 路 $#{amount}"},
"content-available": 1,
sound: "default"
}
}

{payload, [{"apns-push-type", "alert"}, {"apns-priority", "10"}]}
end

defp build_message(_count, _total, _alert) do
{%{aps: %{"content-available": 1}}, [{"apns-push-type", "background"}, {"apns-priority", "5"}]}
end
end
133 changes: 133 additions & 0 deletions lib/spendable/accounts/actions/deliver_notification_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
defmodule Spendable.Accounts.Actions.DeliverNotificationTest do
# Not async: the unconfigured case is the absence of application config, which is global.
use Spendable.DataCase, async: false

alias Spendable.Accounts
alias Spendable.Accounts.Clients.Apns
alias Spendable.Accounts.Schemas.ApiToken
alias Spendable.Scope

@device_token String.duplicate("ab", 32)

setup do
{:ok, user} =
Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"})

scope = Scope.for_user(user)

{:ok, api_token} = Accounts.create_api_token(scope, %{})
{:ok, api_token} = Accounts.register_apns_token(scope, api_token, @device_token)

%{api_token: api_token, user: user}
end

test "tells the user what landed", %{user: user} do
expect(TeslaMock, :call, fn %{body: body, headers: headers}, _opts ->
send(self(), {:pushed, body, headers})

TeslaHelper.response(status: 200)
end)

assert :ok =
Accounts.deliver_notification(user.id, %{
count: 3,
total: Decimal.new("-84.21"),
alert: true
})

assert_received {:pushed, body, headers}
assert %{"aps" => %{"alert" => %{"body" => "3 new transactions 路 $84.21"}}} = Jason.decode!(body)
assert {"apns-push-type", "alert"} in headers
assert {"apns-priority", "10"} in headers
end

test "counts one transaction as one", %{user: user} do
expect(TeslaMock, :call, fn %{body: body}, _opts ->
send(self(), {:pushed, body})

TeslaHelper.response(status: 200)
end)

:ok =
Accounts.deliver_notification(user.id, %{
count: 1,
total: Decimal.new("-9.5"),
alert: true
})

assert_received {:pushed, body}
assert %{"aps" => %{"alert" => %{"body" => "1 new transaction 路 $9.50"}}} = Jason.decode!(body)
end

# The app has no other signal that a sync it asked for has finished.
test "wakes the app when a sync found nothing", %{user: user} do
expect(TeslaMock, :call, fn %{body: body, headers: headers}, _opts ->
send(self(), {:pushed, body, headers})

TeslaHelper.response(status: 200)
end)

:ok = Accounts.deliver_notification(user.id, %{count: 0, total: Decimal.new(0), alert: true})

assert_received {:pushed, body, headers}
assert %{"aps" => aps} = Jason.decode!(body)
refute Map.has_key?(aps, "alert")
assert {"apns-push-type", "background"} in headers
assert {"apns-priority", "5"} in headers
end

test "stays silent about a run that was not the user's to hear about", %{user: user} do
expect(TeslaMock, :call, fn %{body: body}, _opts ->
send(self(), {:pushed, body})

TeslaHelper.response(status: 200)
end)

:ok =
Accounts.deliver_notification(user.id, %{
count: 240,
total: Decimal.new("-9000"),
alert: false
})

assert_received {:pushed, body}
assert %{"aps" => aps} = Jason.decode!(body)
refute Map.has_key?(aps, "alert")
end

test "drops a device APNs says is gone", %{api_token: api_token, user: user} do
expect(TeslaMock, :call, fn _env, _opts ->
TeslaHelper.response(status: 410, body: %{"reason" => "Unregistered"})
end)

assert :ok = Accounts.deliver_notification(user.id, %{count: 0, total: Decimal.new(0), alert: true})

assert %ApiToken{apns_token: nil} = Repo.get(ApiToken, api_token.id)
end

test "retries an APNs failure that is not the device's fault", %{user: user} do
expect(TeslaMock, :call, fn _env, _opts ->
TeslaHelper.response(status: 503, body: %{"reason" => "ServiceUnavailable"})
end)

assert {:error, _reason} =
Accounts.deliver_notification(user.id, %{count: 0, total: Decimal.new(0), alert: true})
end

test "sends nothing to a user with no registered device" do
{:ok, other} =
Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"})

assert :ok = Accounts.deliver_notification(other.id, %{count: 0, total: Decimal.new(0), alert: true})
end

test "does nothing on a machine with no signing key", %{user: user} do
configured = Application.get_env(:spendable, Apns)

on_exit(fn -> Application.put_env(:spendable, Apns, configured) end)

Application.put_env(:spendable, Apns, Keyword.delete(configured, :private_key))

assert :ok = Accounts.deliver_notification(user.id, %{count: 0, total: Decimal.new(0), alert: true})
end
end
19 changes: 19 additions & 0 deletions lib/spendable/accounts/actions/notify_user.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
defmodule Spendable.Accounts.Actions.NotifyUser do
@moduledoc false

alias Spendable.Accounts.Jobs.SendNotification
alias Spendable.Scope

@doc """
Queues one notification for a user, whatever it took to produce it - a sync that found fifty
charges is still one push.

`alert` false sends the silent half only: the app is told the sync finished without the user
being told anything.
"""
def notify_user(%Scope{user: %{id: user_id}}, %{count: count, total: total, alert: alert}) do
%{user_id: user_id, count: count, total: Decimal.to_string(total), alert: alert}
|> SendNotification.new()
|> Oban.insert()
end
end
30 changes: 30 additions & 0 deletions lib/spendable/accounts/actions/notify_user_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
defmodule Spendable.Accounts.Actions.NotifyUserTest do
use Spendable.DataCase, async: true

alias Spendable.Accounts
alias Spendable.Accounts.Jobs.SendNotification
alias Spendable.Scope

setup do
{:ok, user} =
Accounts.upsert_user_from_oauth(%{external_id: Ecto.UUID.generate(), provider: "google"})

%{scope: Scope.for_user(user)}
end

test "queues one notification for the user", %{scope: scope} do
{:ok, _job} =
Accounts.notify_user(scope, %{count: 3, total: Decimal.new("-84.21"), alert: true})

assert_enqueued(
worker: SendNotification,
args: %{user_id: scope.user.id, count: 3, total: "-84.21", alert: true}
)
end

test "queues a silent one", %{scope: scope} do
{:ok, _job} = Accounts.notify_user(scope, %{count: 0, total: Decimal.new(0), alert: false})

assert_enqueued(worker: SendNotification, args: %{user_id: scope.user.id, alert: false})
end
end
Loading
Loading