• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

source-academy / backend / e0330f2cf38b2d8af12bffd20f4cac2158d607fc-PR-1236

31 Mar 2025 09:12AM UTC coverage: 19.982% (-73.6%) from 93.607%
e0330f2cf38b2d8af12bffd20f4cac2158d607fc-PR-1236

Pull #1236

github

RichDom2185
Redate migrations to maintain total ordering
Pull Request #1236: Added Exam mode

12 of 57 new or added lines in 8 files covered. (21.05%)

2430 existing lines in 97 files now uncovered.

671 of 3358 relevant lines covered (19.98%)

3.03 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

11.69
/lib/cadet/devices/devices.ex
1
defmodule Cadet.Devices do
2
  @moduledoc """
3
  Contains domain logic for remote execution devices.
4
  """
5
  use Cadet, :context
6

7
  import Ecto.Query
8

9
  alias Cadet.AwsHelper
10
  alias Cadet.Accounts.User
11
  alias Cadet.Devices.{Device, DeviceRegistration}
12
  alias ExAws.STS
13

14
  @spec get_user_registrations(integer | String.t() | User.t()) :: [DeviceRegistration.t()]
15
  def get_user_registrations(%User{id: user_id}) do
UNCOV
16
    get_user_registrations(user_id)
×
17
  end
18

19
  def get_user_registrations(user_id) when is_ecto_id(user_id) do
20
    DeviceRegistration
21
    |> where(user_id: ^user_id)
UNCOV
22
    |> preload(:device)
×
UNCOV
23
    |> Repo.all()
×
24
  end
25

26
  @spec get_user_registration(integer | String.t() | User.t(), integer | String.t()) ::
27
          DeviceRegistration.t() | nil
28
  def get_user_registration(%User{id: user_id}, id) do
UNCOV
29
    get_user_registration(user_id, id)
×
30
  end
31

32
  def get_user_registration(user_id, id)
33
      when is_ecto_id(user_id) and is_ecto_id(id) do
34
    DeviceRegistration
35
    |> preload(:device)
UNCOV
36
    |> where(user_id: ^user_id)
×
UNCOV
37
    |> Repo.get(id)
×
38
  end
39

40
  @spec delete_registration(DeviceRegistration.t()) :: {:ok, DeviceRegistration.t()}
41
  def delete_registration(registration = %DeviceRegistration{}) do
UNCOV
42
    Repo.delete(registration)
×
43
  end
44

45
  @spec rename_registration(DeviceRegistration.t(), String.t()) ::
46
          {:ok, DeviceRegistration.t()} | {:error, Ecto.Changeset.t()}
47
  def rename_registration(registration = %DeviceRegistration{}, title) do
48
    registration
49
    |> DeviceRegistration.changeset(%{title: title})
UNCOV
50
    |> Repo.update()
×
51
  end
52

53
  @spec get_device(binary | integer) :: Device.t() | nil
54
  def get_device(device_id) when is_integer(device_id) do
UNCOV
55
    Repo.get(Device, device_id)
×
56
  end
57

58
  def get_device(device_secret) when is_binary(device_secret) do
59
    Device
60
    |> where(secret: ^device_secret)
6✔
61
    |> Repo.one()
6✔
62
  end
63

64
  @spec register(binary, binary, binary, integer | User.t()) ::
65
          {:ok, DeviceRegistration.t()} | {:error, Ecto.Changeset.t() | :conflicting_device}
66
  def register(title, type, secret, %User{id: user_id}) do
UNCOV
67
    register(title, type, secret, user_id)
×
68
  end
69

70
  def register(title, type, secret, user_id)
71
      when is_binary(title) and is_binary(type) and is_binary(secret) and is_integer(user_id) do
UNCOV
72
    with {:ok, device} <- maybe_insert_device(type, secret),
×
UNCOV
73
         {:ok, registration} <-
×
74
           %DeviceRegistration{}
