analytics/test/plausible_web/controllers/api/external_sites_controller_test.exs
Adrian Gruntkowski 790984e1ad
Refactor Sites and Stats API authorization logic (#4297)
* Refactor and unify auth plugs for Stats and Sites APIs

* Expose get site Sites API endpoint to all API keys

* Test the new plug

* Add test for endpoint with modified scope

* Fix typos

Co-authored-by: hq1 <hq@mtod.org>

* Rename plug for consistency (h/t @aerosol)

---------

Co-authored-by: hq1 <hq@mtod.org>
2024-07-02 15:09:23 +02:00

574 lines
17 KiB
Elixir

defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
use Plausible
use PlausibleWeb.ConnCase, async: false
use Plausible.Repo
on_ee do
setup :create_user
setup %{conn: conn, user: user} do
api_key = insert(:api_key, user: user, scopes: ["sites:provision:*"])
conn = Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key.key}")
{:ok, api_key: api_key, conn: conn}
end
describe "POST /api/v1/sites" do
test "can create a site", %{conn: conn} do
conn =
post(conn, "/api/v1/sites", %{
"domain" => "some-site.domain",
"timezone" => "Europe/Tallinn"
})
assert json_response(conn, 200) == %{
"domain" => "some-site.domain",
"timezone" => "Europe/Tallinn"
}
end
test "timezone is validated", %{conn: conn} do
conn =
post(conn, "/api/v1/sites", %{
"domain" => "some-site.domain",
"timezone" => "d"
})
assert json_response(conn, 400) == %{
"error" => "timezone: is invalid"
}
end
test "timezone defaults to Etc/UTC", %{conn: conn} do
conn =
post(conn, "/api/v1/sites", %{
"domain" => "some-site.domain"
})
assert json_response(conn, 200) == %{
"domain" => "some-site.domain",
"timezone" => "Etc/UTC"
}
end
test "domain is required", %{conn: conn} do
conn = post(conn, "/api/v1/sites", %{})
assert json_response(conn, 400) == %{
"error" => "domain: can't be blank"
}
end
test "accepts international domain names", %{conn: conn} do
["müllers-café.test", "音乐.cn", "до.101домен.рф/pages"]
|> Enum.each(fn idn_domain ->
conn = post(conn, "/api/v1/sites", %{"domain" => idn_domain})
assert %{"domain" => ^idn_domain} = json_response(conn, 200)
end)
end
test "validates uri breaking domains", %{conn: conn} do
["quero:café.test", "h&llo.test", "iamnotsur&about?this.com"]
|> Enum.each(fn bad_domain ->
conn = post(conn, "/api/v1/sites", %{"domain" => bad_domain})
assert %{"error" => error} = json_response(conn, 400)
assert error =~ "domain: must not contain URI reserved characters"
end)
end
test "does not allow creating more sites than the limit", %{conn: conn, user: user} do
insert_list(50, :site, members: [user])
conn =
post(conn, "/api/v1/sites", %{
"domain" => "some-site.domain",
"timezone" => "Europe/Tallinn"
})
assert json_response(conn, 402) == %{
"error" =>
"Your account has reached the limit of 10 sites. To unlock more sites, please upgrade your subscription."
}
end
test "cannot access with a bad API key scope", %{conn: conn, user: user} do
api_key = insert(:api_key, user: user, scopes: ["stats:read:*"])
conn =
conn
|> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}")
|> post("/api/v1/sites", %{"site" => %{"domain" => "domain.com"}})
assert json_response(conn, 401) == %{
"error" =>
"Invalid API key. Please make sure you're using a valid API key with access to the resource you've requested."
}
end
end
describe "DELETE /api/v1/sites/:site_id" do
setup :create_new_site
test "delete a site by its domain", %{conn: conn, site: site} do
conn = delete(conn, "/api/v1/sites/" <> site.domain)
assert json_response(conn, 200) == %{"deleted" => true}
end
test "delete a site by its old domain after domain change", %{conn: conn, site: site} do
old_domain = site.domain
new_domain = "new.example.com"
Plausible.Site.Domain.change(site, new_domain)
conn = delete(conn, "/api/v1/sites/" <> old_domain)
assert json_response(conn, 200) == %{"deleted" => true}
end
test "is 404 when site cannot be found", %{conn: conn} do
conn = delete(conn, "/api/v1/sites/foobar.baz")
assert json_response(conn, 404) == %{"error" => "Site could not be found"}
end
test "cannot delete a site that the user does not own", %{conn: conn, user: user} do
site = insert(:site, members: [])
insert(:site_membership, user: user, site: site, role: :admin)
conn = delete(conn, "/api/v1/sites/" <> site.domain)
assert json_response(conn, 404) == %{"error" => "Site could not be found"}
end
test "cannot access with a bad API key scope", %{conn: conn, site: site, user: user} do
api_key = insert(:api_key, user: user, scopes: ["stats:read:*"])
conn =
conn
|> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}")
|> delete("/api/v1/sites/" <> site.domain)
assert json_response(conn, 401) == %{
"error" =>
"Invalid API key. Please make sure you're using a valid API key with access to the resource you've requested."
}
end
end
describe "PUT /api/v1/sites/shared-links" do
setup :create_site
test "can add a shared link to a site", %{conn: conn, site: site} do
conn =
put(conn, "/api/v1/sites/shared-links", %{
site_id: site.domain,
name: "WordPress"
})
res = json_response(conn, 200)
assert res["name"] == "WordPress"
assert String.starts_with?(res["url"], "http://")
end
test "can add a shared link to a site using the old site id after domain change", %{
conn: conn,
site: site
} do
old_domain = site.domain
new_domain = "new.example.com"
Plausible.Site.Domain.change(site, new_domain)
conn =
put(conn, "/api/v1/sites/shared-links", %{
site_id: old_domain,
name: "WordPress"
})
res = json_response(conn, 200)
assert res["name"] == "WordPress"
assert String.starts_with?(res["url"], "http://")
end
test "is idempotent find or create op", %{conn: conn, site: site} do
conn =
put(conn, "/api/v1/sites/shared-links", %{
site_id: site.domain,
name: "WordPress"
})
%{"url" => url} = json_response(conn, 200)
conn =
put(conn, "/api/v1/sites/shared-links", %{
site_id: site.domain,
name: "WordPress"
})
assert %{"url" => ^url} = json_response(conn, 200)
end
test "returns 400 when site id missing", %{conn: conn} do
conn =
put(conn, "/api/v1/sites/shared-links", %{
name: "WordPress"
})
res = json_response(conn, 400)
assert res["error"] == "Parameter `site_id` is required to create a shared link"
end
test "returns 404 when site id is non existent", %{conn: conn} do
conn =
put(conn, "/api/v1/sites/shared-links", %{
name: "WordPress",
site_id: "bad"
})
res = json_response(conn, 404)
assert res["error"] == "Site could not be found"
end
test "returns 404 when api key owner does not have permissions to create a shared link", %{
conn: conn,
site: site,
user: user
} do
Repo.update_all(
from(sm in Plausible.Site.Membership,
where: sm.site_id == ^site.id and sm.user_id == ^user.id
),
set: [role: :viewer]
)
conn =
put(conn, "/api/v1/sites/shared-links", %{
site_id: site.domain,
name: "WordPress"
})
res = json_response(conn, 404)
assert res["error"] == "Site could not be found"
end
end
describe "PUT /api/v1/sites/goals" do
setup :create_site
test "can add a goal as event to a site", %{conn: conn, site: site} do
conn =
put(conn, "/api/v1/sites/goals", %{
site_id: site.domain,
goal_type: "event",
event_name: "Signup"
})
res = json_response(conn, 200)
assert res["goal_type"] == "event"
assert res["event_name"] == "Signup"
assert res["domain"] == site.domain
end
test "can add a goal as page to a site", %{conn: conn, site: site} do
conn =
put(conn, "/api/v1/sites/goals", %{
site_id: site.domain,
goal_type: "page",
page_path: "/signup"
})
res = json_response(conn, 200)
assert res["goal_type"] == "page"
assert res["page_path"] == "/signup"
assert res["domain"] == site.domain
end
test "can add a goal using old site_id after domain change", %{conn: conn, site: site} do
old_domain = site.domain
new_domain = "new.example.com"
Plausible.Site.Domain.change(site, new_domain)
conn =
put(conn, "/api/v1/sites/goals", %{
site_id: old_domain,
goal_type: "event",
event_name: "Signup"
})
res = json_response(conn, 200)
assert res["goal_type"] == "event"
assert res["event_name"] == "Signup"
assert res["domain"] == new_domain
end
test "is idempotent find or create op", %{conn: conn, site: site} do
conn =
put(conn, "/api/v1/sites/goals", %{
site_id: site.domain,
goal_type: "event",
event_name: "Signup"
})
%{"id" => goal_id} = json_response(conn, 200)
conn =
put(conn, "/api/v1/sites/goals", %{
site_id: site.domain,
goal_type: "event",
event_name: "Signup"
})
assert %{"id" => ^goal_id} = json_response(conn, 200)
end
test "returns 400 when site id missing", %{conn: conn} do
conn =
put(conn, "/api/v1/sites/goals", %{
goal_type: "event",
event_name: "Signup"
})
res = json_response(conn, 400)
assert res["error"] == "Parameter `site_id` is required to create a goal"
end
test "returns 404 when site id is non existent", %{conn: conn} do
conn =
put(conn, "/api/v1/sites/goals", %{
goal_type: "event",
event_name: "Signup",
site_id: "bad"
})
res = json_response(conn, 404)
assert res["error"] == "Site could not be found"
end
test "returns 404 when api key owner does not have permissions to create a goal", %{
conn: conn,
site: site,
user: user
} do
Repo.update_all(
from(sm in Plausible.Site.Membership,
where: sm.site_id == ^site.id and sm.user_id == ^user.id
),
set: [role: :viewer]
)
conn =
put(conn, "/api/v1/sites/goals", %{
site_id: site.domain,
goal_type: "event",
event_name: "Signup"
})
res = json_response(conn, 404)
assert res["error"] == "Site could not be found"
end
test "returns 400 when goal type missing", %{conn: conn, site: site} do
conn =
put(conn, "/api/v1/sites/goals", %{
site_id: site.domain,
event_name: "Signup"
})
res = json_response(conn, 400)
assert res["error"] == "Parameter `goal_type` is required to create a goal"
end
test "returns 400 when goal event name missing", %{conn: conn, site: site} do
conn =
put(conn, "/api/v1/sites/goals", %{
site_id: site.domain,
goal_type: "event"
})
res = json_response(conn, 400)
assert res["error"] == "Parameter `event_name` is required to create a goal"
end
test "returns 400 when goal page path missing", %{conn: conn, site: site} do
conn =
put(conn, "/api/v1/sites/goals", %{
site_id: site.domain,
goal_type: "page"
})
res = json_response(conn, 400)
assert res["error"] == "Parameter `page_path` is required to create a goal"
end
end
describe "DELETE /api/v1/sites/goals/:goal_id" do
setup :create_new_site
test "delete a goal by its id", %{conn: conn, site: site} do
conn =
put(conn, "/api/v1/sites/goals", %{
site_id: site.domain,
goal_type: "event",
event_name: "Signup"
})
%{"id" => goal_id} = json_response(conn, 200)
conn =
delete(conn, "/api/v1/sites/goals/#{goal_id}", %{
site_id: site.domain
})
assert json_response(conn, 200) == %{"deleted" => true}
end
test "delete a goal using old site_id after domain change", %{conn: conn, site: site} do
old_domain = site.domain
new_domain = "new.example.com"
Plausible.Site.Domain.change(site, new_domain)
conn =
put(conn, "/api/v1/sites/goals", %{
site_id: new_domain,
goal_type: "event",
event_name: "Signup"
})
%{"id" => goal_id} = json_response(conn, 200)
conn =
delete(conn, "/api/v1/sites/goals/#{goal_id}", %{
site_id: old_domain
})
assert json_response(conn, 200) == %{"deleted" => true}
end
test "is 404 when goal cannot be found", %{conn: conn, site: site} do
conn =
delete(conn, "/api/v1/sites/goals/0", %{
site_id: site.domain
})
assert json_response(conn, 404) == %{"error" => "Goal could not be found"}
end
test "cannot delete a goal belongs to a site that the user does not own", %{
conn: conn,
user: user
} do
site = insert(:site, members: [])
insert(:site_membership, user: user, site: site, role: :viewer)
conn =
delete(conn, "/api/v1/sites/goals/1", %{
site_id: site.domain
})
assert json_response(conn, 404) == %{"error" => "Site could not be found"}
end
test "cannot access with a bad API key scope", %{conn: conn, site: site, user: user} do
api_key = insert(:api_key, user: user, scopes: ["stats:read:*"])
conn =
conn
|> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}")
conn =
delete(conn, "/api/v1/sites/goals/1", %{
site_id: site.domain
})
assert json_response(conn, 401) == %{
"error" =>
"Invalid API key. Please make sure you're using a valid API key with access to the resource you've requested."
}
end
end
describe "GET /api/v1/sites/:site_id" do
setup :create_new_site
test "get a site by its domain", %{conn: conn, site: site} do
conn = get(conn, "/api/v1/sites/" <> site.domain)
assert json_response(conn, 200) == %{"domain" => site.domain, "timezone" => site.timezone}
end
test "get a site by old site_id after domain change", %{conn: conn, site: site} do
old_domain = site.domain
new_domain = "new.example.com"
Plausible.Site.Domain.change(site, new_domain)
conn = get(conn, "/api/v1/sites/" <> old_domain)
assert json_response(conn, 200) == %{"domain" => new_domain, "timezone" => site.timezone}
end
test "get a site with basic scope config", %{conn: conn, user: user, site: site} do
api_key = insert(:api_key, user: user, scopes: ["stats:read:*"])
conn =
conn
|> Plug.Conn.put_req_header("authorization", "Bearer #{api_key.key}")
|> get("/api/v1/sites/" <> site.domain)
assert json_response(conn, 200) == %{"domain" => site.domain, "timezone" => site.timezone}
end
test "is 404 when site cannot be found", %{conn: conn} do
conn = get(conn, "/api/v1/sites/foobar.baz")
assert json_response(conn, 404) == %{"error" => "Site could not be found"}
end
end
describe "PUT /api/v1/sites/:site_id" do
setup :create_new_site
test "can change domain name", %{conn: conn, site: site} do
old_domain = site.domain
assert old_domain != "new.example.com"
conn =
put(conn, "/api/v1/sites/#{old_domain}", %{
"domain" => "new.example.com"
})
assert json_response(conn, 200) == %{
"domain" => "new.example.com",
"timezone" => "UTC"
}
site = Repo.reload!(site)
assert site.domain == "new.example.com"
assert site.domain_changed_from == old_domain
end
test "can't make a no-op change", %{conn: conn, site: site} do
conn =
put(conn, "/api/v1/sites/#{site.domain}", %{
"domain" => site.domain
})
assert json_response(conn, 400) == %{
"error" => "domain: New domain must be different than the current one"
}
end
test "domain parameter is required", %{conn: conn, site: site} do
conn = put(conn, "/api/v1/sites/#{site.domain}", %{})
assert json_response(conn, 400) == %{
"error" => "domain: can't be blank"
}
end
end
end
end