Skip to content

Commit 5324a28

Browse files
authored
Merge pull request code-corps#859 from code-corps/858-associate-connected-user-with-installations
Associate connected user with installations
2 parents 761efd5 + d8340b1 commit 5324a28

8 files changed

Lines changed: 138 additions & 142 deletions

File tree

lib/code_corps/github/adapters/user.ex

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,22 @@ defmodule CodeCorps.GitHub.Adapters.User do
77
@mapping [
88
{:github_avatar_url, ["avatar_url"]},
99
{:github_id, ["id"]},
10-
{:github_username, ["login"]}
10+
{:github_username, ["login"]},
11+
{:email, ["email"]}
1112
]
1213

14+
@doc ~S"""
15+
Converts a Github user payload into a map of attributes suitable for creating
16+
or updating a `CodeCorps.User`
17+
18+
Any nil values are removed here, since we do not want to, for example, delete
19+
an existing email just because the github payload doesn't have that data.
20+
"""
1321
@spec from_github_user(map) :: map
1422
def from_github_user(%{} = payload) do
15-
payload |> CodeCorps.Adapter.MapTransformer.transform(@mapping)
23+
payload
24+
|> CodeCorps.Adapter.MapTransformer.transform(@mapping)
25+
|> Enum.reject(fn {_, v} -> is_nil(v) end)
26+
|> Map.new
1627
end
1728
end

lib/code_corps/github/oauth.ex

Lines changed: 0 additions & 42 deletions
This file was deleted.

lib/code_corps/github/user.ex

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,85 @@
11
defmodule CodeCorps.GitHub.User do
22
@moduledoc """
33
Used to perform user actions on the github API
4-
Also defines a GitHub.User struct
54
"""
6-
defstruct [:avatar_url, :email, :id, :login]
75

8-
@type t :: %__MODULE__{}
6+
alias CodeCorps.{GitHub, GithubAppInstallation, Repo, User}
7+
alias CodeCorps.GitHub.Adapters.User, as: UserAdapter
8+
alias Ecto.{Changeset, Multi}
99

10-
alias CodeCorps.GitHub
10+
import Ecto.Query
1111

1212
@single_endpoint "user"
1313

14-
def new() do
14+
@doc """
15+
POSTs `code` and `state` to GitHub to receive an OAuth token,
16+
then associates the given user with that OAuth token.
1517
18+
Also associates any orphaned `GithubAppInstallation` records matching their
19+
`sender_github_id` field with the user's `github_id`
20+
21+
Returns one of the following:
22+
23+
- `{:ok, %CodeCorps.User{}}`
24+
- `{:error, %Ecto.Changeset{}}`
25+
- `{:error, GitHub.api_error_struct}`
26+
"""
27+
@spec connect(User.t, String.t, String.t) ::
28+
{:ok, User.t} | {:error, Changeset.t} | {:error, GitHub.api_error_struct}
29+
def connect(%User{} = user, code, state) do
30+
with {:ok, %{"access_token" => access_token}} <- GitHub.user_access_token_request(code, state),
31+
{:ok, %{} = user_payload} <- access_token |> GitHub.User.me()
32+
do
33+
user |> do_connect(user_payload, access_token)
34+
else
35+
{:error, error} -> {:error, error}
36+
end
1637
end
1738

39+
@spec do_connect(User.t, map, String.t) :: {:ok, User.t} | {:error, Changeset.t}
40+
defp do_connect(%User{} = user, %{} = user_payload, access_token)
41+
when is_binary(access_token) do
42+
43+
changeset =
44+
user
45+
|> Changeset.change(user_payload |> UserAdapter.from_github_user)
46+
|> Changeset.put_change(:github_auth_token, access_token)
47+
|> Changeset.validate_required([:github_auth_token, :github_avatar_url, :github_id, :github_username])
48+
49+
multi =
50+
Multi.new
51+
|> Multi.update(:user, changeset)
52+
|> Multi.run(:installations, fn %{user: %User{} = user} -> user |> associate_installations() end)
53+
54+
case Repo.transaction(multi) do
55+
{:ok, %{user: %User{} = user, installations: installations}} ->
56+
{:ok, user |> Map.put(:github_app_installations, installations)}
57+
{:error, :user, %Changeset{} = changeset, _actions_done} ->
58+
{:error, changeset}
59+
end
60+
end
61+
62+
63+
@spec associate_installations(User.t) :: {:ok, list(GithubAppInstallation.t)}
64+
defp associate_installations(%User{id: user_id, github_id: github_id}) do
65+
updates = [set: [user_id: user_id]]
66+
update_options = [returning: true]
67+
68+
GithubAppInstallation
69+
|> where([i], i.sender_github_id == ^github_id)
70+
|> where([i], is_nil(i.user_id))
71+
|> Repo.update_all(updates, update_options)
72+
|> (fn {_count, installations} -> {:ok, installations} end).()
73+
end
74+
75+
@doc ~S"""
76+
Requests the currently authenticated user payload from github
77+
"""
78+
@spec me(String.t, Keyword.t) :: {:ok, map} | {:error, GitHub.api_error_struct}
1879
def me(access_token, opts \\ []) do
1980
case GitHub.Request.retrieve(@single_endpoint, opts ++ [access_token: access_token]) do
20-
{:ok, %{"error" => error}} ->
21-
{:error, error}
22-
{:ok, %{"avatar_url" => avatar_url, "email" => email, "id" => id, "login" => login}} ->
23-
{:ok, %__MODULE__{avatar_url: avatar_url, email: email, id: id, login: login}}
81+
{:ok, %{"error" => error}} -> {:error, error}
82+
{:ok, %{} = user_payload} -> {:ok, user_payload}
2483
end
2584
end
2685
end