UNCOV
75
           |> DeviceRegistration.changeset(%{user_id: user_id, device_id: device.id, title: title})
×
76
           |> Repo.insert() do
77
      {:ok, registration |> Repo.preload(:device)}
78
    end
79
  end
80

81
  @spec get_device_key_cert(binary | integer | Device.t()) ::
82
          {:ok, {String.t(), String.t()}}
83
          | {:error,
84
             :no_such_device
85
             | {:http_error, number, map}
86
             | Jason.DecodeError.t()
87
             | Jason.EncodeError.t()
88
             | Ecto.Changeset.t()}
89
  def get_device_key_cert(%Device{client_key: key, client_cert: cert})
2✔
90
      when not is_nil(key) and not is_nil(cert) do
91
    {:ok, {key, cert}}
92
  end
93

94
  def get_device_key_cert(device = %Device{id: id, client_key: nil, client_cert: nil}) do
UNCOV
95
    with {:ok, {key, cert}} <- create_device_key_cert(id),
×
UNCOV
96
         {:ok, _} <-
×
97
           device |> Device.changeset(%{client_key: key, client_cert: cert}) |> Repo.update() do
98
      {:ok, {key, cert}}
99
    end
100
  end
101

102
  def get_device_key_cert(device_id_or_secret)
103
      when is_integer(device_id_or_secret) or is_binary(device_id_or_secret) do
104
    case get_device(device_id_or_secret) do
4✔
105
      nil -> {:error, :no_such_device}
2✔
106
      device -> get_device_key_cert(device)
2✔
107
    end
108
  end
109

110
  @spec maybe_insert_device(binary, binary) ::
111
          {:ok, Device.t()} | {:error, Ecto.Changeset.t() | :conflicting_device}
112
  defp maybe_insert_device(type, secret) do
UNCOV
113
    case get_device(secret) do
×
114
      device = %Device{} ->
UNCOV
115
        if(device.type == type and device.secret == secret,
×
116
          do: {:ok, device},
117
          else: {:error, :conflicting_device}
118
        )
119

120
      nil ->
UNCOV
121
        %Device{} |> Device.changeset(%{type: type, secret: secret}) |> Repo.insert()
×
122
    end
123
  end
124

125
  @spec create_device_key_cert(integer) ::
126
          {:error,
127
           :no_such_device
128
           | {:http_error, number, map}
129
           | Jason.DecodeError.t()
130
           | Jason.EncodeError.t()}
131
          | {:ok, {String.t(), String.t()}}
132
  defp create_device_key_cert(device_id) do
UNCOV
133
    thing_name = get_thing_name(device_id)
×
134

UNCOV
135
    with {:make_cert, {:ok, %{body: cert}}} <-
×
136
           {:make_cert, do_awsiot_request(:post, "/keys-and-certificate?setAsActive=true")},
UNCOV
137
         {:attach_thing, {:ok, _}} <-
×
138
           {:attach_thing, maybe_create_thing_and_attach_cert(thing_name, cert["certificateArn"])} do
139
      {:ok, {cert["keyPair"]["PrivateKey"], cert["certificatePem"]}}
140
    else
UNCOV
141
      {_, e = {:error, _}} -> e
×
142
    end
143
  end
144

145
  defp maybe_create_thing_and_attach_cert(thing_name, cert_arn, retry \\ false) do
UNCOV
146
    case {do_awsiot_request(:put, "/things/#{thing_name}/principals", [
×
147
            {"x-amzn-principal", cert_arn}
148
            # hack so that we can differentiate these two requests in ExVCR :/
UNCOV
149
            | if(Cadet.Env.env() == :test,
×
150
                do: [{"x-different-request", Atom.to_string(retry)}],
151
                else: []
152
              )
153
          ]), retry} do
154
      {{:error, {:http_error, 404, _}}, false} ->
UNCOV
155
        with {:ok, _} <- do_awsiot_json_request(:post, "/things/#{thing_name}", %{}),
