Snippet integration verification (#4106)

* Allow running browserless.io locally

* Compile tailwind classes based on extra/ too

* Add browserless runtime configuration

* Ignore verification events on ingestion

* Improve extracting HTML text in tests

* Update dependencies

- Floki will be used on production to parse site contents
- Req will be used to handle redundant stuff like retrying etc.

* Add shuttle SVG to generic components

Later on we'll use it to indicate verification errors

* Connect live socket & allow skipping awaiting the first pageview

* Connect live socket in general settings

* Implement verification checks & diagnostics

* Stub remote services with Req for testing

* Change snippet screen copy

* Update tracker script, so that:

1. headless browsers aren't ignored if `window.__plausible` is defined
2. callback optionally supplies the event response HTTP status

This will be later used to check whether the server acknowledged
the verification event.

* Implement LiveView verification UI

* Embed the verification UIs into settings and onboarding

* Implement browserless puppeteer verification script

It:
 - tries to visit the site
 - defines window.__plausible, so the tracker doesn't ignore test events
 - sends a verification event and instruments the callback
 - awaits the callback to fire and returns the result

* Improve diagnostics for CSP

Only report CSP error if the snippet is already found

* Put verification behind a feature flag/env setting

* Contact Us hint only for Enterprise Edition

* For headless code, use JS context instead of EEx interpolation

* Update diagnostics test with WordPress scenarios

* Shorten exception/throw interception

* Rename test

* Tidy up

* Bust URL always on headless check

* Update moduledoc

* Detect official Plausible WordPress Plugin

and act accordingly on diagnostics interoperation

* Stop using 'rating' in favour of 'interpretation'

* Only report CSP error if no proxy is likely

* Update CHANGELOG

* Allow event-* attributes on snippet elements

* Improve naive GTM detection, not to confuse it with GA4

* Update lib/plausible/verification.ex

Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>

* Update test/plausible/site/verification/checks_test.exs

Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>

* s/perform_wrapped/perform_safe

* Update lib/plausible/verification/checks/installation.ex

Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>

* Remove garbage

---------

Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>
This commit is contained in:
hq1 2024-05-23 15:00:50 +02:00 committed by GitHub
parent 5881f1c1bf
commit c81cb16933
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 2838 additions and 34 deletions

View File

@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file.
### Added
- Snippet integration verification
### Removed
### Changed

View File

@ -37,6 +37,9 @@ postgres-prod: ## Start a container with the same version of postgres as the one
postgres-stop: ## Stop and remove the postgres container
docker stop plausible_db && docker rm plausible_db
browserless:
docker run -e "TOKEN=dummy_token" -p 3000:3000 --network host ghcr.io/browserless/chromium
minio: ## Start a transient container with a recent version of minio (s3)
docker run -d --rm -p 10000:10000 -p 10001:10001 --name plausible_minio minio/minio server /data --address ":10000" --console-address ":10001"
while ! docker exec plausible_minio mc alias set local http://localhost:10000 minioadmin minioadmin; do sleep 1; done

View File

@ -5,7 +5,9 @@ module.exports = {
content: [
"./js/**/*.js",
"../lib/*_web.ex",
"../lib/*_web/**/*.*ex"
"../lib/*_web/**/*.*ex",
"../extra/*_web.ex",
"../extra/*_web/**/*.*ex"
],
safelist: [
// PlausibleWeb.StatsView.stats_container_class/1 uses this class

View File

@ -28,3 +28,5 @@ S3_REGION=us-east-1
S3_ENDPOINT=http://localhost:10000
S3_EXPORTS_BUCKET=dev-exports
S3_IMPORTS_BUCKET=dev-imports
VERIFICATION_ENABLED=true

View File

@ -701,6 +701,15 @@ config :plausible, Plausible.PromEx,
grafana: :disabled,
metrics_server: :disabled
config :plausible, Plausible.Verification,
enabled?:
get_var_from_path_or_env(config_dir, "VERIFICATION_ENABLED", "false")
|> String.to_existing_atom()
config :plausible, Plausible.Verification.Checks.Installation,
token: get_var_from_path_or_env(config_dir, "BROWSERLESS_TOKEN", "dummy_token"),
endpoint: get_var_from_path_or_env(config_dir, "BROWSERLESS_ENDPOINT", "http://0.0.0.0:3000")
if not is_selfhost do
site_default_ingest_threshold =
case System.get_env("SITE_DEFAULT_INGEST_THRESHOLD") do

View File

@ -31,3 +31,13 @@ config :ex_money, api_module: Plausible.ExchangeRateMock
config :plausible, Plausible.Ingestion.Counters, enabled: false
config :plausible, Oban, testing: :manual
config :plausible, Plausible.Verification.Checks.FetchBody,
req_opts: [
plug: {Req.Test, Plausible.Verification.Checks.FetchBody}
]
config :plausible, Plausible.Verification.Checks.Installation,
req_opts: [
plug: {Req.Test, Plausible.Verification.Checks.Installation}
]

View File

@ -22,7 +22,8 @@ defmodule Plausible.Ingestion.Event.Revenue do
}
matching_goal.currency != revenue_source.currency ->
converted = Money.to_currency!(revenue_source, matching_goal.currency)
converted =
Money.to_currency!(revenue_source, matching_goal.currency)
%{
revenue_source_amount: Money.to_decimal(revenue_source),

View File

@ -21,6 +21,8 @@ defmodule Plausible.Ingestion.Event do
salts: nil,
changeset: nil
@verification_user_agent Plausible.Verification.user_agent()
@type drop_reason() ::
:bot
| :spam_referrer
@ -31,6 +33,7 @@ defmodule Plausible.Ingestion.Event do
| :site_country_blocklist
| :site_page_blocklist
| :site_hostname_allowlist
| :verification_agent
@type t() :: %__MODULE__{
domain: String.t() | nil,
@ -104,6 +107,7 @@ defmodule Plausible.Ingestion.Event do
defp pipeline() do
[
drop_verification_agent: &drop_verification_agent/1,
drop_datacenter_ip: &drop_datacenter_ip/1,
drop_shield_rule_hostname: &drop_shield_rule_hostname/1,
drop_shield_rule_page: &drop_shield_rule_page/1,
@ -167,6 +171,16 @@ defmodule Plausible.Ingestion.Event do
struct!(event, clickhouse_session_attrs: Map.merge(event.clickhouse_session_attrs, attrs))
end
defp drop_verification_agent(%__MODULE__{} = event) do
case event.request.user_agent do
@verification_user_agent ->
drop(event, :verification_agent)
_ ->
event
end
end
defp drop_datacenter_ip(%__MODULE__{} = event) do
case event.request.ip_classification do
"dc_ip" ->

View File

@ -0,0 +1,26 @@
defmodule Plausible.Verification do
@moduledoc """
Module defining the user-agent used for site verification.
"""
use Plausible
@feature_flag :verification
def enabled?(user) do
enabled_via_config? =
:plausible |> Application.fetch_env!(__MODULE__) |> Keyword.fetch!(:enabled?)
enabled_for_user? = not is_nil(user) and FunWithFlags.enabled?(@feature_flag, for: user)
enabled_via_config? or enabled_for_user?
end
on_ee do
def user_agent() do
"Plausible Verification Agent - if abused, contact support@plausible.io"
end
else
def user_agent() do
"Plausible Community Edition"
end
end
end

View File

@ -0,0 +1,37 @@
defmodule Plausible.Verification.Check do
@moduledoc """
Behaviour to be implemented by specific site verification checks.
`friendly_name()` doesn't necessarily reflect the actual check description,
it serves as a user-facing message grouping mechanism, to prevent frequent message flashing when checks rotate often.
Each check operates on `state()` and is expected to return it, optionally modified, by all means.
`perform_safe/1` is used to guarantee no exceptions are thrown by faulty implementations, not to interrupt LiveView.
"""
@type state() :: Plausible.Verification.State.t()
@callback friendly_name() :: String.t()
@callback perform(state()) :: state()
defmacro __using__(_) do
quote do
import Plausible.Verification.State
alias Plausible.Verification.Checks
alias Plausible.Verification.State
alias Plausible.Verification.Diagnostics
require Logger
@behaviour Plausible.Verification.Check
def perform_safe(state) do
perform(state)
catch
_, e ->
Logger.error(
"Error running check #{inspect(__MODULE__)} on #{state.url}: #{inspect(e)}"
)
put_diagnostics(state, service_error: true)
end
end
end
end

View File

@ -0,0 +1,75 @@
defmodule Plausible.Verification.Checks do
@moduledoc """
Checks that are performed during site verification.
Each module defined in `@checks` implements the `Plausible.Verification.Check` behaviour.
Checks are normally run asynchronously, except when synchronous execution is optionally required
for tests. Slowdowns can be optionally added, the user doesn't benefit from running the checks too quickly.
In async execution, each check notifies the caller by sending a message to it.
"""
alias Plausible.Verification.Checks
alias Plausible.Verification.State
require Logger
@checks [
Checks.FetchBody,
Checks.CSP,
Checks.ScanBody,
Checks.Snippet,
Checks.SnippetCacheBust,
Checks.Installation
]
def run(url, data_domain, opts \\ []) do
checks = Keyword.get(opts, :checks, @checks)
report_to = Keyword.get(opts, :report_to, self())
async? = Keyword.get(opts, :async?, true)
slowdown = Keyword.get(opts, :slowdown, 500)
if async? do
Task.start_link(fn -> do_run(url, data_domain, checks, report_to, slowdown) end)
else
do_run(url, data_domain, checks, report_to, slowdown)
end
end
def interpret_diagnostics(%State{} = state) do
Plausible.Verification.Diagnostics.interpret(state.diagnostics, state.url)
end
defp do_run(url, data_domain, checks, report_to, slowdown) do
init_state = %State{url: url, data_domain: data_domain, report_to: report_to}
state =
Enum.reduce(
checks,
init_state,
fn check, state ->
state
|> notify_start(check, slowdown)
|> check.perform_safe()
end
)
notify_verification_end(state, slowdown)
end
defp notify_start(state, check, slowdown) do
if is_pid(state.report_to) do
if is_integer(slowdown) and slowdown > 0, do: :timer.sleep(slowdown)
send(state.report_to, {:verification_check_start, {check, state}})
end
state
end
defp notify_verification_end(state, slowdown) do
if is_pid(state.report_to) do
if is_integer(slowdown) and slowdown > 0, do: :timer.sleep(slowdown)
send(state.report_to, {:verification_end, state})
end
state
end
end

View File

@ -0,0 +1,34 @@
defmodule Plausible.Verification.Checks.CSP do
@moduledoc """
Scans the Content Security Policy header to ensure that the Plausible domain is allowed.
See `Plausible.Verification.Checks` for the execution sequence.
"""
use Plausible.Verification.Check
@impl true
def friendly_name, do: "We're visiting your site to ensure that everything is working correctly"
@impl true
def perform(%State{assigns: %{headers: headers}} = state) do
case headers["content-security-policy"] do
[policy] ->
directives = String.split(policy, ";")
allowed? =
Enum.any?(directives, fn directive ->
String.contains?(directive, PlausibleWeb.Endpoint.host())
end)
if allowed? do
state
else
put_diagnostics(state, disallowed_via_csp?: true)
end
_ ->
state
end
end
def perform(state), do: state
end

View File

@ -0,0 +1,64 @@
defmodule Plausible.Verification.Checks.FetchBody do
@moduledoc """
Fetches the body of the site and extracts the HTML document, if available, for
further processing.
See `Plausible.Verification.Checks` for the execution sequence.
"""
use Plausible.Verification.Check
@impl true
def friendly_name, do: "We're visiting your site to ensure that everything is working correctly"
@impl true
def perform(%State{url: "https://" <> _ = url} = state) do
fetch_body_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || []
opts =
Keyword.merge(
[
base_url: url,
max_redirects: 2,
connect_options: [timeout: 4_000],
receive_timeout: 4_000,
max_retries: 3,
retry_log_level: :warning
],
fetch_body_opts
)
req = Req.new(opts)
case Req.get(req) do
{:ok, %Req.Response{status: status, body: body} = response}
when is_binary(body) and status in 200..299 ->
extract_document(state, response)
_ ->
state
end
end
defp extract_document(state, response) when byte_size(response.body) <= 500_000 do
with true <- html?(response),
{:ok, document} <- Floki.parse_document(response.body) do
state
|> assign(raw_body: response.body, document: document, headers: response.headers)
|> put_diagnostics(body_fetched?: true)
else
_ ->
state
end
end
defp extract_document(state, response) when byte_size(response.body) > 500_000 do
state
end
defp html?(%Req.Response{headers: headers}) do
headers
|> Map.get("content-type", "")
|> List.wrap()
|> List.first()
|> String.contains?("text/html")
end
end

View File

@ -0,0 +1,69 @@
defmodule Plausible.Verification.Checks.Installation do
@verification_script_filename "verification/verify_plausible_installed.js"
@verification_script_path Path.join(:code.priv_dir(:plausible), @verification_script_filename)
@external_resource @verification_script_path
@code File.read!(@verification_script_path)
@moduledoc """
Calls the browserless.io service (local instance can be spawned with `make browserless`)
and runs #{@verification_script_filename} via the [function API](https://docs.browserless.io/HTTP-APIs/function).
The successful execution assumes the following JSON payload:
- `data.plausibleInstalled` - boolean indicating whether the `plausible()` window function was found
- `data.callbackStatus` - integer. 202 indicates that the server acknowledged the test event.
The test event ingestion is discarded based on user-agent, see: `Plausible.Verification.user_agent/0`
"""
use Plausible.Verification.Check
@impl true
def friendly_name, do: "We're verifying that your visitors are being counted correctly"
@impl true
def perform(%State{url: url} = state) do
opts = [
headers: %{content_type: "application/json"},
body:
Jason.encode!(%{
code: @code,
context: %{
url: Plausible.Verification.URL.bust_url(url),
userAgent: Plausible.Verification.user_agent(),
debug: Application.get_env(:plausible, :environment) == "dev"
}
}),
retry: :transient,
retry_log_level: :warning,
max_retries: 2,
receive_timeout: 6_000
]
extra_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || []
opts = Keyword.merge(opts, extra_opts)
case Req.post(verification_endpoint(), opts) do
{:ok,
%{
status: 200,
body: %{
"data" => %{"plausibleInstalled" => installed?, "callbackStatus" => callback_status}
}
}}
when is_boolean(installed?) ->
put_diagnostics(state, plausible_installed?: installed?, callback_status: callback_status)
{:ok, %{status: status}} ->
put_diagnostics(state, plausible_installed?: false, service_error: status)
{:error, %{reason: reason}} ->
put_diagnostics(state, plausible_installed?: false, service_error: reason)
end
end
defp verification_endpoint() do
config = Application.fetch_env!(:plausible, __MODULE__)
token = Keyword.fetch!(config, :token)
endpoint = Keyword.fetch!(config, :endpoint)
Path.join(endpoint, "function?token=#{token}")
end
end

View File

@ -0,0 +1,65 @@
defmodule Plausible.Verification.Checks.ScanBody do
@moduledoc """
Naive way of detecting GTM and WordPress powered sites.
"""
use Plausible.Verification.Check
@impl true
def friendly_name, do: "We're visiting your site to ensure that everything is working correctly"
@impl true
def perform(%State{assigns: %{raw_body: body}} = state) when is_binary(body) do
state
|> scan_wp_plugin()
|> scan_gtm()
|> scan_wp()
end
def perform(state), do: state
defp scan_wp_plugin(%{assigns: %{document: document}} = state) do
case Floki.find(document, ~s|meta[name="plausible-analytics-version"]|) do
[] ->
state
[_] ->
state
|> assign(skip_wordpress_check: true)
|> put_diagnostics(wordpress_likely?: true, wordpress_plugin?: true)
end
end
defp scan_wp_plugin(state) do
state
end
@gtm_signatures [
"googletagmanager.com/gtm.js"
]
defp scan_gtm(state) do
if Enum.any?(@gtm_signatures, &String.contains?(state.assigns.raw_body, &1)) do
put_diagnostics(state, gtm_likely?: true)
else
state
end
end
@wordpress_signatures [
"wp-content",
"wp-includes",
"wp-json"
]
defp scan_wp(%{assigns: %{skip_wordpress_check: true}} = state) do
state
end
defp scan_wp(state) do
if Enum.any?(@wordpress_signatures, &String.contains?(state.assigns.raw_body, &1)) do
put_diagnostics(state, wordpress_likely?: true)
else
state
end
end
end

View File

@ -0,0 +1,52 @@
defmodule Plausible.Verification.Checks.Snippet do
@moduledoc """
The check looks for Plausible snippets and tries to address the common
integration issues, such as bad placement, data-domain typos, unknown
attributes frequently added by performance optimization plugins, etc.
"""
use Plausible.Verification.Check
@impl true
def friendly_name, do: "We're looking for the Plausible snippet on your site"
@impl true
def perform(%State{assigns: %{document: document}} = state) do
in_head = Floki.find(document, "head script[data-domain]")
in_body = Floki.find(document, "body script[data-domain]")
all = in_head ++ in_body
put_diagnostics(state,
snippets_found_in_head: Enum.count(in_head),
snippets_found_in_body: Enum.count(in_body),
proxy_likely?: proxy_likely?(all),
snippet_unknown_attributes?: unknown_attributes?(all),
data_domain_mismatch?: data_domain_mismatch?(all, state.data_domain)
)
end
def perform(state), do: state
defp proxy_likely?(nodes) do
nodes
|> Floki.attribute("src")
|> Enum.any?(&(not String.starts_with?(&1, PlausibleWeb.Endpoint.url())))
end
@known_attributes ["data-domain", "src", "defer", "data-api", "data-exclude", "data-include"]
@known_prefix "event-"
defp unknown_attributes?(nodes) do
Enum.any?(nodes, fn {_, attrs, _} ->
Enum.any?(attrs, fn {key, _} ->
key not in @known_attributes and not String.starts_with?(key, @known_prefix)
end)
end)
end
defp data_domain_mismatch?(nodes, data_domain) do
nodes
|> Floki.attribute("data-domain")
|> Enum.any?(&(&1 != data_domain and data_domain not in String.split(&1, ",")))
end
end

View File

@ -0,0 +1,40 @@
defmodule Plausible.Verification.Checks.SnippetCacheBust do
@moduledoc """
A naive way of trying to figure out whether the latest site contents
is wrapped with some CDN/caching layer.
In case no snippets were found, we'll try to bust the cache by appending a random query parameter
and re-run `Plausible.Verification.Checks.FetchBody` and `Plausible.Verification.Checks.Snippet` checks.
If the result is different this time, we'll assume cache likely.
"""
use Plausible.Verification.Check
@impl true
def friendly_name, do: "We're looking for the Plausible snippet on your site"
@impl true
def perform(
%State{
url: url,
diagnostics: %Diagnostics{
snippets_found_in_head: 0,
snippets_found_in_body: 0,
body_fetched?: true
}
} = state
) do
state2 =
%{state | url: Plausible.Verification.URL.bust_url(url)}
|> Plausible.Verification.Checks.FetchBody.perform()
|> Plausible.Verification.Checks.ScanBody.perform()
|> Plausible.Verification.Checks.Snippet.perform()
if state2.diagnostics.snippets_found_in_head > 0 or
state2.diagnostics.snippets_found_in_body > 0 do
put_diagnostics(state2, snippet_found_after_busting_cache?: true)
else
state
end
end
def perform(state), do: state
end

View File

@ -0,0 +1,378 @@
defmodule Plausible.Verification.Diagnostics do
@moduledoc """
Module responsible for translating diagnostics to user-friendly messages and recommendations.
"""
require Logger
defstruct plausible_installed?: false,
snippets_found_in_head: 0,
snippets_found_in_body: 0,
snippet_found_after_busting_cache?: false,
snippet_unknown_attributes?: false,
disallowed_via_csp?: false,
service_error: nil,
body_fetched?: false,
wordpress_likely?: false,
gtm_likely?: false,
callback_status: -1,
proxy_likely?: false,
data_domain_mismatch?: false,
wordpress_plugin?: false
@type t :: %__MODULE__{}
defmodule Result do
@moduledoc """
Diagnostics interpretation result.
"""
defstruct ok?: false, errors: [], recommendations: []
@type t :: %__MODULE__{}
end
@spec interpret(t(), String.t()) :: Result.t()
def interpret(
%__MODULE__{
plausible_installed?: true,
snippets_found_in_head: 1,
snippets_found_in_body: 0,
callback_status: 202,
snippet_found_after_busting_cache?: false,
service_error: nil,
data_domain_mismatch?: false
},
_url
) do
%Result{ok?: true}
end
def interpret(%__MODULE__{plausible_installed?: false, gtm_likely?: true}, _url) do
%Result{
ok?: false,
errors: ["We encountered an issue with your Plausible integration"],
recommendations: [
{"As you're using Google Tag Manager, you'll need to use a GTM-specific Plausible snippet",
"https://plausible.io/docs/google-tag-manager"}
]
}
end
def interpret(
%__MODULE__{
plausible_installed?: false,
snippets_found_in_head: 1,
disallowed_via_csp?: true,
proxy_likely?: false
},
_url
) do
%Result{
ok?: false,
errors: ["We encountered an issue with your site's CSP"],
recommendations: [
{"Please add plausible.io domain specifically to the allowed list of domains in your Content Security Policy (CSP)",
"https://plausible.io/docs/troubleshoot-integration"}
]
}
end
def interpret(
%__MODULE__{
plausible_installed?: false,
snippets_found_in_head: 0,
snippets_found_in_body: 0,
body_fetched?: true,
service_error: nil,
wordpress_likely?: false
},
_url
) do
%Result{
ok?: false,
errors: ["We couldn't find the Plausible snippet on your site"],
recommendations: [
{"Please insert the snippet into your site", "https://plausible.io/docs/plausible-script"}
]
}
end
def interpret(
%__MODULE__{
plausible_installed?: false,
body_fetched?: false
},
url
) do
%Result{
ok?: false,
errors: ["We couldn't reach #{url}. Is your site up?"],
recommendations: [
{"If your site is running at a different location, please manually check your integration",
"https://plausible.io/docs/troubleshoot-integration"}
]
}
end
def interpret(
%__MODULE__{
plausible_installed?: false,
service_error: service_error
},
_url
)
when not is_nil(service_error) do
%Result{
ok?: false,
errors: ["We encountered a temporary problem verifying your website"],
recommendations: [
{"Please try again in a few minutes or manually check your integration",
"https://plausible.io/docs/troubleshoot-integration"}
]
}
end
def interpret(
%__MODULE__{
plausible_installed?: true,
service_error: nil,
body_fetched?: false
},
url
) do
%Result{
ok?: false,
errors: ["We couldn't reach #{url}. Is your site up?"],
recommendations: [
{"If your site is running at a different location, please manually check your integration",
"https://plausible.io/docs/troubleshoot-integration"}
]
}
end
def interpret(
%__MODULE__{
plausible_installed?: true,
callback_status: callback_status,
proxy_likely?: true
},
_url
)
when callback_status != 202 do
%Result{
ok?: false,
errors: ["We encountered an error with your Plausible proxy"],
recommendations: [
{"Please check whether you've configured the /event route correctly",
"https://plausible.io/docs/proxy/introduction"}
]
}
end
def interpret(
%__MODULE__{
plausible_installed?: false,
snippets_found_in_head: 1,
proxy_likely?: true,
wordpress_likely?: true,
wordpress_plugin?: false
},
_url
) do
%Result{
ok?: false,
errors: ["We encountered an error with your Plausible proxy"],
recommendations: [
{"Please re-enable the proxy in our WordPress plugin to start counting your visitors",
"https://plausible.io/wordpress-analytics-plugin"}
]
}
end
def interpret(
%__MODULE__{
plausible_installed?: false,
snippets_found_in_head: 1,
proxy_likely?: true,
wordpress_likely?: false
},
_url
) do
%Result{
ok?: false,
errors: ["We encountered an error with your Plausible proxy"],
recommendations: [
{"Please check your proxy configuration to make sure it's set up correctly",
"https://plausible.io/docs/proxy/introduction"}
]
}
end
def interpret(
%__MODULE__{snippets_found_in_head: count_head, snippets_found_in_body: count_body},
_url
)
when count_head + count_body > 1 do
%Result{
ok?: false,
errors: ["We've found multiple Plausible snippets on your site."],
recommendations: [
{"Please ensure that only one snippet is used",
"https://plausible.io/docs/troubleshoot-integration"}
]
}
end
def interpret(
%__MODULE__{
plausible_installed?: true,
callback_status: 202,
snippet_found_after_busting_cache?: true,
wordpress_likely?: true,
wordpress_plugin?: true
},
_url
) do
%Result{
ok?: false,
errors: ["We encountered an issue with your site cache"],
recommendations: [
{"Please clear your WordPress cache to ensure that the latest version of your site is being displayed to all your visitors",
"https://plausible.io/wordpress-analytics-plugin"}
]
}
end
def interpret(
%__MODULE__{
plausible_installed?: true,
callback_status: 202,
snippet_found_after_busting_cache?: true,
wordpress_likely?: true,
wordpress_plugin?: false
},
_url
) do
%Result{
ok?: false,
errors: ["We encountered an issue with your site cache"],
recommendations: [
{"Please install and activate our WordPress plugin to start counting your visitors",
"https://plausible.io/wordpress-analytics-plugin"}
]
}
end
def interpret(
%__MODULE__{
plausible_installed?: true,
callback_status: 202,
snippet_found_after_busting_cache?: true,
wordpress_likely?: false
},
_url
) do
%Result{
ok?: false,
errors: ["We encountered an issue with your site cache"],
recommendations: [
{"Please clear your cache (or wait for your provider to clear it) to ensure that the latest version of your site is being displayed to all your visitors",
"https://plausible.io/docs/troubleshoot-integration"}
]
}
end
def interpret(%__MODULE__{snippets_found_in_head: 0, snippets_found_in_body: n}, _url)
when n >= 1 do
%Result{
ok?: false,
errors: ["Plausible snippet is placed in the body of your site"],
recommendations: [
{"Please relocate the snippet to the header of your site",
"https://plausible.io/docs/troubleshoot-integration"}
]
}
end
def interpret(%__MODULE__{data_domain_mismatch?: true}, url) do
%Result{
ok?: false,
errors: ["Your data-domain is different than #{url}"],
recommendations: [
{"Please ensure that the site in the data-domain attribute is an exact match to the site as you added it to your Plausible account",
"https://plausible.io/docs/troubleshoot-integration"}
]
}
end
def interpret(
%__MODULE__{
plausible_installed?: false,
snippet_unknown_attributes?: true,
wordpress_likely?: true,
wordpress_plugin?: true
},
_url
) do
%Result{
ok?: false,
errors: ["A performance optimization plugin seems to have altered our snippet"],
recommendations: [
{"Please whitelist our script in your performance optimization plugin to stop it from changing our snippet",
"https://plausible.io/wordpress-analytics-plugin "}
]
}
end
def interpret(
%__MODULE__{
plausible_installed?: false,
snippet_unknown_attributes?: true,
wordpress_likely?: true,
wordpress_plugin?: false
},
_url
) do
%Result{
ok?: false,
errors: ["A performance optimization plugin seems to have altered our snippet"],
recommendations: [
{"Please install and activate our WordPress plugin to avoid the most common plugin conflicts",
"https://plausible.io/wordpress-analytics-plugin "}
]
}
end
def interpret(
%__MODULE__{
plausible_installed?: false,
snippet_unknown_attributes?: true,
wordpress_likely?: false
},
_url
) do
%Result{
ok?: false,
errors: ["Something seems to have altered our snippet"],
recommendations: [
{"Please manually check your integration to make sure that nothing prevents our script from working",
"https://plausible.io/docs/troubleshoot-integration"}
]
}
end
def interpret(rating, url) do
Sentry.capture_message("Unhandled case for site verification: #{url}",
extra: %{
message: inspect(rating)
}
)
%Result{
ok?: false,
errors: ["Your Plausible integration is not working"],
recommendations: [
{"Please manually check your integration to make sure that the Plausible snippet has been inserted correctly",
"https://plausible.io/docs/troubleshoot-integration"}
]
}
end
end

View File

@ -0,0 +1,27 @@
defmodule Plausible.Verification.State do
@moduledoc """
The struct and interface describing the state of the site verification process.
Assigns are meant to be used to communicate between checks, while diagnostics
are later on interpreted (translated into user-friendly messages and recommendations)
via `Plausible.Verification.Diagnostics` module.
"""
defstruct url: nil,
data_domain: nil,
report_to: nil,
assigns: %{},
diagnostics: %Plausible.Verification.Diagnostics{}
@type t() :: %__MODULE__{}
def assign(%__MODULE__{} = state, assigns) do
%{state | assigns: Map.merge(state.assigns, Enum.into(assigns, %{}))}
end
def put_diagnostics(%__MODULE__{} = state, diagnostics) when is_list(diagnostics) do
%{state | diagnostics: struct!(state.diagnostics, diagnostics)}
end
def put_diagnostics(%__MODULE__{} = state, diagnostics) do
put_diagnostics(state, List.wrap(diagnostics))
end
end

View File

@ -0,0 +1,25 @@
defmodule Plausible.Verification.URL do
@moduledoc """
Busting some caches by appending ?plausible_verification=12345 to it.
"""
def bust_url(url) do
cache_invalidator = abs(:erlang.unique_integer())
update_url(url, cache_invalidator)
end
defp update_url(url, invalidator) do
url
|> URI.parse()
|> then(fn uri ->
updated_query =
(uri.query || "")
|> URI.decode_query()
|> Map.put("plausible_verification", invalidator)
|> URI.encode_query()
struct!(uri, query: updated_query)
end)
|> to_string()
end
end

File diff suppressed because one or more lines are too long

View File

@ -129,6 +129,7 @@ defmodule PlausibleWeb.SiteController do
|> render("settings_general.html",
site: site,
changeset: Plausible.Site.changeset(site, %{}),
connect_live_socket: true,
dogfood_page_path: "/:dashboard/settings/general",
layout: {PlausibleWeb.LayoutView, "site_settings.html"}
)

View File

@ -56,9 +56,10 @@ defmodule PlausibleWeb.StatsController do
can_see_stats? = not Sites.locked?(site) or conn.assigns[:current_user_role] == :super_admin
demo = site.domain == PlausibleWeb.Endpoint.host()
dogfood_page_path = if !demo, do: "/:dashboard"
skip_to_dashboard? = conn.params["skip_to_dashboard"] == "true"
cond do
stats_start_date && can_see_stats? ->
(stats_start_date && can_see_stats?) || (can_see_stats? && skip_to_dashboard?) ->
conn
|> put_resp_header("x-robots-tag", "noindex, nofollow")
|> render("stats.html",
@ -80,7 +81,8 @@ defmodule PlausibleWeb.StatsController do
!stats_start_date && can_see_stats? ->
render(conn, "waiting_first_pageview.html",
site: site,
dogfood_page_path: dogfood_page_path
dogfood_page_path: dogfood_page_path,
connect_live_socket: true
)
Sites.locked?(site) ->

View File

@ -0,0 +1,145 @@
defmodule PlausibleWeb.Live.Components.Verification do
@moduledoc """
This component is responsible for rendering the verification progress
and diagnostics.
"""
use Phoenix.LiveComponent
use Plausible
import PlausibleWeb.Components.Generic
attr :domain, :string, required: true
attr :modal?, :boolean, default: false
attr :message, :string,
default: "We're visiting your site to ensure that everything is working correctly"
attr :finished?, :boolean, default: false
attr :success?, :boolean, default: false
attr :interpretation, Plausible.Verification.Diagnostics.Result, default: nil
attr :attempts, :integer, default: 0
def render(assigns) do
~H"""
<div class={[
"bg-white dark:bg-gray-800 text-center h-96 flex flex-col",
if(!@modal?, do: "shadow-md rounded px-8 pt-6 pb-4 mb-4 mt-16")
]}>
<h2 class="text-xl font-bold dark:text-gray-100">
<%= if @success? && @finished? do %>
Success!
<% else %>
Verifying your integration
<% end %>
</h2>
<h2 class="text-xl dark:text-gray-100 text-xs">
<%= if @finished? && @success? do %>
Your integration is working and visitors are being counted accurately
<% else %>
on <%= @domain %>
<% end %>
</h2>
<div
:if={!@finished? || @success?}
class="flex justify-center w-full my-auto"
id="progress-indicator"
>
<div class={["block pulsating-circle", if(@modal? && @finished?, do: "hidden")]}></div>
<Heroicons.check_circle
:if={@modal? && @finished? && @success?}
id="check-circle"
solid
class="w-24 h-24 text-green-500 pt-8"
/>
</div>
<div
:if={@finished? && !@success?}
class="flex justify-center pt-3 h-14 mb-4 dark:text-gray-400 "
id="progress-indicator"
>
<.shuttle width={50} height={50} />
</div>
<div
id="progress"
class={[
"mt-2 dark:text-gray-400",
if(!@finished?, do: "animate-pulse text-xs", else: "font-bold text-sm"),
if(@finished? && !@success?, do: "text-red-500 dark:text-red-600")
]}
>
<p id="progress-message" class="leading-normal">
<span :if={!@finished?}><%= @message %></span>
<span :if={@finished? && !@success? && @interpretation && @interpretation.errors}>
<%= List.first(@interpretation.errors) %>
<div class="text-xs dark:text-gray-400 font-normal mt-1" id="recommendations">
<.recommendations interpretation={@interpretation} />
</div>
</span>
<p
:if={@finished? && @success? && !@modal?}
class="leading-normal animate-pulse text-xs font-normal"
>
Awaiting your first pageview.
</p>
</p>
</div>
<div class="mt-auto pb-2 text-gray-600 dark:text-gray-400 text-xs w-full text-center leading-normal">
<div :if={@finished?} class="mb-4">
<div class="flex justify-center gap-x-4 mt-4">
<.button_link :if={!@success?} href="#" phx-click="retry" class="text-xs font-bold">
Verify integration again
</.button_link>
<.button_link
:if={@success?}
href={"/#{URI.encode_www_form(@domain)}?skip_to_dashboard=true"}
class="text-xs font-bold"
>
Go to the dashboard
</.button_link>
</div>
</div>
<%= if ee?() && @finished? && !@success? && @attempts >= 3 do %>
Need further help with your integration? Do
<.styled_link href="https://plausible.io/contact">
contact us
</.styled_link>
<br />
<% end %>
<%= if !@modal? && !@success? do %>
Need to see the snippet again?
<.styled_link href={"/#{URI.encode_www_form(@domain)}/snippet"}>
Click here
</.styled_link>
<br /> Run verification later and go to Site Settings?
<.styled_link href={"/#{URI.encode_www_form(@domain)}/settings/general"}>
Click here
</.styled_link>
<br />
<% end %>
</div>
</div>
"""
end
def recommendations(assigns) do
~H"""
<p class="leading-normal">
<span :for={recommendation <- @interpretation.recommendations} class="recommendation">
<span :if={is_binary(recommendation)}><%= recommendation %></span>
<span :if={is_tuple(recommendation)}><%= elem(recommendation, 0) %> -</span>
<.styled_link
:if={is_tuple(recommendation)}
href={elem(recommendation, 1)}
new_tab={true}
class="text-xs"
>
Learn more
</.styled_link>
<br />
</span>
</p>
"""
end
end

View File

@ -0,0 +1,156 @@
defmodule PlausibleWeb.Live.Verification do
@moduledoc """
LiveView coordinating the site verification process.
Onboarding new sites, renders a standalone component.
Embedded modal variant is available for general site settings.
"""
use PlausibleWeb, :live_view
use Phoenix.HTML
alias Plausible.Verification.{Checks, State}
alias PlausibleWeb.Live.Components.Modal
@component PlausibleWeb.Live.Components.Verification
@slowdown_for_frequent_checking :timer.seconds(5)
def mount(
:not_mounted_at_router,
%{"domain" => domain} = session,
socket
) do
socket =
assign(socket,
domain: domain,
modal?: !!session["modal?"],
component: @component,
report_to: session["report_to"] || self(),
delay: session["slowdown"] || 500,
slowdown: session["slowdown"] || 500,
checks_pid: nil,
attempts: 0
)
if connected?(socket) and !session["modal?"] do
launch_delayed(socket)
end
{:ok, socket}
end
def render(assigns) do
~H"""
<div :if={@modal?} phx-click-away="reset">
<.live_component module={Modal} id="verification-modal">
<.live_component
module={@component}
domain={@domain}
id="verification-within-modal"
modal?={@modal?}
attempts={@attempts}
/>
</.live_component>
<PlausibleWeb.Components.Generic.button
id="launch-verification-button"
x-data
x-on:click={Modal.JS.open("verification-modal")}
phx-click="launch-verification"
class="mt-6"
>
Verify your integration
</PlausibleWeb.Components.Generic.button>
</div>
<.live_component
:if={!@modal?}
module={@component}
domain={@domain}
id="verification-standalone"
attempts={@attempts}
/>
"""
end
def handle_event("launch-verification", _, socket) do
launch_delayed(socket)
{:noreply, reset_component(socket)}
end
def handle_event("retry", _, socket) do
launch_delayed(socket)
{:noreply, reset_component(socket)}
end
def handle_info({:start, report_to}, socket) do
if is_pid(socket.assigns.checks_pid) and Process.alive?(socket.assigns.checks_pid) do
{:noreply, socket}
else
case Plausible.RateLimit.check_rate(
"site_verification_#{socket.assigns.domain}",
:timer.minutes(60),
3
) do
{:allow, _} -> :ok
{:deny, _} -> :timer.sleep(@slowdown_for_frequent_checking)
end
{:ok, pid} =
Checks.run(
"https://#{socket.assigns.domain}",
socket.assigns.domain,
report_to: report_to,
slowdown: socket.assigns.slowdown
)
{:noreply, assign(socket, checks_pid: pid, attempts: socket.assigns.attempts + 1)}
end
end
def handle_info({:verification_check_start, {check, _state}}, socket) do
update_component(socket,
message: check.friendly_name()
)
{:noreply, socket}
end
def handle_info({:verification_end, %State{} = state}, socket) do
interpretation = Checks.interpret_diagnostics(state)
update_component(socket,
finished?: true,
success?: interpretation.ok?,
interpretation: interpretation
)
{:noreply, assign(socket, checks_pid: nil)}
end
defp reset_component(socket) do
update_component(socket,
message: "We're visiting your site to ensure that everything is working correctly",
finished?: false,
success?: false,
diagnostics: nil
)
socket
end
defp update_component(socket, updates) do
send_update(
@component,
Keyword.merge(updates,
id:
if(socket.assigns.modal?,
do: "verification-within-modal",
else: "verification-standalone"
)
)
)
end
defp launch_delayed(socket) do
Process.send_after(self(), {:start, socket.assigns.report_to}, socket.assigns.delay)
end
end

View File

@ -108,5 +108,16 @@
</svg>
</a>
</div>
<div :if={Plausible.Verification.enabled?(@current_user)}>
<%= live_render(@conn, PlausibleWeb.Live.Verification,
session: %{
"site_id" => @site.id,
"domain" => @site.domain,
"modal?" => true,
"slowdown" => @conn.private[:verification_slowdown]
}
) %>
</div>
</div>
<% end %>

View File

@ -7,7 +7,11 @@
<%= form_for @conn, "/", [class: "max-w-lg w-full mx-auto bg-white dark:bg-gray-800 shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8"], fn f -> %>
<h2 class="text-xl font-bold dark:text-gray-100">Add JavaScript snippet</h2>
<div class="mt-4">
<p class="dark:text-gray-100">
<p :if={Plausible.Verification.enabled?(@current_user)} class="dark:text-gray-100">
Include this snippet in the <code>&lt;head&gt;</code>
section of your website.<br />To verify your integration, click the button below to confirm that everything is working correctly.
</p>
<p :if={not Plausible.Verification.enabled?(@current_user)} class="dark:text-gray-100">
Paste this snippet in the <code>&lt;head&gt;</code> of your website.
</p>
@ -60,7 +64,13 @@
</.styled_link>
</p>
</div>
<%= link("Start collecting data →",
<% button_label =
if Plausible.Verification.enabled?(@current_user) do
"Verify your integration to start collecting data →"
else
"Start collecting data →"
end %>
<%= link(button_label,
class: "button mt-4 w-full",
to: "/#{URI.encode_www_form(@site.domain)}"
) %>

View File

@ -11,7 +11,6 @@
setInterval(updateStatus, 5000)
</script>
<div class="w-full max-w-md mx-auto mt-8">
<%= if @site.locked do %>
<div
@ -22,7 +21,10 @@
<p>This dashboard is actually locked. You are viewing it with super-admin access</p>
</div>
<% end %>
<div class="bg-white dark:bg-gray-800 shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-16 relative text-center">
<div
:if={not Plausible.Verification.enabled?(assigns[:current_user])}
class="bg-white dark:bg-gray-800 shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-16 relative text-center"
>
<h2 class="text-xl font-bold dark:text-gray-100">Waiting for first pageview</h2>
<h2 class="text-xl font-bold dark:text-gray-100">on <%= @site.domain %></h2>
<div class="my-44">
@ -58,4 +60,14 @@
</p>
</div>
</div>
<%= if Plausible.Verification.enabled?(assigns[:current_user]),
do:
live_render(@conn, PlausibleWeb.Live.Verification,
session: %{
"site_id" => @site.id,
"domain" => @site.domain,
"slowdown" => @conn.private[:verification_slowdown]
}
) %>
</div>

View File

@ -84,8 +84,8 @@ defmodule Plausible.MixProject do
{:eqrcode, "~> 0.1.10"},
{:ex_machina, "~> 2.3", only: [:dev, :test, :ce_dev, :ce_test]},
{:excoveralls, "~> 0.10", only: :test},
{:finch, "~> 0.16.0"},
{:floki, "~> 0.35.0", only: [:dev, :test, :ce_dev, :ce_test]},
{:finch, "~> 0.17.0"},
{:floki, "~> 0.35.0"},
{:fun_with_flags, "~> 1.11.0"},
{:fun_with_flags_ui, "~> 1.0"},
{:locus, "~> 2.3"},
@ -142,7 +142,8 @@ defmodule Plausible.MixProject do
{:ex_aws_s3, "~> 2.5"},
{:sweet_xml, "~> 0.7.4"},
{:zstream, "~> 0.6.4"},
{:con_cache, "~> 1.1.0"}
{:con_cache, "~> 1.1.0"},
{:req, "~> 0.4.14"}
]
end

View File

@ -8,7 +8,7 @@
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.1.0", "0b110a9a6c619b19a7f73fa3004aa11d6e719a67e672d1633dc36b6b2290a0f7", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2ad2acb5a8bc049e8d5aa267802631912bb80d5f4110a178ae7999e69dca1bf7"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"},
"castore": {:hex, :castore, "1.0.6", "ffc42f110ebfdafab0ea159cd43d31365fa0af0ce4a02ecebf1707ae619ee727", [:mix], [], "hexpm", "374c6e7ca752296be3d6780a6d5b922854ffcc74123da90f2f328996b962d33a"},
"castore": {:hex, :castore, "1.0.7", "b651241514e5f6956028147fe6637f7ac13802537e895a724f90bf3e36ddd1dd", [:mix], [], "hexpm", "da7785a4b0d2a021cd1292a60875a784b6caef71e76bf4917bdee1f390455cf5"},
"certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"},
"ch": {:hex, :ch, "0.2.5", "b8d70689951bd14c8c8791dc72cdc957ba489ceae723e79cf1a91d95b6b855ae", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "97de104c8f513a23c6d673da37741f68ae743f6cdb654b96a728d382e2fba4de"},
"chatterbox": {:hex, :ts_chatterbox, "0.15.1", "5cac4d15dd7ad61fc3c4415ce4826fc563d4643dee897a558ec4ea0b1c835c9c", [:rebar3], [{:hpack, "~> 0.3.0", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "4f75b91451338bc0da5f52f3480fa6ef6e3a2aeecfc33686d6b3d0a0948f31aa"},
@ -53,7 +53,7 @@
"excoveralls": {:hex, :excoveralls, "0.18.0", "b92497e69465dc51bc37a6422226ee690ab437e4c06877e836f1c18daeb35da9", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1109bb911f3cb583401760be49c02cbbd16aed66ea9509fc5479335d284da60b"},
"expo": {:hex, :expo, "0.5.1", "249e826a897cac48f591deba863b26c16682b43711dd15ee86b92f25eafd96d9", [:mix], [], "hexpm", "68a4233b0658a3d12ee00d27d37d856b1ba48607e7ce20fd376958d0ba6ce92b"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"finch": {:hex, :finch, "0.16.0", "40733f02c89f94a112518071c0a91fe86069560f5dbdb39f9150042f44dcfb1a", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f660174c4d519e5fec629016054d60edd822cdfe2b7270836739ac2f97735ec5"},
"finch": {:hex, :finch, "0.17.0", "17d06e1d44d891d20dbd437335eebe844e2426a0cd7e3a3e220b461127c73f70", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8d014a661bb6a437263d4b5abf0bcbd3cf0deb26b1e8596f2a271d22e48934c7"},
"floki": {:hex, :floki, "0.35.2", "87f8c75ed8654b9635b311774308b2760b47e9a579dabf2e4d5f1e1d42c39e0b", [:mix], [], "hexpm", "6b05289a8e9eac475f644f09c2e4ba7e19201fd002b89c28c1293e7bd16773d9"},
"fun_with_flags": {:hex, :fun_with_flags, "1.11.0", "a9019d0300e9755c53111cf5b2aba640d7f0de2a8a03a0bd0c593e943c3e9ec5", [:mix], [{:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: true]}, {:redix, "~> 1.0", [hex: :redix, repo: "hexpm", optional: true]}], "hexpm", "448ec640cd1ade4728979ae5b3e7592b0fc8b0f99cf40785d048515c27d09743"},
"fun_with_flags_ui": {:hex, :fun_with_flags_ui, "1.0.0", "d764a4d1cc1233bdbb18dfb416a6ef96d0ecf4a5dc5a0201f7aa0b13cf2e7802", [:mix], [{:cowboy, ">= 2.0.0", [hex: :cowboy, repo: "hexpm", optional: true]}, {:fun_with_flags, "~> 1.11", [hex: :fun_with_flags, repo: "hexpm", optional: false]}, {:plug, "~> 1.12", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 2.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "b0c145894c00d65d5dc20ee5b1f18457985d1fd0b87866f0b41894d5979e55e0"},
@ -65,7 +65,7 @@
"hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"},
"heroicons": {:hex, :heroicons, "0.5.3", "ee8ae8335303df3b18f2cc07f46e1cb6e761ba4cf2c901623fbe9a28c0bc51dd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.18.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "a210037e8a09ac17e2a0a0779d729e89c821c944434c3baa7edfc1f5b32f3502"},
"hpack": {:hex, :hpack_erl, "0.3.0", "2461899cc4ab6a0ef8e970c1661c5fc6a52d3c25580bc6dd204f84ce94669926", [:rebar3], [], "hexpm", "d6137d7079169d8c485c6962dfe261af5b9ef60fbc557344511c1e65e3d95fb0"},
"hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"},
"hpax": {:hex, :hpax, "0.2.0", "5a58219adcb75977b2edce5eb22051de9362f08236220c9e859a47111c194ff5", [:mix], [], "hexpm", "bea06558cdae85bed075e6c036993d43cd54d447f76d8190a8db0dc5893fa2f1"},
"httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"},
@ -81,7 +81,7 @@
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
"mint": {:hex, :mint, "1.5.2", "4805e059f96028948870d23d7783613b7e6b0e2fb4e98d720383852a760067fd", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "d77d9e9ce4eb35941907f1d3df38d8f750c357865353e21d335bdcdf6d892a02"},
"mint": {:hex, :mint, "1.6.0", "88a4f91cd690508a04ff1c3e28952f322528934be541844d54e0ceb765f01d5e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "3c5ae85d90a5aca0a49c0d8b67360bbe407f3b54f1030a111047ff988e8fefaa"},
"mjml": {:hex, :mjml, "1.5.0", "20a4ed2490a60c6928d45a69b64fb45ce8d8bdac686ef689315b0adda69c6406", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6.0", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "44dc36c0fccf52eeb8e0afcb26a863ba41a5f9adcb71bb32e084619a13bb4cdf"},
"mjml_eex": {:hex, :mjml_eex, "0.9.1", "102b6b6e57bfd6db01e0feef801b573fcddb1ee34effb884695da8407544a5be", [:mix], [{:erlexec, "~> 2.0", [hex: :erlexec, repo: "hexpm", optional: true]}, {:mjml, "~> 1.5.0", [hex: :mjml, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.2", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "310f9364d4f1126170835db6fb8dad87e393b28860b0e710d870812fb0bd7892"},
"mox": {:hex, :mox, "1.1.0", "0f5e399649ce9ab7602f72e718305c0f9cdc351190f72844599545e4996af73c", [:mix], [], "hexpm", "d44474c50be02d5b72131070281a5d3895c0e7a95c780e90bc0cfe712f633a13"},
@ -91,7 +91,7 @@
"nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"},
"nimble_ownership": {:hex, :nimble_ownership, "0.3.1", "99d5244672fafdfac89bfad3d3ab8f0d367603ce1dc4855f86a1c75008bce56f", [:mix], [], "hexpm", "4bf510adedff0449a1d6e200e43e57a814794c8b5b6439071274d248d272a549"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
"nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"nimble_totp": {:hex, :nimble_totp, "1.0.0", "79753bae6ce59fd7cacdb21501a1dbac249e53a51c4cd22b34fa8438ee067283", [:mix], [], "hexpm", "6ce5e4c068feecdb782e85b18237f86f66541523e6bad123e02ee1adbe48eda9"},
"oban": {:hex, :oban, "2.17.2", "bcd1276473d8635475076b01032c00474f9c7841d3a2ca46ead26e1ec023cdd3", [:mix], [{:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "de90489c05c039a6d942fa54d8fa13b858db315da2178e4e2c35c82c0a3ab556"},
"observer_cli": {:hex, :observer_cli, "1.7.4", "3c1bfb6d91bf68f6a3d15f46ae20da0f7740d363ee5bc041191ce8722a6c4fae", [:mix, :rebar3], [{:recon, "~> 2.5.1", [hex: :recon, repo: "hexpm", optional: false]}], "hexpm", "50de6d95d814f447458bd5d72666a74624eddb0ef98bdcee61a0153aae0865ff"},
@ -128,6 +128,7 @@
"recon": {:hex, :recon, "2.5.4", "05dd52a119ee4059fa9daa1ab7ce81bc7a8161a2f12e9d42e9d551ffd2ba901c", [:mix, :rebar3], [], "hexpm", "e9ab01ac7fc8572e41eb59385efeb3fb0ff5bf02103816535bacaedf327d0263"},
"ref_inspector": {:hex, :ref_inspector, "2.0.0", "f3e97e51d9782de4c792f56eed26c80903bc39174c878285392ce76d5e67fe98", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:yamerl, "~> 0.7", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "bf62f3f1a87d6b8b30f457a480668f965373e64f184611282b5e89d8dd81fd33"},
"referrer_blocklist": {:git, "https://github.com/plausible/referrer-blocklist.git", "d6f52c225cccb4f04b80e3a5d588868ec234139d", []},
"req": {:hex, :req, "0.4.14", "103de133a076a31044e5458e0f850d5681eef23dfabf3ea34af63212e3b902e2", [:mix], [{:aws_signature, "~> 0.3.2", [hex: :aws_signature, repo: "hexpm", optional: true]}, {:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:nimble_ownership, "~> 0.2.0 or ~> 0.3.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "2ddd3d33f9ab714ced8d3c15fd03db40c14dbf129003c4a3eb80fac2cc0b1b08"},
"rustler_precompiled": {:hex, :rustler_precompiled, "0.6.2", "d2218ba08a43fa331957f30481d00b666664d7e3861431b02bd3f4f30eec8e5b", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "b9048eaed8d7d14a53f758c91865cc616608a438d2595f621f6a4b32a5511709"},
"scrivener": {:hex, :scrivener, "2.7.2", "1d913c965ec352650a7f864ad7fd8d80462f76a32f33d57d1e48bc5e9d40aba2", [:mix], [], "hexpm", "7866a0ec4d40274efbee1db8bead13a995ea4926ecd8203345af8f90d2b620d9"},
"scrivener_ecto": {:hex, :scrivener_ecto, "2.7.0", "cf64b8cb8a96cd131cdbcecf64e7fd395e21aaa1cb0236c42a7c2e34b0dca580", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm", "e809f171687806b0031129034352f5ae44849720c48dd839200adeaf0ac3e260"},

View File

@ -0,0 +1,42 @@
export default async function({ page, context }) {
if (context.debug) {
page.on('console', (msg) => console[msg.type()]('PAGE LOG:', msg.text()));
}
await page.setUserAgent(context.userAgent);
await page.goto(context.url);
await page.waitForNetworkIdle({ idleTime: 1000 });
const plausibleInstalled = await page.evaluate(() => {
window.__plausible = true;
if (typeof (window.plausible) === "function") {
window.plausible('verification-agent-test', {
callback: function(options) {
window.plausibleCallbackResult = () => options && options.status ? options.status : 1;
}
});
return true;
} else {
window.plausibleCallbackResult = () => 0;
return false;
}
});
await page.waitForFunction('window.plausibleCallbackResult', { timeout: 2000 });
const callbackStatus = await page.evaluate(() => {
if (typeof (window.plausibleCallbackResult) === "function") {
return window.plausibleCallbackResult();
} else {
return 0;
}
});
return {
data: {
plausibleInstalled, callbackStatus
},
type: "application/json"
};
}

View File

@ -6,7 +6,7 @@ defmodule Plausible.Ingestion.EventTest do
alias Plausible.Ingestion.Request
alias Plausible.Ingestion.Event
test "event pipeline processes a request into an event" do
test "processes a request into an event" do
site = insert(:site)
payload = %{
@ -20,7 +20,25 @@ defmodule Plausible.Ingestion.EventTest do
assert {:ok, %{buffered: [_], dropped: []}} = Event.build_and_buffer(request)
end
test "event pipeline drops a request when site does not exists" do
test "drops verification agent" do
site = insert(:site)
payload = %{
name: "pageview",
url: "http://#{site.domain}"
}
conn =
build_conn(:post, "/api/events", payload)
|> Plug.Conn.put_req_header("user-agent", Plausible.Verification.user_agent())
assert {:ok, request} = Request.build(conn)
assert {:ok, %{buffered: [], dropped: [dropped]}} = Event.build_and_buffer(request)
assert dropped.drop_reason == :verification_agent
end
test "drops a request when site does not exists" do
payload = %{
name: "pageview",
url: "http://dummy.site"
@ -33,7 +51,7 @@ defmodule Plausible.Ingestion.EventTest do
assert dropped.drop_reason == :not_found
end
test "event pipeline drops a request when referrer is spam" do
test "drops a request when referrer is spam" do
site = insert(:site)
payload = %{
@ -50,7 +68,7 @@ defmodule Plausible.Ingestion.EventTest do
assert dropped.drop_reason == :spam_referrer
end
test "event pipeline drops a request when referrer is spam for multiple domains" do
test "drops a request when referrer is spam for multiple domains" do
site = insert(:site)
payload = %{
@ -67,7 +85,7 @@ defmodule Plausible.Ingestion.EventTest do
assert dropped.drop_reason == :spam_referrer
end
test "event pipeline selectively drops an event for multiple domains" do
test "selectively drops an event for multiple domains" do
site = insert(:site)
payload = %{
@ -83,7 +101,7 @@ defmodule Plausible.Ingestion.EventTest do
assert dropped.drop_reason == :not_found
end
test "event pipeline selectively drops an event when rate-limited" do
test "selectively drops an event when rate-limited" do
site = insert(:site, ingest_rate_limit_threshold: 1)
payload = %{
@ -100,7 +118,7 @@ defmodule Plausible.Ingestion.EventTest do
assert dropped.drop_reason == :throttle
end
test "event pipeline drops a request when header x-plausible-ip-type is dc_ip" do
test "drops a request when header x-plausible-ip-type is dc_ip" do
site = insert(:site)
payload = %{
@ -117,7 +135,7 @@ defmodule Plausible.Ingestion.EventTest do
assert dropped.drop_reason == :dc_ip
end
test "event pipeline drops a request when ip is on blocklist" do
test "drops a request when ip is on blocklist" do
site = insert(:site)
payload = %{
@ -137,7 +155,7 @@ defmodule Plausible.Ingestion.EventTest do
assert dropped.drop_reason == :site_ip_blocklist
end
test "event pipeline drops a request when country is on blocklist" do
test "drops a request when country is on blocklist" do
site = insert(:site)
payload = %{
@ -158,7 +176,7 @@ defmodule Plausible.Ingestion.EventTest do
assert dropped.drop_reason == :site_country_blocklist
end
test "event pipeline drops a request when page is on blocklist" do
test "drops a request when page is on blocklist" do
site = insert(:site)
payload = %{
@ -177,7 +195,7 @@ defmodule Plausible.Ingestion.EventTest do
assert dropped.drop_reason == :site_page_blocklist
end
test "event pipeline drops a request when hostname allowlist is defined and hostname is not on the list" do
test "drops a request when hostname allowlist is defined and hostname is not on the list" do
site = insert(:site)
payload = %{
@ -196,7 +214,7 @@ defmodule Plausible.Ingestion.EventTest do
assert dropped.drop_reason == :site_hostname_allowlist
end
test "event pipeline passes a request when hostname allowlist is defined and hostname is on the list" do
test "passes a request when hostname allowlist is defined and hostname is on the list" do
site = insert(:site)
payload = %{
@ -214,7 +232,7 @@ defmodule Plausible.Ingestion.EventTest do
assert {:ok, %{buffered: [_], dropped: []}} = Event.build_and_buffer(request)
end
test "event pipeline drops events for site with accept_trafic_until in the past" do
test "drops events for site with accept_trafic_until in the past" do
yesterday = Date.add(Date.utc_today(), -1)
site =

View File

@ -0,0 +1,39 @@
defmodule Plausible.Verification.Checks.CSPTest do
use Plausible.DataCase, async: true
alias Plausible.Verification.State
@check Plausible.Verification.Checks.CSP
test "skips no headers" do
state = %State{}
assert ^state = @check.perform(state)
end
test "skips no headers 2" do
state = %State{} |> State.assign(headers: %{})
assert ^state = @check.perform(state)
end
test "disallowed" do
headers = %{"content-security-policy" => ["default-src 'self' foo.local; example.com"]}
state =
%State{}
|> State.assign(headers: headers)
|> @check.perform()
assert state.diagnostics.disallowed_via_csp?
end
test "allowed" do
headers = %{"content-security-policy" => ["default-src 'self' example.com; localhost"]}
state =
%State{}
|> State.assign(headers: headers)
|> @check.perform()
refute state.diagnostics.disallowed_via_csp?
end
end

View File

@ -0,0 +1,64 @@
defmodule Plausible.Verification.Checks.FetchBodyTest do
use Plausible.DataCase, async: true
import Plug.Conn
@check Plausible.Verification.Checks.FetchBody
@normal_body """
<html>
<head>
<script defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
</head>
<body>Hello</body>
</html>
"""
setup do
{:ok,
state: %Plausible.Verification.State{
url: "https://example.com"
}}
end
test "extracts document", %{state: state} do
stub()
state = @check.perform(state)
assert state.assigns.raw_body == @normal_body
assert state.assigns.document == Floki.parse_document!(@normal_body)
assert state.assigns.headers["content-type"] == ["text/html; charset=utf-8"]
assert state.diagnostics.body_fetched?
end
test "doesn't extract on non-2xx", %{state: state} do
stub(400)
state = @check.perform(state)
assert map_size(state.assigns) == 0
refute state.diagnostics.body_fetched?
end
test "doesn't extract non-HTML", %{state: state} do
stub(200, @normal_body, "text/plain")
state = @check.perform(state)
assert map_size(state.assigns) == 0
refute state.diagnostics.body_fetched?
end
defp stub(f) when is_function(f, 1) do
Req.Test.stub(@check, f)
end
defp stub(status \\ 200, body \\ @normal_body, content_type \\ "text/html") do
stub(fn conn ->
conn
|> put_resp_content_type(content_type)
|> send_resp(status, body)
end)
end
end

View File

@ -0,0 +1,70 @@
defmodule Plausible.Verification.Checks.ScanBodyTest do
use Plausible.DataCase, async: true
alias Plausible.Verification.State
@check Plausible.Verification.Checks.ScanBody
test "skips on no raw body" do
state = %State{}
assert ^state = @check.perform(state)
end
test "detects nothing" do
state =
%State{}
|> State.assign(raw_body: "...")
|> @check.perform()
refute state.diagnostics.gtm_likely?
refute state.diagnostics.wordpress_likely?
end
test "detects GTM" do
state =
%State{}
|> State.assign(raw_body: "...googletagmanager.com/gtm.js...")
|> @check.perform()
assert state.diagnostics.gtm_likely?
refute state.diagnostics.wordpress_likely?
end
for signature <- ["wp-content", "wp-includes", "wp-json"] do
test "detects WordPress: #{signature}" do
state =
%State{}
|> State.assign(raw_body: "...#{unquote(signature)}...")
|> @check.perform()
refute state.diagnostics.gtm_likely?
assert state.diagnostics.wordpress_likely?
refute state.diagnostics.wordpress_plugin?
end
end
test "detects GTM and WordPress" do
state =
%State{}
|> State.assign(raw_body: "...googletagmanager.com/gtm.js....wp-content...")
|> @check.perform()
assert state.diagnostics.gtm_likely?
assert state.diagnostics.wordpress_likely?
refute state.diagnostics.wordpress_plugin?
end
@d """
<meta name='plausible-analytics-version' content='2.0.9' />
"""
test "detects official plugin" do
state =
%State{}
|> State.assign(raw_body: @d, document: Floki.parse_document!(@d))
|> @check.perform()
assert state.diagnostics.wordpress_likely?
assert state.diagnostics.wordpress_plugin?
end
end

View File

@ -0,0 +1,143 @@
defmodule Plausible.Verification.Checks.SnippetTest do
use Plausible.DataCase, async: true
alias Plausible.Verification.State
@check Plausible.Verification.Checks.Snippet
test "skips when there's no document" do
state = %State{}
assert ^state = @check.perform(state)
end
@well_placed """
<head>
<script defer data-domain="example.com" event-author="Me" src="http://localhost:8000/js/script.js"></script>
</head>
"""
test "figures out well placed snippet" do
state =
@well_placed
|> new_state()
|> @check.perform()
assert state.diagnostics.snippets_found_in_head == 1
assert state.diagnostics.snippets_found_in_body == 0
refute state.diagnostics.data_domain_mismatch?
refute state.diagnostics.snippet_unknown_attributes?
refute state.diagnostics.proxy_likely?
end
@multi_domain """
<head>
<script defer data-domain="example.org,example.com,example.net" src="http://localhost:8000/js/script.js"></script>
</head>
"""
test "figures out well placed snippet in a multi-domain setup" do
state =
@multi_domain
|> new_state()
|> @check.perform()
assert state.diagnostics.snippets_found_in_head == 1
assert state.diagnostics.snippets_found_in_body == 0
refute state.diagnostics.data_domain_mismatch?
refute state.diagnostics.snippet_unknown_attributes?
refute state.diagnostics.proxy_likely?
end
@crazy """
<head>
<script defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
<script defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
</head>
<body>
<script defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
<script defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
<script defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
</body>
"""
test "counts snippets" do
state =
@crazy
|> new_state()
|> @check.perform()
assert state.diagnostics.snippets_found_in_head == 2
assert state.diagnostics.snippets_found_in_body == 3
end
test "figures out data-domain mismatch" do
state =
@well_placed
|> new_state(data_domain: "example.typo")
|> @check.perform()
assert state.diagnostics.snippets_found_in_head == 1
assert state.diagnostics.snippets_found_in_body == 0
assert state.diagnostics.data_domain_mismatch?
refute state.diagnostics.snippet_unknown_attributes?
refute state.diagnostics.proxy_likely?
end
@proxy_likely """
<head>
<script defer data-domain="example.com" src="http://my-domain.example.com/js/script.js"></script>
</head>
"""
test "figures out proxy likely" do
state =
@proxy_likely
|> new_state()
|> @check.perform()
assert state.diagnostics.snippets_found_in_head == 1
assert state.diagnostics.snippets_found_in_body == 0
refute state.diagnostics.data_domain_mismatch?
refute state.diagnostics.snippet_unknown_attributes?
assert state.diagnostics.proxy_likely?
end
@unknown_attributes """
<head>
<script defer data-api="some" data-include="some" data-exclude="some" weird="one" data-domain="example.com" src="http://my-domain.example.com/js/script.js"></script>
</head>
"""
@valid_attributes """
<head>
<script defer data-api="some" data-include="some" data-exclude="some" data-domain="example.com" src="http://my-domain.example.com/js/script.js"></script>
</head>
"""
test "figures out unknown attributes" do
state =
@valid_attributes
|> new_state()
|> @check.perform()
refute state.diagnostics.snippet_unknown_attributes?
state =
@unknown_attributes
|> new_state()
|> @check.perform()
assert state.diagnostics.snippet_unknown_attributes?
end
defp new_state(html, opts \\ []) do
doc = Floki.parse_document!(html)
opts =
[data_domain: "example.com"]
|> Keyword.merge(opts)
State
|> struct!(opts)
|> State.assign(document: doc)
end
end

View File

@ -0,0 +1,790 @@
defmodule Plausible.Verification.ChecksTest do
use Plausible.DataCase, async: true
alias Plausible.Verification.Checks
alias Plausible.Verification.State
import ExUnit.CaptureLog
import Plug.Conn
@normal_body """
<html>
<head>
<script defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
</head>
<body>Hello</body>
</html>
"""
describe "running checks" do
test "success" do
stub_fetch_body(200, @normal_body)
stub_installation()
result = run_checks()
interpretation = Checks.interpret_diagnostics(result)
assert interpretation.ok?
assert interpretation.errors == []
assert interpretation.recommendations == []
end
test "service error - 400" do
stub_fetch_body(200, @normal_body)
stub_installation(400, %{})
result = run_checks()
interpretation = Checks.interpret_diagnostics(result)
refute interpretation.ok?
assert interpretation.errors == [
"We encountered a temporary problem verifying your website"
]
assert interpretation.recommendations == [
{"Please try again in a few minutes or manually check your integration",
"https://plausible.io/docs/troubleshoot-integration"}
]
end
@tag :slow
test "can't fetch body but headless reports ok" do
stub_fetch_body(500, "")
stub_installation()
{result, log} =
with_log(fn ->
run_checks()
end)
assert log =~ "3 attempts left"
assert log =~ "2 attempts left"
assert log =~ "1 attempt left"
interpretation = Checks.interpret_diagnostics(result)
refute interpretation.ok?
assert interpretation.errors == ["We couldn't reach https://example.com. Is your site up?"]
assert interpretation.recommendations == [
{"If your site is running at a different location, please manually check your integration",
"https://plausible.io/docs/troubleshoot-integration"}
]
end
test "fetching will follow 2 redirects" do
ref = :counters.new(1, [:atomics])
test = self()
Req.Test.stub(Plausible.Verification.Checks.FetchBody, fn conn ->
if :counters.get(ref, 1) < 2 do
:counters.add(ref, 1, 1)
send(test, :redirect_sent)
conn
|> put_resp_header("location", "https://example.com")
|> send_resp(302, "redirecting to https://example.com")
else
conn
|> put_resp_header("content-type", "text/html")
|> send_resp(200, @normal_body)
end
end)
stub_installation()
result = run_checks()
assert_receive :redirect_sent
assert_receive :redirect_sent
refute_receive _
interpretation = Checks.interpret_diagnostics(result)
assert interpretation.ok?
assert interpretation.errors == []
assert interpretation.recommendations == []
end
test "fetching will not follow more than 2 redirect" do
test = self()
stub_fetch_body(fn conn ->
send(test, :redirect_sent)
conn
|> put_resp_header("location", "https://example.com")
|> send_resp(302, "redirecting to https://example.com")
end)
stub_installation()
result = run_checks()
assert_receive :redirect_sent
assert_receive :redirect_sent
assert_receive :redirect_sent
refute_receive _
interpretation = Checks.interpret_diagnostics(result)
refute interpretation.ok?
assert interpretation.errors == ["We couldn't reach https://example.com. Is your site up?"]
assert interpretation.recommendations == [
{"If your site is running at a different location, please manually check your integration",
"https://plausible.io/docs/troubleshoot-integration"}
]
end
test "fetching body fails at non-2xx status, but installation is ok" do
stub_fetch_body(599, "boo")
stub_installation()
result = run_checks()
interpretation = Checks.interpret_diagnostics(result)
refute interpretation.ok?
assert interpretation.errors == ["We couldn't reach https://example.com. Is your site up?"]
assert interpretation.recommendations == [
{"If your site is running at a different location, please manually check your integration",
"https://plausible.io/docs/troubleshoot-integration"}
]
end
@snippet_in_body """
<html>
<head>
</head>
<body>
Hello
<script defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
</body>
</html>
"""
test "detecting snippet in body" do
stub_fetch_body(200, @snippet_in_body)
stub_installation()
result = run_checks()
interpretation = Checks.interpret_diagnostics(result)
refute interpretation.ok?
assert interpretation.errors == ["Plausible snippet is placed in the body of your site"]
assert interpretation.recommendations == [
{"Please relocate the snippet to the header of your site",
"https://plausible.io/docs/troubleshoot-integration"}
]
end
@many_snippets """
<html>
<head>
<script defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>
<script defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>
</head>
<body>
Hello
<script defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>
<script defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>
<!-- maybe proxy? -->
<script defer data-domain="example.com" src="https://example.com/js/script.js"></script>
</body>
</html>
"""
test "detecting many snippets" do
stub_fetch_body(200, @many_snippets)
stub_installation()
result = run_checks()
interpretation = Checks.interpret_diagnostics(result)
refute interpretation.ok?
assert interpretation.errors == ["We've found multiple Plausible snippets on your site."]
assert interpretation.recommendations == [
{"Please ensure that only one snippet is used",
"https://plausible.io/docs/troubleshoot-integration"}
]
end
@body_no_snippet """
<html>
<head>
</head>
<body>
Hello
</body>
</html>
"""
test "detecting snippet after busting cache" do
stub_fetch_body(fn conn ->
conn = fetch_query_params(conn)
if conn.query_params["plausible_verification"] do
conn
|> put_resp_content_type("text/html")
|> send_resp(200, @normal_body)
else
conn
|> put_resp_content_type("text/html")
|> send_resp(200, @body_no_snippet)
end
end)
stub_installation(fn conn ->
{:ok, body, _} = read_body(conn)
if String.contains?(body, "?plausible_verification") do
conn
|> put_resp_content_type("application/json")
|> send_resp(200, Jason.encode!(plausible_installed()))
else
raise "Should not get here even"
end
end)
result = run_checks()
interpretation = Checks.interpret_diagnostics(result)
refute interpretation.ok?
assert interpretation.errors == ["We encountered an issue with your site cache"]
assert interpretation.recommendations == [
{"Please clear your cache (or wait for your provider to clear it) to ensure that the latest version of your site is being displayed to all your visitors",
"https://plausible.io/docs/troubleshoot-integration"}
]
end
@normal_body_wordpress """
<html>
<head>
<meta name="foo" content="/wp-content/plugins/bar"/>
<script defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
</head>
<body>Hello</body>
</html>
"""
test "detecting snippet after busting WordPress cache - no official plugin" do
stub_fetch_body(fn conn ->
conn = fetch_query_params(conn)
if conn.query_params["plausible_verification"] do
conn
|> put_resp_content_type("text/html")
|> send_resp(200, @normal_body_wordpress)
else
conn
|> put_resp_content_type("text/html")
|> send_resp(200, @body_no_snippet)
end
end)
stub_installation(fn conn ->
{:ok, body, _} = read_body(conn)
if String.contains?(body, "?plausible_verification") do
conn
|> put_resp_content_type("application/json")
|> send_resp(200, Jason.encode!(plausible_installed()))
else
raise "Should not get here even"
end
end)
result = run_checks()
interpretation = Checks.interpret_diagnostics(result)
refute interpretation.ok?
assert interpretation.errors == ["We encountered an issue with your site cache"]
assert interpretation.recommendations == [
{"Please install and activate our WordPress plugin to start counting your visitors",
"https://plausible.io/wordpress-analytics-plugin"}
]
end
@normal_body_wordpress_official_plugin """
<html>
<head>
<meta name="foo" content="/wp-content/plugins/bar"/>
<meta name='plausible-analytics-version' content='2.0.9' />
<script defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
</head>
<body>Hello</body>
</html>
"""
test "detecting snippet after busting WordPress cache - official plugin" do
stub_fetch_body(fn conn ->
conn = fetch_query_params(conn)
if conn.query_params["plausible_verification"] do
conn
|> put_resp_content_type("text/html")
|> send_resp(200, @normal_body_wordpress_official_plugin)
else
conn
|> put_resp_content_type("text/html")
|> send_resp(200, @body_no_snippet)
end
end)
stub_installation(fn conn ->
{:ok, body, _} = read_body(conn)
if String.contains?(body, "?plausible_verification") do
conn
|> put_resp_content_type("application/json")
|> send_resp(200, Jason.encode!(plausible_installed()))
else
raise "Should not get here even"
end
end)
result = run_checks()
interpretation = Checks.interpret_diagnostics(result)
refute interpretation.ok?
assert interpretation.errors == ["We encountered an issue with your site cache"]
assert interpretation.recommendations == [
{"Please clear your WordPress cache to ensure that the latest version of your site is being displayed to all your visitors",
"https://plausible.io/wordpress-analytics-plugin"}
]
end
test "detecting no snippet" do
stub_fetch_body(200, @body_no_snippet)
stub_installation(200, plausible_installed(false))
result = run_checks()
interpretation = Checks.interpret_diagnostics(result)
refute interpretation.ok?
assert interpretation.errors == ["We couldn't find the Plausible snippet on your site"]
assert interpretation.recommendations == [
{"Please insert the snippet into your site",
"https://plausible.io/docs/plausible-script"}
]
end
test "a check that raises" do
defmodule FaultyCheckRaise do
use Plausible.Verification.Check
@impl true
def friendly_name, do: "Faulty check"
@impl true
def perform(_), do: raise("boom")
end
{result, log} =
with_log(fn ->
run_checks(checks: [FaultyCheckRaise])
end)
assert log =~
~s|Error running check Plausible.Verification.ChecksTest.FaultyCheckRaise on https://example.com: %RuntimeError{message: "boom"}|
interpretation = Checks.interpret_diagnostics(result)
refute interpretation.ok?
assert interpretation.errors == ["We couldn't reach https://example.com. Is your site up?"]
assert interpretation.recommendations == [
{"If your site is running at a different location, please manually check your integration",
"https://plausible.io/docs/troubleshoot-integration"}
]
end
test "a check that throws" do
defmodule FaultyCheckThrow do
use Plausible.Verification.Check
@impl true
def friendly_name, do: "Faulty check"
@impl true
def perform(_), do: :erlang.throw(:boom)
end
{result, log} =
with_log(fn ->
run_checks(checks: [FaultyCheckThrow])
end)
assert log =~
~s|Error running check Plausible.Verification.ChecksTest.FaultyCheckThrow on https://example.com: :boom|
interpretation = Checks.interpret_diagnostics(result)
refute interpretation.ok?
assert interpretation.errors == ["We couldn't reach https://example.com. Is your site up?"]
assert interpretation.recommendations == [
{"If your site is running at a different location, please manually check your integration",
"https://plausible.io/docs/troubleshoot-integration"}
]
end
test "disallowed via content-security-policy" do
stub_fetch_body(fn conn ->
conn
|> put_resp_header("content-security-policy", "default-src 'self' foo.local")
|> put_resp_content_type("text/html")
|> send_resp(200, @normal_body)
end)
stub_installation(200, plausible_installed(false))
result = run_checks()
interpretation = Checks.interpret_diagnostics(result)
refute interpretation.ok?
assert interpretation.errors == ["We encountered an issue with your site's CSP"]
assert interpretation.recommendations == [
{
"Please add plausible.io domain specifically to the allowed list of domains in your Content Security Policy (CSP)",
"https://plausible.io/docs/troubleshoot-integration"
}
]
end
test "disallowed via content-security-policy with no snippet should make the latter a priority" do
stub_fetch_body(fn conn ->
conn
|> put_resp_header("content-security-policy", "default-src 'self' foo.local")
|> put_resp_content_type("text/html")
|> send_resp(200, @body_no_snippet)
end)
stub_installation(200, plausible_installed(false))
result = run_checks()
interpretation = Checks.interpret_diagnostics(result)
refute interpretation.ok?
assert interpretation.errors == ["We couldn't find the Plausible snippet on your site"]
end
test "allowed via content-security-policy" do
stub_fetch_body(fn conn ->
conn
|> put_resp_header(
"content-security-policy",
Enum.random([
"default-src 'self'; script-src plausible.io; connect-src #{PlausibleWeb.Endpoint.host()}",
"default-src 'self' *.#{PlausibleWeb.Endpoint.host()}"
])
)
|> put_resp_content_type("text/html")
|> send_resp(200, @normal_body)
end)
stub_installation()
result = run_checks()
interpretation = Checks.interpret_diagnostics(result)
assert interpretation.ok?
assert interpretation.errors == []
assert interpretation.recommendations == []
end
test "running checks sends progress messages" do
stub_fetch_body(200, @normal_body)
stub_installation()
final_state = run_checks(report_to: self())
assert_receive {:verification_check_start, {Checks.FetchBody, %State{}}}
assert_receive {:verification_check_start, {Checks.CSP, %State{}}}
assert_receive {:verification_check_start, {Checks.ScanBody, %State{}}}
assert_receive {:verification_check_start, {Checks.Snippet, %State{}}}
assert_receive {:verification_check_start, {Checks.SnippetCacheBust, %State{}}}
assert_receive {:verification_check_start, {Checks.Installation, %State{}}}
assert_receive {:verification_end, %State{} = ^final_state}
refute_receive _
end
@gtm_body """
<html>
<head>
<!-- Google Tag Manager -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','XXXX');</script>
<!-- End Google Tag Manager -->
</head>
<body>
Hello
</body>
</html>
"""
test "detecting gtm" do
stub_fetch_body(200, @gtm_body)
stub_installation(200, plausible_installed(false))
result = run_checks()
interpretation = Checks.interpret_diagnostics(result)
refute interpretation.ok?
assert interpretation.errors == ["We encountered an issue with your Plausible integration"]
assert interpretation.recommendations == [
{"As you're using Google Tag Manager, you'll need to use a GTM-specific Plausible snippet",
"https://plausible.io/docs/google-tag-manager"}
]
end
test "non-html body" do
stub_fetch_body(fn conn ->
conn
|> put_resp_content_type("image/png")
|> send_resp(200, :binary.copy(<<0>>, 100))
end)
stub_installation(200, plausible_installed(false))
result = run_checks()
interpretation = Checks.interpret_diagnostics(result)
refute interpretation.ok?
assert interpretation.errors == ["We couldn't reach https://example.com. Is your site up?"]
assert interpretation.recommendations == [
{"If your site is running at a different location, please manually check your integration",
"https://plausible.io/docs/troubleshoot-integration"}
]
end
@proxied_script_body """
<html>
<head>
<script defer data-domain="example.com" src="https://proxy.example.com/js/script.js"></script>
</head>
<body>Hello</body>
</html>
"""
test "proxied setup working OK" do
stub_fetch_body(200, @proxied_script_body)
stub_installation()
result = run_checks()
interpretation = Checks.interpret_diagnostics(result)
assert interpretation.ok?
assert interpretation.errors == []
assert interpretation.recommendations == []
end
test "proxied setup, function defined but callback won't fire" do
stub_fetch_body(200, @proxied_script_body)
stub_installation(200, plausible_installed(true, 0))
result = run_checks()
interpretation = Checks.interpret_diagnostics(result)
refute interpretation.ok?
assert interpretation.errors == ["We encountered an error with your Plausible proxy"]
assert interpretation.recommendations == [
{"Please check whether you've configured the /event route correctly",
"https://plausible.io/docs/proxy/introduction"}
]
end
@proxied_script_body_wordpress """
<html>
<head>
<meta name="foo" content="/wp-content/plugins/bar"/>
<script defer data-domain="example.com" src="https://proxy.example.com/js/script.js"></script>
</head>
<body>Hello</body>
</html>
"""
test "proxied WordPress setup, function undefined, callback won't fire" do
stub_fetch_body(200, @proxied_script_body_wordpress)
stub_installation(200, plausible_installed(false, 0))
result = run_checks()
interpretation = Checks.interpret_diagnostics(result)
refute interpretation.ok?
assert interpretation.errors == ["We encountered an error with your Plausible proxy"]
assert interpretation.recommendations ==
[
{"Please re-enable the proxy in our WordPress plugin to start counting your visitors",
"https://plausible.io/wordpress-analytics-plugin"}
]
end
test "proxied setup, function undefined, callback won't fire" do
stub_fetch_body(200, @proxied_script_body)
stub_installation(200, plausible_installed(false, 0))
result = run_checks()
interpretation = Checks.interpret_diagnostics(result)
refute interpretation.ok?
assert interpretation.errors == ["We encountered an error with your Plausible proxy"]
assert interpretation.recommendations ==
[
{"Please check your proxy configuration to make sure it's set up correctly",
"https://plausible.io/docs/proxy/introduction"}
]
end
test "non-proxied setup, but callback fails to fire" do
stub_fetch_body(200, @normal_body)
stub_installation(200, plausible_installed(true, 0))
result = run_checks()
interpretation = Checks.interpret_diagnostics(result)
refute interpretation.ok?
assert interpretation.errors == ["Your Plausible integration is not working"]
assert interpretation.recommendations == [
{"Please manually check your integration to make sure that the Plausible snippet has been inserted correctly",
"https://plausible.io/docs/troubleshoot-integration"}
]
end
@body_unknown_attributes """
<html>
<head>
<script foo="bar" defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
</head>
<body>Hello</body>
</html>
"""
test "unknown attributes" do
stub_fetch_body(200, @body_unknown_attributes)
stub_installation(200, plausible_installed(false, 0))
result = run_checks()
interpretation = Checks.interpret_diagnostics(result)
refute interpretation.ok?
assert interpretation.errors == ["Something seems to have altered our snippet"]
assert interpretation.recommendations == [
{"Please manually check your integration to make sure that nothing prevents our script from working",
"https://plausible.io/docs/troubleshoot-integration"}
]
end
@body_unknown_attributes_wordpress """
<html>
<head>
<meta name="foo" content="/wp-content/plugins/bar"/>
<script foo="bar" defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
</head>
<body>Hello</body>
</html>
"""
test "unknown attributes for WordPress installation" do
stub_fetch_body(200, @body_unknown_attributes_wordpress)
stub_installation(200, plausible_installed(false, 0))
result = run_checks()
interpretation = Checks.interpret_diagnostics(result)
refute interpretation.ok?
assert interpretation.errors == [
"A performance optimization plugin seems to have altered our snippet"
]
assert interpretation.recommendations == [
{"Please install and activate our WordPress plugin to avoid the most common plugin conflicts",
"https://plausible.io/wordpress-analytics-plugin "}
]
end
@body_unknown_attributes_wordpress_official_plugin """
<html>
<head>
<meta name="foo" content="/wp-content/plugins/bar"/>
<meta name='plausible-analytics-version' content='2.0.9' />
<script foo="bar" defer data-domain="example.com" src="http://localhost:8000/js/script.js"></script>
</head>
<body>Hello</body>
</html>
"""
test "unknown attributes for WordPress installation - official plugin" do
stub_fetch_body(200, @body_unknown_attributes_wordpress_official_plugin)
stub_installation(200, plausible_installed(false, 0))
result = run_checks()
interpretation = Checks.interpret_diagnostics(result)
refute interpretation.ok?
assert interpretation.errors == [
"A performance optimization plugin seems to have altered our snippet"
]
assert interpretation.recommendations == [
{"Please whitelist our script in your performance optimization plugin to stop it from changing our snippet",
"https://plausible.io/wordpress-analytics-plugin "}
]
end
end
defp run_checks(extra_opts \\ []) do
Checks.run(
"https://example.com",
"example.com",
Keyword.merge([async?: false, report_to: nil, slowdown: 0], extra_opts)
)
end
defp stub_fetch_body(f) when is_function(f, 1) do
Req.Test.stub(Plausible.Verification.Checks.FetchBody, f)
end
defp stub_installation(f) when is_function(f, 1) do
Req.Test.stub(Plausible.Verification.Checks.Installation, f)
end
defp stub_fetch_body(status, body) do
stub_fetch_body(fn conn ->
conn
|> put_resp_content_type("text/html")
|> send_resp(status, body)
end)
end
defp stub_installation(status \\ 200, json \\ plausible_installed()) do
stub_installation(fn conn ->
conn
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(json))
end)
end
defp plausible_installed(bool \\ true, callback_status \\ 202) do
%{"data" => %{"plausibleInstalled" => bool, "callbackStatus" => callback_status}}
end
end

View File

@ -77,6 +77,17 @@ defmodule PlausibleWeb.StatsControllerTest do
assert text_of_attr(resp, @react_container, "data-logged-in") == "true"
end
test "can view stats of a website I've created, enforcing pageviews check skip", %{
conn: conn,
site: site
} do
resp = conn |> get("/" <> site.domain) |> html_response(200)
refute text_of_attr(resp, @react_container, "data-logged-in") == "true"
resp = conn |> get("/" <> site.domain <> "?skip_to_dashboard=true") |> html_response(200)
assert text_of_attr(resp, @react_container, "data-logged-in") == "true"
end
test "shows locked page if page is locked", %{conn: conn, user: user} do
locked_site = insert(:site, locked: true, members: [user])
conn = get(conn, "/" <> locked_site.domain)

View File

@ -0,0 +1,87 @@
defmodule PlausibleWeb.Live.Components.VerificationTest do
use PlausibleWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Plausible.Test.Support.HTML
@component PlausibleWeb.Live.Components.Verification
@progress ~s|div#progress|
@pulsating_circle ~s|div#progress-indicator div.pulsating-circle|
@check_circle ~s|div#progress-indicator #check-circle|
@shuttle ~s|div#progress-indicator svg#shuttle|
@recommendations ~s|div#recommendations .recommendation|
test "renders initial state" do
html = render_component(@component, domain: "example.com")
assert element_exists?(html, @progress)
assert text_of_element(html, @progress) ==
"We're visiting your site to ensure that everything is working correctly"
assert element_exists?(html, ~s|a[href="/example.com/snippet"]|)
assert element_exists?(html, ~s|a[href="/example.com/settings/general"]|)
assert element_exists?(html, @pulsating_circle)
refute class_of_element(html, @pulsating_circle) =~ "hidden"
refute element_exists?(html, @recommendations)
refute element_exists?(html, @check_circle)
end
test "renders shuttle on error" do
html = render_component(@component, domain: "example.com", success?: false, finished?: true)
refute element_exists?(html, @pulsating_circle)
refute element_exists?(html, @check_circle)
refute element_exists?(html, @recommendations)
assert element_exists?(html, @shuttle)
end
test "renders diagnostic interpretation" do
interpretation =
Plausible.Verification.Checks.interpret_diagnostics(%Plausible.Verification.State{
url: "example.com"
})
html =
render_component(@component,
domain: "example.com",
success?: false,
finished?: true,
interpretation: interpretation
)
recommendations = html |> find(@recommendations) |> Enum.map(&text/1)
assert recommendations == [
"If your site is running at a different location, please manually check your integration - Learn more"
]
end
test "hides pulsating circle when finished in a modal, shows check circle" do
html =
render_component(@component,
domain: "example.com",
modal?: true,
success?: true,
finished?: true
)
assert class_of_element(html, @pulsating_circle) =~ "hidden"
assert element_exists?(html, @check_circle)
end
test "renders a progress message" do
html = render_component(@component, domain: "example.com", message: "Arbitrary message")
assert text_of_element(html, @progress) == "Arbitrary message"
end
@tag :ee_only
test "renders contact link on >3 attempts" do
html = render_component(@component, domain: "example.com", attempts: 2, finished?: true)
refute html =~ "Need further help with your integration?"
refute element_exists?(html, ~s|a[href="https://plausible.io/contact"]|)
html = render_component(@component, domain: "example.com", attempts: 3, finished?: true)
assert html =~ "Need further help with your integration?"
assert element_exists?(html, ~s|a[href="https://plausible.io/contact"]|)
end
end

View File

@ -84,8 +84,7 @@ defmodule PlausibleWeb.Live.Shields.HostnamesTest do
html = render(lv)
assert text(html) =~
"NB: Once added, we will start rejecting traffic from non-matching hostnames within a few minutes.
"
"NB: Once added, we will start rejecting traffic from non-matching hostnames within a few minutes."
refute text(html) =~ "we will start accepting"
end

View File

@ -0,0 +1,238 @@
defmodule PlausibleWeb.Live.VerificationTest do
use PlausibleWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Plausible.Test.Support.HTML
setup [:create_user, :log_in, :create_site]
@verify_button ~s|button#launch-verification-button[phx-click="launch-verification"]|
@verification_modal ~s|div#verification-modal|
@retry_button ~s|a[phx-click="retry"]|
@go_to_dashboard_button ~s|a[href$="?skip_to_dashboard=true"]|
@progress ~s|div#progress|
describe "GET /:domain" do
test "static verification screen renders", %{conn: conn, site: site} do
resp = conn |> no_slowdown() |> get("/#{site.domain}") |> html_response(200)
assert text_of_element(resp, @progress) =~
"We're visiting your site to ensure that everything is working correctly"
assert resp =~ "Verifying your integration"
assert resp =~ "on #{site.domain}"
assert resp =~ "Need to see the snippet again?"
assert resp =~ "Run verification later and go to Site Settings?"
refute resp =~ "modal"
refute element_exists?(resp, @verification_modal)
end
end
describe "GET /settings/general" do
test "verification elements render under the snippet", %{conn: conn, site: site} do
resp =
conn |> no_slowdown() |> get("/#{site.domain}/settings/general") |> html_response(200)
assert element_exists?(resp, @verify_button)
assert element_exists?(resp, @verification_modal)
end
end
describe "LiveView: standalone" do
test "LiveView mounts", %{conn: conn, site: site} do
stub_fetch_body(200, "")
stub_installation()
{_, html} = get_lv_standalone(conn, site)
assert html =~ "Verifying your integration"
assert html =~ "on #{site.domain}"
assert text_of_element(html, @progress) =~
"We're visiting your site to ensure that everything is working correctly"
end
test "eventually verifies installation", %{conn: conn, site: site} do
stub_fetch_body(200, source(site.domain))
stub_installation()
{:ok, lv} = kick_off_live_verification_standalone(conn, site)
assert eventually(fn ->
html = render(lv)
{
text_of_element(html, @progress) =~
"Awaiting your first pageview",
html
}
end)
html = render(lv)
assert html =~ "Success!"
assert html =~ "Your integration is working and visitors are being counted accurately"
end
test "eventually fails to verify installation", %{conn: conn, site: site} do
stub_fetch_body(200, "")
stub_installation(200, plausible_installed(false))
{:ok, lv} = kick_off_live_verification_standalone(conn, site)
assert html =
eventually(fn ->
html = render(lv)
{html =~ "", html}
{
text_of_element(html, @progress) =~
"We couldn't find the Plausible snippet on your site",
html
}
end)
refute element_exists?(html, @verification_modal)
assert element_exists?(html, @retry_button)
assert html =~ "Please insert the snippet into your site"
end
end
describe "LiveView: modal" do
test "LiveView mounts", %{conn: conn, site: site} do
stub_fetch_body(200, "")
stub_installation()
{_, html} = get_lv_modal(conn, site)
text = text(html)
refute text =~ "Need to see the snippet again?"
refute text =~ "Run verification later and go to Site Settings?"
assert element_exists?(html, @verification_modal)
end
test "Clicking the Verify modal launches verification", %{conn: conn, site: site} do
stub_fetch_body(200, source(site.domain))
stub_installation()
{lv, html} = get_lv_modal(conn, site)
assert element_exists?(html, @verification_modal)
assert element_exists?(html, @verify_button)
assert text_of_attr(html, @verify_button, "x-on:click") =~ "open-modal"
assert text_of_element(html, @progress) =~
"We're visiting your site to ensure that everything is working correctly"
lv |> element(@verify_button) |> render_click()
assert html =
eventually(fn ->
html = render(lv)
{
html =~ "Success!",
html
}
end)
refute html =~ "Awaiting your first pageview"
assert element_exists?(html, @go_to_dashboard_button)
end
test "failed verification can be retried", %{conn: conn, site: site} do
stub_fetch_body(200, "")
stub_installation(200, plausible_installed(false))
{lv, _html} = get_lv_modal(conn, site)
lv |> element(@verify_button) |> render_click()
assert html =
eventually(fn ->
html = render(lv)
{text_of_element(html, @progress) =~
"We couldn't find the Plausible snippet on your site", html}
end)
assert element_exists?(html, @retry_button)
stub_fetch_body(200, source(site.domain))
stub_installation()
lv |> element(@retry_button) |> render_click()
assert eventually(fn ->
html = render(lv)
{html =~ "Success!", html}
end)
end
end
defp get_lv_standalone(conn, site) do
conn = conn |> no_slowdown() |> assign(:live_module, PlausibleWeb.Live.Verification)
{:ok, lv, html} = live(conn, "/#{site.domain}")
{lv, html}
end
defp get_lv_modal(conn, site) do
conn = conn |> no_slowdown() |> assign(:live_module, PlausibleWeb.Live.Verification)
{:ok, lv, html} = live(no_slowdown(conn), "/#{site.domain}/settings/general")
{lv, html}
end
defp kick_off_live_verification_standalone(conn, site) do
{:ok, lv, _} =
live_isolated(conn, PlausibleWeb.Live.Verification,
session: %{
"domain" => site.domain,
"delay" => 0,
"slowdown" => 0
}
)
{:ok, lv}
end
defp no_slowdown(conn) do
Plug.Conn.put_private(conn, :verification_slowdown, 0)
end
defp stub_fetch_body(f) when is_function(f, 1) do
Req.Test.stub(Plausible.Verification.Checks.FetchBody, f)
end
defp stub_installation(f) when is_function(f, 1) do
Req.Test.stub(Plausible.Verification.Checks.Installation, f)
end
defp stub_fetch_body(status, body) do
stub_fetch_body(fn conn ->
conn
|> put_resp_content_type("text/html")
|> send_resp(status, body)
end)
end
defp stub_installation(status \\ 200, json \\ plausible_installed()) do
stub_installation(fn conn ->
conn
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(json))
end)
end
defp plausible_installed(bool \\ true, callback_status \\ 202) do
%{"data" => %{"plausibleInstalled" => bool, "callbackStatus" => callback_status}}
end
defp source(domain) do
"""
<head>
<script defer data-domain="#{domain}" src="http://localhost:8000/js/script.js"></script>
</head>
"""
end
end

View File

@ -38,6 +38,7 @@ defmodule Plausible.Test.Support.HTML do
element
|> Floki.text()
|> String.trim()
|> String.replace(~r/\s+/, " ")
end
def class_of_element(html, element) do

View File

@ -1,3 +1,5 @@
FunWithFlags.enable(:verification)
if not Enum.empty?(Path.wildcard("lib/**/*_test.exs")) do
raise "Oops, test(s) found in `lib/` directory. Move them to `test/`."
end

View File

@ -33,7 +33,7 @@
if (/^localhost$|^127(\.[0-9]+){0,2}\.[0-9]+$|^\[::1?\]$/.test(location.hostname) || location.protocol === 'file:') {
return onIgnoredEvent('localhost', options)
}
if (window._phantom || window.__nightmare || window.navigator.webdriver || window.Cypress) {
if ((window._phantom || window.__nightmare || window.navigator.webdriver || window.Cypress) && !window.__plausible) {
return onIgnoredEvent(null, options)
}
{{/unless}}
@ -115,7 +115,7 @@
request.onreadystatechange = function() {
if (request.readyState === 4) {
options && options.callback && options.callback()
options && options.callback && options.callback({status: request.status})
}
}
}