test/lib/code_corps/github/oauth_test.exs

Lines changed: 0 additions & 68 deletions
This file was deleted.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
defmodule CodeCorps.GitHub.UserTest do
2+
@moduledoc false
3+
4+
use CodeCorps.ModelCase
5+
use CodeCorps.GitHubCase
6+
7+
alias CodeCorps.{GitHub, GithubAppInstallation, User}
8+
9+
@user_data %{
10+
"avatar_url" => "foo_url",
11+
"email" => "foo_email",
12+
"id" => 123,
13+
"login" => "foo_login"
14+
}
15+
16+
@token_data %{"access_token" => "foo_auth_token"}
17+
18+
describe "connect/2" do
19+
@tag bypass: %{"/user" => {200, @user_data}, "/" => {200, @token_data}}
20+
test "posts to github, associates user and installations, returns updated user" do
21+
user = insert(:user)
22+
23+
# 3 test installations
24+
# this one should associate, because it's orphaned and matches github id
25+
installation_1 = insert(:github_app_installation, user: nil, sender_github_id: 123)
26+
# this one matches github id, but is not orphaned, so should not associate
27+
installation_2 = insert(:github_app_installation, sender_github_id: 123)
28+
# this one is orphaned, but does not match github id, should not associate
29+
installation_3 = insert(:github_app_installation, user: nil, sender_github_id: 234)
30+
31+
{:ok, %User{} = returned_user} = GitHub.User.connect(user, "foo_code", "foo_state")
32+
33+
assert returned_user.id == user.id
34+
assert returned_user.github_auth_token == "foo_auth_token"
35+
assert returned_user.github_avatar_url == "foo_url"
36+
assert returned_user.email == "foo_email"
37+
assert returned_user.github_id == 123
38+
assert returned_user.github_username == "foo_login"
39+
40+
assert Enum.count(returned_user.github_app_installations) == 1
41+
42+
assert Repo.get(GithubAppInstallation, installation_1.id).user_id == returned_user.id
43+
refute Repo.get(GithubAppInstallation, installation_2.id).user_id == returned_user.id
44+
refute Repo.get(GithubAppInstallation, installation_3.id).user_id == returned_user.id
45+
end
46+
47+
@error_data %{"error" => "Not Found"}
48+
49+
@tag bypass: %{"/" => {404, @error_data}}
50+
test "posts to github, returns error if reply is not ok" do
51+
user = insert(:user)
52+
error = GitHub.APIError.new({404, %{"message" => "{\"error\":\"Not Found\"}"}})
53+
assert {:error, error} == GitHub.User.connect(user, "foo_code", "foo_state")
54+
end
55+
end
56+
end

test/models/user_test.exs

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -140,20 +140,6 @@ defmodule CodeCorps.UserTest do
140140
end
141141
end
142142

143-
describe "github_association_changeset" do
144-
test "should cast github_auth_token" do
145-
user = insert(:user)
146-
changeset = user |> User.github_association_changeset(%{github_auth_token: "foo_token", github_avatar_url: "foo_url", github_id: 123, github_username: "foo_username"})
147-
assert changeset.valid?
148-
assert changeset.changes.github_auth_token == "foo_token"
149-
end
150-
test "github_auth_token should be required" do
151-
user = insert(:user)
152-
changeset = user |> User.github_association_changeset(%{})
153-
refute changeset.valid?
154-
end
155-
end
156-
157143
test "reset_password_changeset with valid passwords" do
158144
changeset = User.reset_password_changeset(%User{}, %{password: "foobar", password_confirmation: "foobar"})
159145
assert changeset.valid?

web/controllers/user_controller.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ defmodule CodeCorps.UserController do
4040
"""
4141
def github_oauth(conn, %{"code" => code, "state" => state}) do
4242
current_user = Guardian.Plug.current_resource(conn)
43-
with {:ok, user} <- GitHub.OAuth.connect(current_user, code, state)
43+
with {:ok, user} <- GitHub.User.connect(current_user, code, state)
4444
do
4545
conn |> render("show.json-api", data: user)
4646
else

web/models/user.ex

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,12 +103,6 @@ defmodule CodeCorps.User do
103103
|> apply_state_transition(struct)
104104
end
105105

106-
def github_association_changeset(struct, params) do
107-
struct
108-
|> cast(params, [:github_auth_token, :github_avatar_url, :github_email, :github_id, :github_username])
109-
|> validate_required([:github_auth_token, :github_avatar_url, :github_id, :github_username])
110-
end
111-
112106
def reset_password_changeset(struct, params) do
113107
struct
114108
|> cast(params, [:password, :password_confirmation])

0 commit comments

Comments
 (0)