×
UNCOV
156
             {:ok, _} <-
×
157
               do_awsiot_json_request(:put, "/thing-groups/addThingToThingGroup", %{
158
                 "thingName" => thing_name,
159
                 "thingGroupName" => aws_thing_group()
160
               }) do
UNCOV
161
          maybe_create_thing_and_attach_cert(thing_name, cert_arn, true)
×
162
        end
163

164
      {result, _} ->
UNCOV
165
        result
×
166
    end
167
  end
168

169
  @spec get_device_ws_endpoint(
170
          binary | integer | Device.t(),
171
          User.t(),
172
          [{:datetime, :calendar.datetime()}]
173
        ) ::
174
          {:ok, %{}}
175
          | {:error,
176
             :no_such_device
177
             | {:http_error, number, map}
178
             | Jason.DecodeError.t()
179
             | Jason.EncodeError.t()}
UNCOV
180
  def get_device_ws_endpoint(device_or_id_or_secret, user, opts \\ [])
×
181

182
  def get_device_ws_endpoint(device_id_or_secret, user = %User{}, opts)
183
      when is_integer(device_id_or_secret) or is_binary(device_id_or_secret) do
UNCOV
184
    case get_device(device_id_or_secret) do
×
185
      nil -> {:error, :no_such_device}
×
UNCOV
186
      device -> get_device_ws_endpoint(device, user, opts)
×
187
    end
188
  end
189

190
  def get_device_ws_endpoint(%Device{id: device_id}, %User{id: user_id}, opts) do
UNCOV
191
    case Keyword.get(config(), :ws_endpoint_address) do
×
192
      nil ->
UNCOV
193
        with {:ok, address} <- get_endpoint_address(),
×
UNCOV
194
             {:ok, %{body: creds}} <- get_temporary_token(device_id, user_id) do
×
UNCOV
195
          uri = URI.to_string(%URI{scheme: "wss", host: address, path: "/mqtt"})
×
UNCOV
196
          region = Application.fetch_env!(:ex_aws, :region)
×
197

UNCOV
198
          {:ok, signed_url} =
×
199
            ExAws.Auth.presigned_url(
200
              :get,
201
              uri,
202
              :iotdevicegateway,
UNCOV
203
              Keyword.get(opts, :datetime) || :calendar.universal_time(),
×
204
              %{
205
                region: region,
UNCOV
206
                access_key_id: creds.access_key_id,
×
UNCOV
207
                secret_access_key: creds.secret_access_key
×
208
              },
209
              300,
210
              [],
211
              ''
212
            )
213

214
          # ExAws includes the session token in the signed payload and doesn't allow
215
          # you not to do so. Some AWS services require it to be in the signed
216
          # payload, some don't. This one doesn't, so.. we manually append the
217
          # security token. *sigh*
218
          {:ok,
219
           %{
220
             endpoint:
UNCOV
221
               "#{signed_url}&X-Amz-Security-Token=#{URI.encode_www_form(creds.session_token)}",
×
222
             thing_name: get_thing_name(device_id),
223
             client_name_prefix: get_ws_client_prefix(user_id)
224
           }}
225
        end
226

UNCOV
227
      address ->
×
228
        {:ok,
229
         %{
230
           endpoint: address,
231
           thing_name: get_thing_name(device_id),
232
           client_name_prefix: get_ws_client_prefix(user_id)
233
         }}
234
    end
235
  end
236

237
  def get_endpoint_address do
UNCOV
238
    case Keyword.get(config(), :endpoint_address) do
×
239
      nil ->
UNCOV
240
        case do_awsiot_request(:get, "/endpoint?endpointType=iot:Data-ATS") do
×
241
          {:ok, %{body: %{"endpointAddress" => address}}} ->
UNCOV
242
            new_config = Keyword.put(config(), :endpoint_address, address)
×
UNCOV
243
            Application.put_env(:cadet, :remote_execution, new_config)
×
244

245
            {:ok, address}
246

