defmodule Plausible.ConfigTest do use ExUnit.Case import Plausible.ConfigHelpers describe "get_bool_from_path_or_env/3" do test "parses truthy vars" do truthy = ["1", "t", "true", "y", "yes", "on"] for var <- truthy do env = [{"ENABLE_EMAIL_VERIFICATION", var}] config = runtime_config(env) assert get_in(config, [:plausible, :selfhost, :enable_email_verification]) == true end end test "parses false vars" do falsy = ["0", "f", "false", "n", "no", "off"] for var <- falsy do env = [{"ENABLE_EMAIL_VERIFICATION", var}] config = runtime_config(env) assert get_in(config, [:plausible, :selfhost, :enable_email_verification]) == false end end test "supports defaults" do put_system_env_undo([{"ENABLE_EMAIL_VERIFICATION", nil}]) config_dir = "/run/secrets" assert get_bool_from_path_or_env(config_dir, "ENABLE_EMAIL_VERIFICATION") == nil assert get_bool_from_path_or_env(config_dir, "ENABLE_EMAIL_VERIFICATION", true) == true assert get_bool_from_path_or_env(config_dir, "ENABLE_EMAIL_VERIFICATION", false) == false end test "raises on invalid var" do env = [{"ENABLE_EMAIL_VERIFICATION", "YOLO"}] assert_raise ArgumentError, "Invalid boolean value: \"YOLO\". Expected one of: 1, 0, t, f, true, false, y, n, yes, no, on, off", fn -> runtime_config(env) end end end describe "mailer" do test "mailer email default" do env = [{"MAILER_EMAIL", nil}] assert get_in(runtime_config(env), [:plausible, :mailer_email]) == "plausible@localhost" end test "mailer email from base url" do env = [{"MAILER_EMAIL", nil}, {"BASE_URL", "https://plausible.example.com:8443"}] assert get_in(runtime_config(env), [:plausible, :mailer_email]) == "plausible@plausible.example.com" end test "mailer email custom" do env = [{"MAILER_EMAIL", "custom@mailer.email"}] assert get_in(runtime_config(env), [:plausible, :mailer_email]) == "custom@mailer.email" end test "mailer name" do env = [{"MAILER_EMAIL", nil}, {"MAILER_NAME", "John"}] assert get_in(runtime_config(env), [:plausible, :mailer_email]) == {"John", "plausible@localhost"} env = [{"MAILER_EMAIL", "custom@mailer.email"}, {"MAILER_NAME", "John"}] assert get_in(runtime_config(env), [:plausible, :mailer_email]) == {"John", "custom@mailer.email"} end test "defaults to Bamboo.Mua" do env = {"MAILER_ADAPTER", nil} assert get_in(runtime_config(env), [:plausible, Plausible.Mailer]) == [ adapter: Bamboo.Mua, ssl: [middlebox_comp_mode: false] ] end test "Bamboo.PostmarkAdapter" do env = [ {"MAILER_ADAPTER", "Bamboo.PostmarkAdapter"}, {"POSTMARK_API_KEY", "some-postmark-key"} ] assert get_in(runtime_config(env), [:plausible, Plausible.Mailer]) == [ adapter: Bamboo.PostmarkAdapter, request_options: [recv_timeout: 10_000], api_key: "some-postmark-key" ] end test "Bamboo.MailgunAdapter" do env = [ {"MAILER_ADAPTER", "Bamboo.MailgunAdapter"}, {"MAILGUN_API_KEY", "some-mailgun-key"}, {"MAILGUN_DOMAIN", "example.com"} ] assert get_in(runtime_config(env), [:plausible, Plausible.Mailer]) == [ adapter: Bamboo.MailgunAdapter, hackney_opts: [recv_timeout: 10_000], api_key: "some-mailgun-key", domain: "example.com" ] end test "Bamboo.MailgunAdapter with custom MAILGUN_BASE_URI" do env = [ {"MAILER_ADAPTER", "Bamboo.MailgunAdapter"}, {"MAILGUN_API_KEY", "some-mailgun-key"}, {"MAILGUN_DOMAIN", "example.com"}, {"MAILGUN_BASE_URI", "https://api.eu.mailgun.net/v3"} ] assert get_in(runtime_config(env), [:plausible, Plausible.Mailer]) == [ adapter: Bamboo.MailgunAdapter, hackney_opts: [recv_timeout: 10_000], api_key: "some-mailgun-key", domain: "example.com", base_uri: "https://api.eu.mailgun.net/v3" ] end test "Bamboo.MandrillAdapter" do env = [ {"MAILER_ADAPTER", "Bamboo.MandrillAdapter"}, {"MANDRILL_API_KEY", "some-mandrill-key"} ] assert get_in(runtime_config(env), [:plausible, Plausible.Mailer]) == [ adapter: Bamboo.MandrillAdapter, hackney_opts: [recv_timeout: 10_000], api_key: "some-mandrill-key" ] end test "Bamboo.SendGridAdapter" do env = [ {"MAILER_ADAPTER", "Bamboo.SendGridAdapter"}, {"SENDGRID_API_KEY", "some-sendgrid-key"} ] assert get_in(runtime_config(env), [:plausible, Plausible.Mailer]) == [ adapter: Bamboo.SendGridAdapter, hackney_opts: [recv_timeout: 10_000], api_key: "some-sendgrid-key" ] end test "Bamboo.Mua (no config)" do env = [{"MAILER_ADAPTER", "Bamboo.Mua"}] assert get_in(runtime_config(env), [:plausible, Plausible.Mailer]) == [ {:adapter, Bamboo.Mua}, {:ssl, [middlebox_comp_mode: false]} ] end test "Bamboo.Mua (middlebox_comp_mode enabled)" do env = [{"MAILER_ADAPTER", "Bamboo.Mua"}, {"SMTP_MIDDLEBOX_COMP_MODE", "true"}] assert get_in(runtime_config(env), [:plausible, Plausible.Mailer]) == [ {:adapter, Bamboo.Mua}, {:ssl, [middlebox_comp_mode: true]} ] end test "Bamboo.Mua (relay config)" do env = [ {"MAILER_ADAPTER", "Bamboo.Mua"}, {"SMTP_HOST_ADDR", "localhost"}, {"SMTP_HOST_PORT", "2525"}, {"SMTP_USER_NAME", "neo"}, {"SMTP_USER_PWD", "one"} ] assert get_in(runtime_config(env), [:plausible, Plausible.Mailer]) == [ {:adapter, Bamboo.Mua}, {:ssl, [middlebox_comp_mode: false]}, {:protocol, :tcp}, {:relay, "localhost"}, {:port, 2525}, {:auth, [username: "neo", password: "one"]} ] end test "Bamboo.Mua (ssl relay config)" do env = [ {"MAILER_ADAPTER", "Bamboo.Mua"}, {"SMTP_HOST_ADDR", "localhost"}, {"SMTP_HOST_PORT", "2525"}, {"SMTP_HOST_SSL_ENABLED", "true"}, {"SMTP_USER_NAME", "neo"}, {"SMTP_USER_PWD", "one"} ] assert get_in(runtime_config(env), [:plausible, Plausible.Mailer]) == [ {:adapter, Bamboo.Mua}, {:ssl, [middlebox_comp_mode: false]}, {:protocol, :ssl}, {:relay, "localhost"}, {:port, 2525}, {:auth, [username: "neo", password: "one"]} ] end test "Bamboo.Mua (port=465 relay config)" do env = [ {"MAILER_ADAPTER", "Bamboo.Mua"}, {"SMTP_HOST_ADDR", "localhost"}, {"SMTP_HOST_PORT", "465"}, {"SMTP_USER_NAME", "neo"}, {"SMTP_USER_PWD", "one"} ] assert get_in(runtime_config(env), [:plausible, Plausible.Mailer]) == [ {:adapter, Bamboo.Mua}, {:ssl, [middlebox_comp_mode: false]}, {:protocol, :ssl}, {:relay, "localhost"}, {:port, 465}, {:auth, [username: "neo", password: "one"]} ] end test "Bamboo.Mua (no auth relay config)" do env = [ {"MAILER_ADAPTER", "Bamboo.Mua"}, {"SMTP_HOST_ADDR", "localhost"}, {"SMTP_HOST_PORT", "2525"}, {"SMTP_USER_NAME", nil}, {"SMTP_USER_PWD", nil} ] assert get_in(runtime_config(env), [:plausible, Plausible.Mailer]) == [ adapter: Bamboo.Mua, ssl: [middlebox_comp_mode: false], protocol: :tcp, relay: "localhost", port: 2525 ] end test "unknown adapter raises" do env = {"MAILER_ADAPTER", "Bamboo.FakeAdapter"} assert_raise ArgumentError, ~r/Unknown mailer_adapter: "Bamboo.FakeAdapter"/, fn -> runtime_config(env) end end end describe "log_failed_login_attempts" do test "can be true" do env = {"LOG_FAILED_LOGIN_ATTEMPTS", "true"} assert get_in(runtime_config(env), [:plausible, :log_failed_login_attempts]) == true end test "can be false" do env = {"LOG_FAILED_LOGIN_ATTEMPTS", "false"} assert get_in(runtime_config(env), [:plausible, :log_failed_login_attempts]) == false end test "is false by default" do env = {"LOG_FAILED_LOGIN_ATTEMPTS", nil} assert get_in(runtime_config(env), [:plausible, :log_failed_login_attempts]) == false end end describe "s3" do test "has required env vars" do env = [ {"S3_ACCESS_KEY_ID", nil}, {"S3_SECRET_ACCESS_KEY", nil}, {"S3_REGION", nil}, {"S3_ENDPOINT", nil}, {"S3_EXPORTS_BUCKET", nil}, {"S3_IMPORTS_BUCKET", nil} ] result = try do runtime_config(env) rescue e -> e end assert %ArgumentError{} = result assert Exception.message(result) == """ Missing S3 configuration. Please set S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_REGION, S3_ENDPOINT, S3_EXPORTS_BUCKET, S3_IMPORTS_BUCKET environment variable(s): \tS3_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE \tS3_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY \tS3_REGION=us-east-1 \tS3_ENDPOINT=https://.r2.cloudflarestorage.com \tS3_EXPORTS_BUCKET=my-csv-exports-bucket \tS3_IMPORTS_BUCKET=my-csv-imports-bucket """ end test "renders only missing env vars" do env = [ {"S3_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE"}, {"S3_SECRET_ACCESS_KEY", nil}, {"S3_REGION", "eu-north-1"}, {"S3_ENDPOINT", nil}, {"S3_EXPORTS_BUCKET", "my-exports"}, {"S3_IMPORTS_BUCKET", nil} ] result = try do runtime_config(env) rescue e -> e end assert %ArgumentError{} = result assert Exception.message(result) == """ Missing S3 configuration. Please set S3_SECRET_ACCESS_KEY, S3_ENDPOINT, S3_IMPORTS_BUCKET environment variable(s): \tS3_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY \tS3_ENDPOINT=https://.r2.cloudflarestorage.com \tS3_IMPORTS_BUCKET=my-csv-imports-bucket """ end test "works when everything is set" do env = [ {"S3_ACCESS_KEY_ID", "minioadmin"}, {"S3_SECRET_ACCESS_KEY", "minioadmin"}, {"S3_REGION", "us-east-1"}, {"S3_ENDPOINT", "http://localhost:6000"}, {"S3_EXPORTS_BUCKET", "my-exports"}, {"S3_IMPORTS_BUCKET", "my-imports"} ] config = runtime_config(env) assert config[:ex_aws] == [ http_client: Plausible.S3.Client, access_key_id: "minioadmin", secret_access_key: "minioadmin", region: "us-east-1", s3: [scheme: "http://", host: "localhost", port: 6000] ] assert get_in(config, [:plausible, Plausible.S3]) == [ exports_bucket: "my-exports", imports_bucket: "my-imports" ] end end describe "storage" do setup do defaults = [ # comes from our Dockerfile {"DEFAULT_DATA_DIR", "/var/lib/plausible"}, # needed to exercise Plausible.Geo :cache_dir {"MAXMIND_LICENSE_KEY", "abc"} ] env = fn env -> env ++ defaults end {:ok, env: env} end test "defaults", %{env: env} do config = runtime_config(env.([{"PERSISTENT_CACHE_DIR", nil}, {"DATA_DIR", nil}])) # exports/imports assert get_in(config, [:plausible, :data_dir]) == "/var/lib/plausible" # locus (mmdb cache) assert get_in(config, [:plausible, Plausible.Geo, :cache_dir]) == "/var/lib/plausible" # tzdata (timezones cache) assert get_in(config, [:tzdata, :data_dir]) == "/var/lib/plausible/tzdata_data" # session transfer assert get_in(config, [:plausible, :session_transfer_dir]) == "/var/lib/plausible/sessions" end test "with only DATA_DIR set", %{env: env} do config = runtime_config(env.([{"PERSISTENT_CACHE_DIR", nil}, {"DATA_DIR", "/data"}])) # exports/imports assert get_in(config, [:plausible, :data_dir]) == "/data" # locus (mmdb cache) assert get_in(config, [:plausible, Plausible.Geo, :cache_dir]) == "/data" # tzdata (timezones cache) assert get_in(config, [:tzdata, :data_dir]) == "/data/tzdata_data" # session transfer assert get_in(config, [:plausible, :session_transfer_dir]) == "/data/sessions" end test "with only PERSISTENT_CACHE_DIR set", %{env: env} do config = runtime_config(env.([{"PERSISTENT_CACHE_DIR", "/cache"}, {"DATA_DIR", nil}])) # exports/imports assert get_in(config, [:plausible, :data_dir]) == "/cache" # locus (mmdb cache) assert get_in(config, [:plausible, Plausible.Geo, :cache_dir]) == "/cache" # tzdata (timezones cache) assert get_in(config, [:tzdata, :data_dir]) == "/cache/tzdata_data" # session transfer assert get_in(config, [:plausible, :session_transfer_dir]) == "/cache/sessions" end test "with both DATA_DIR and PERSISTENT_CACHE_DIR set", %{env: env} do config = runtime_config(env.([{"PERSISTENT_CACHE_DIR", "/cache"}, {"DATA_DIR", "/data"}])) # exports/imports assert get_in(config, [:plausible, :data_dir]) == "/data" # locus (mmdb cache) assert get_in(config, [:plausible, Plausible.Geo, :cache_dir]) == "/cache" # tzdata (timezones cache) assert get_in(config, [:tzdata, :data_dir]) == "/cache/tzdata_data" # session transfer assert get_in(config, [:plausible, :session_transfer_dir]) == "/cache/sessions" end end describe "postgres" do test "default" do env = [{"DATABASE_URL", nil}] config = runtime_config(env) assert get_in(config, [:plausible, Plausible.Repo]) == [ url: "postgres://postgres:postgres@plausible_db:5432/plausible_db", ssl: false ] end test "socket_dir in hostname" do env = [{"DATABASE_URL", "postgresql://postgres:postgres@%2Frun%2Fpostgresql/plausible_db"}] config = runtime_config(env) assert get_in(config, [:plausible, Plausible.Repo]) == [ socket_dir: "/run/postgresql", database: "plausible_db", username: "postgres", password: "postgres" ] end test "socket_dir in query" do env = [{"DATABASE_URL", "postgresql:///plausible_db?host=/run/postgresql"}] config = runtime_config(env) assert get_in(config, [:plausible, Plausible.Repo]) == [ socket_dir: "/run/postgresql", database: "plausible_db" ] end test "socket_dir missing" do env = [{"DATABASE_URL", "postgresql:///plausible_db"}] assert_raise ArgumentError, ~r/doesn't include host info/, fn -> runtime_config(env) end end test "custom URL" do env = [ {"DATABASE_URL", "postgresql://your_username:your_password@cluster-do-user-1234567-0.db.ondigitalocean.com:25060/defaultdb"} ] config = runtime_config(env) assert get_in(config, [:plausible, Plausible.Repo]) == [ url: "postgresql://your_username:your_password@cluster-do-user-1234567-0.db.ondigitalocean.com:25060/defaultdb", ssl: false ] end test "DATABASE_CACERTFILE enables SSL" do env = [ {"DATABASE_URL", "postgresql://your_username:your_password@cluster-do-user-1234567-0.db.ondigitalocean.com:25060/defaultdb"}, {"DATABASE_CACERTFILE", "/path/to/cacert.pem"} ] config = runtime_config(env) assert get_in(config, [:plausible, Plausible.Repo]) == [ url: "postgresql://your_username:your_password@cluster-do-user-1234567-0.db.ondigitalocean.com:25060/defaultdb", ssl: [cacertfile: "/path/to/cacert.pem", verify: :verify_peer] ] end test "sslmode=require disables peer verification" do env = [ {"DATABASE_URL", "postgresql://username:password@company.postgres.database.azure.com:5432/prod_plausible?sslmode=require"} ] config = runtime_config(env) assert get_in(config, [:plausible, Plausible.Repo]) == [ url: "postgresql://username:password@company.postgres.database.azure.com:5432/prod_plausible?sslmode=require", ssl: [verify: :verify_none] ] end test "sslmode=disable explicitly disables SSL" do env = [ {"DATABASE_URL", "postgresql://username:password@company.postgres.database.azure.com:5432/prod_plausible?sslmode=disable"} ] config = runtime_config(env) assert get_in(config, [:plausible, Plausible.Repo]) == [ url: "postgresql://username:password@company.postgres.database.azure.com:5432/prod_plausible?sslmode=disable", ssl: false ] end test "sslmode=verify-ca uses system certificates" do env = [ {"DATABASE_URL", "postgresql://username:password@company.postgres.database.azure.com:5432/prod_plausible?sslmode=verify-ca"} ] config = runtime_config(env) assert get_in(config, [:plausible, Plausible.Repo]) == [ url: "postgresql://username:password@company.postgres.database.azure.com:5432/prod_plausible?sslmode=verify-ca", ssl: [ cacerts: :public_key.cacerts_get(), verify: :verify_peer ] ] end test "sslmode=verify-full raises error without certificate" do env = [ {"DATABASE_URL", "postgresql://username:password@company.postgres.database.azure.com:5432/prod_plausible?sslmode=verify-full"} ] assert_raise ArgumentError, ~r/PostgreSQL SSL mode `sslmode=verify-full` requires a certificate/, fn -> runtime_config(env) end end test "unsupported sslmode raises error" do env = [ {"DATABASE_URL", "postgresql://username:password@company.postgres.database.azure.com:5432/prod_plausible?sslmode=prefer"} ] assert_raise ArgumentError, ~r/PostgreSQL SSL mode `sslmode=prefer` is not supported/, fn -> runtime_config(env) end end test "verify-full with sslrootcert enables peer verification" do env = [ {"DATABASE_URL", "postgresql://username:password@company.postgres.database.azure.com:5432/prod_plausible?sslmode=verify-full&sslrootcert=/path/to/cert.pem"} ] config = runtime_config(env) assert get_in(config, [:plausible, Plausible.Repo]) == [ url: "postgresql://username:password@company.postgres.database.azure.com:5432/prod_plausible?sslmode=verify-full&sslrootcert=/path/to/cert.pem", ssl: [cacertfile: "/path/to/cert.pem", verify: :verify_peer] ] end test "sslrootcert alone is not enough for peer verification" do env = [ {"DATABASE_URL", "postgresql://username:password@company.postgres.database.azure.com:5432/prod_plausible?sslrootcert=/path/to/cert.pem"} ] config = runtime_config(env) assert get_in(config, [:plausible, Plausible.Repo]) == [ url: "postgresql://username:password@company.postgres.database.azure.com:5432/prod_plausible?sslrootcert=/path/to/cert.pem", ssl: false ] end test "DATABASE_CACERTFILE takes precedence over sslrootcert" do env = [ {"DATABASE_URL", "postgresql://username:password@company.postgres.database.azure.com:5432/prod_plausible?sslrootcert=/url/cert.pem"}, {"DATABASE_CACERTFILE", "/env/cacert.pem"} ] config = runtime_config(env) assert get_in(config, [:plausible, Plausible.Repo]) == [ url: "postgresql://username:password@company.postgres.database.azure.com:5432/prod_plausible?sslrootcert=/url/cert.pem", ssl: [cacertfile: "/env/cacert.pem", verify: :verify_peer] ] end end describe "extra config" do test "no-op when no extra path is set" do put_system_env_undo({"EXTRA_CONFIG_PATH", nil}) assert Config.Reader.read!("rel/overlays/import_extra_config.exs") == [] end test "raises if path is invalid" do put_system_env_undo({"EXTRA_CONFIG_PATH", "no-such-file"}) assert_raise File.Error, ~r/could not read file/, fn -> Config.Reader.read!("rel/overlays/import_extra_config.exs") end end @tag :tmp_dir test "reads extra config", %{tmp_dir: tmp_dir} do extra_config_path = Path.join(tmp_dir, "config.exs") File.write!(extra_config_path, """ import Config config :plausible, Plausible.Repo, after_connect: {Postgrex, :query!, ["SET search_path TO global_prefix", []]} """) put_system_env_undo({"EXTRA_CONFIG_PATH", extra_config_path}) assert Config.Reader.read!("rel/overlays/import_extra_config.exs") == [ {:plausible, [ {Plausible.Repo, [after_connect: {Postgrex, :query!, ["SET search_path TO global_prefix", []]}]} ]} ] end end describe "totp" do test "pbkdf2 if not set" do env = [ {"TOTP_VAULT_KEY", nil} ] config = runtime_config(env) assert keys = get_in(config, [:plausible, Plausible.Auth.TOTP]) vault_key = keys[:vault_key] fallback_vault_key = keys[:fallback_vault_key] assert byte_size(vault_key) == 32 # make sure it doesn't change between releases assert vault_key == "\x95\x9C\x05\x9A\xCD\xE4\xEF\xDDH\xFB\xCA\xD5o\xD1z\xCCTǐ\xBC\"J\xF8:\xFAs\xCA\x0Fo\x10\x9B\x84" assert vault_key == fallback_vault_key end test "can be Base64-encoded 32 bytes (with padding)" do # $ openssl rand -base64 32 # dx2W6PNd/QIC6IyYVWMEaG2fI8/5WVylryM3mRaOpAo= env = [ {"TOTP_VAULT_KEY", "dx2W6PNd/QIC6IyYVWMEaG2fI8/5WVylryM3mRaOpAo="} ] config = runtime_config(env) assert keys = get_in(config, [:plausible, Plausible.Auth.TOTP]) vault_key = keys[:vault_key] fallback_vault_key = keys[:fallback_vault_key] assert byte_size(vault_key) == 32 assert vault_key == Base.decode64!("dx2W6PNd/QIC6IyYVWMEaG2fI8/5WVylryM3mRaOpAo=") assert vault_key == fallback_vault_key end test "fails on invalid key length" do assert_raise ArgumentError, ~r/Got Base64 encoded 31 bytes/, fn -> runtime_config(_env = [{"TOTP_VAULT_KEY", Base.encode64(:crypto.strong_rand_bytes(31))}]) end assert_raise ArgumentError, ~r/Got Base64 encoded 33 bytes/, fn -> runtime_config(_env = [{"TOTP_VAULT_KEY", Base.encode64(:crypto.strong_rand_bytes(33))}]) end end test "fails on invalid encoding" do assert_raise ArgumentError, ~r/TOTP_VAULT_KEY must be Base64 encoded/, fn -> runtime_config( _env = [ {"TOTP_VAULT_KEY", "openssl" <> Base.encode64(:crypto.strong_rand_bytes(32))} ] ) end end end defp runtime_config(env) do put_system_env_undo(env) Config.Reader.read!("config/runtime.exs", env: :prod) end defp put_system_env_undo(env) do before = System.get_env() {to_delete, to_put} = Enum.split_with(List.wrap(env), fn {_, v} -> is_nil(v) end) Enum.each(to_delete, fn {k, _} -> System.delete_env(k) end) System.put_env(to_put) on_exit(fn -> Enum.each(System.get_env(), fn {k, _} -> System.delete_env(k) end) System.put_env(before) end) end end