247
          error = {:error, _} ->
UNCOV
248
            error
×
249
        end
250

UNCOV
251
      address ->
×
252
        {:ok, address}
253
    end
254
  end
255

256
  defp get_temporary_token(device_id, user_id) do
UNCOV
257
    r =
×
258
      STS.assume_role(
259
        aws_client_role_arn(),
UNCOV
260
        "#{aws_thing_prefix()}-d#{device_id}-u#{user_id}"
×
261
      )
262

UNCOV
263
    policy =
×
264
      Jason.encode!(%{
265
        "Version" => "2012-10-17",
266
        "Statement" => [
267
          %{
268
            "Sid" => "Stmt0",
269
            "Effect" => "Allow",
270
            "Action" => [
271
              "iot:Receive",
272
              "iot:Subscribe",
273
              "iot:Connect",
274
              "iot:Publish"
275
            ],
276
            "Resource" => [
UNCOV
277
              "arn:aws:iot:*:*:topicfilter/#{get_thing_name(device_id)}/*",
×
UNCOV
278
              "arn:aws:iot:*:*:topic/#{get_thing_name(device_id)}/*",
×
UNCOV
279
              "arn:aws:iot:*:*:client/#{get_ws_client_prefix(user_id)}*"
×
280
            ]
281
          }
282
        ]
283
      })
284

285
    r
UNCOV
286
    |> Map.update!(:params, &Map.put(&1, "Policy", policy))
×
UNCOV
287
    |> ExAws.request()
×
288
  end
289

290
  def get_thing_name(device_id) do
291
    "#{aws_thing_prefix()}:#{device_id}"
2✔
292
  end
293

294
  defp do_awsiot_request(method, path, headers \\ []) do
UNCOV
295
    do_awsiot_operation(
×
296
      %ExAws.Operation.RestQuery{
297
        http_method: method,
298
        path: path
299
      },
300
      headers
301
    )
302
  end
303

304
  defp do_awsiot_json_request(method, path, body, headers \\ []) do
UNCOV
305
    with {:ok, body} <- Jason.encode(body) do
×
UNCOV
306
      do_awsiot_operation(
×
307
        %ExAws.Operation.RestQuery{
308
          http_method: method,
309
          path: path,
310
          body: body
311
        },
312
        [{"Content-Type", "application/json"} | headers]
313
      )
314
    end
315
  end
316

317
  defp do_awsiot_operation(request = %ExAws.Operation.RestQuery{}, headers) do
318
    request
319
    |> Map.merge(%{parser: &decode_awsiot_response/2, service: :iot})
320
    # ex_aws bug, sigh
UNCOV
321
    |> AwsHelper.request(headers, service_override: :"execute-api")
×
322
  end
323

324
  defp decode_awsiot_response({:ok, payload}, _) do
UNCOV
325
    case String.trim(payload.body) do
×
326
      "" ->
×
327
        {:ok, nil}
328

329
      trimmed_body ->
UNCOV
330
        with {:ok, body} <- Jason.decode(trimmed_body) do
×
331
          {:ok, Map.put(payload, :body, body)}
332
        end
333
    end
334
  end
335

336
  defp decode_awsiot_response(e = {:error, _}, _) do
UNCOV
337
    e
×
338
  end
339

340
  defp get_ws_client_prefix(user_id) do
UNCOV
341
    "#{aws_thing_prefix()}-u#{user_id}-"
×
342
  end
343

344
  defp aws_thing_prefix do
345
    Keyword.fetch!(config(), :thing_prefix)
2✔
346
  end
347

348
  defp aws_thing_group do
UNCOV
349
    Keyword.fetch!(config(), :thing_group)
×
350
  end
351

352
  defp aws_client_role_arn do
UNCOV
353
    Keyword.fetch!(config(), :client_role_arn)
×
354
  end
355

356
  defp config do
357
    Application.fetch_env!(:cadet, :remote_execution)
2✔
358
  end
359
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc