use std::path::PathBuf;
use sofie_auth::SIGNING_KEYS;
use sofie_model::{App, ConfigKey, DATABASE_URL, KeyKind, Module};
use crate::gen_command::{GenError, read_existing};
pub(crate) const ENV_HEADER: &str = "# @generated by `sofie gen`; do not edit.\n";
const FRAMEWORK_KEYS: [(&str, KeyKind, &str); 50] = [
(
DATABASE_URL,
KeyKind::Text,
"The app's database, and every module's that sets none of its own.",
),
(
SIGNING_KEYS,
KeyKind::Text,
"Comma-separated base64 keys of 32 bytes sealing sessions and signing links; the first seals. Read as `SOFIE_SESSION_KEYS` too, with a warning.",
),
(
BASE_URL,
KeyKind::Text,
"Where the app is reached, such as `https://example.com`, which mails, checkouts, OAuth and pages link back to; each package's own base URL overrides it.",
),
(
ENVIRONMENT,
KeyKind::Text,
"The environment feature flags are narrowed to, such as `production`; an app with flags needs it.",
),
(
"SOFIE_ADDRESS",
KeyKind::Address,
"Where the web role listens; 127.0.0.1:8080 when unset.",
),
(
"SOFIE_TRUSTED_PROXIES",
KeyKind::Text,
"Comma-separated addresses or CIDR ranges of the proxies that may tell the client's address; none when unset.",
),
(
"SOFIE_REQUEST_ID_HEADER",
KeyKind::Text,
"The header a trusted proxy names its own request id in, logged as `edge_request_id` beside the server's; `x-request-id` when unset.",
),
(
"SOFIE_CORS_ORIGINS",
KeyKind::Text,
"With the `cors` feature: comma-separated `area=origin`, such as `api=https://app.example.com`, added to each area's own origins; none when unset.",
),
(
"SOFIE_TLS_CERTIFICATE",
KeyKind::Text,
"With the `tls` feature: the PEM file holding the certificate chain, leaf first; set with `SOFIE_TLS_KEY`, or neither for plain HTTP.",
),
(
"SOFIE_TLS_KEY",
KeyKind::Text,
"With the `tls` feature: the PEM file holding the certificate's PKCS#8, PKCS#1 or SEC1 key.",
),
(
"SOFIE_SHIELD",
KeyKind::Text,
"With the `shield` feature: `on`, `dry-run` (reports only) or `off` (tests, benchmarks, fuzzing); dry-run in a debug build and on in a release one when unset.",
),
(
"SOFIE_SHIELD_THRESHOLDS",
KeyKind::Text,
"With the `shield` feature: comma-separated `rule=count` or `area.rule=count`, such as `scan=60, web.probe=10`; each rule's default when unset.",
),
(
"SOFIE_SHIELD_SHARED",
KeyKind::Switch,
"With the `shield` feature: `on` shares blocks between instances through the database; `off` when unset.",
),
(
"SOFIE_METRICS_TOKEN",
KeyKind::Text,
"With the `metrics` feature: the bearer token, at least 32 characters, that `/metrics` asks for; without it `/metrics` does not exist.",
),
(
"SOFIE_MIGRATE",
KeyKind::Switch,
"`on` migrates the database when the process starts; `off` when unset.",
),
(
"SOFIE_TIME_ZONE",
KeyKind::Text,
"The IANA time zone instants are shown in, such as `Europe/Istanbul`; UTC when unset.",
),
(
"SOFIE_LOG",
KeyKind::Text,
"Which events are logged, such as `info,sofie_http=debug`; `info` when unset.",
),
(
"SOFIE_LOG_SAMPLE",
KeyKind::Decimal,
"The share of traces, from 0 to 1, whose info lines are logged; warnings and errors always are; 1 when unset.",
),
(
"SOFIE_TRACE_SAMPLE",
KeyKind::Decimal,
"With the `otlp` feature: the share of traces, from 0 to 1, that are exported; 1 when unset.",
),
(
"SOFIE_BROWSER_SAMPLE",
KeyKind::Decimal,
"With the `browser-telemetry` feature: the share of browser reports, from 0 to 1, that are kept; 1 when unset.",
),
(
"SOFIE_PRODUCT_EVENTS_KEEP_DAYS",
KeyKind::Count,
"With the `product-reports` feature: days product events are kept; 400 when unset.",
),
(
"SOFIE_RUNTIME_THREADS",
KeyKind::Count,
"Threads running requests and jobs; one per logical CPU when unset.",
),
(
"SOFIE_DB_POOL_SIZE",
KeyKind::Count,
"Database connections the process keeps open at most; twice the runtime threads when unset.",
),
(
"SOFIE_DB_POOL_WAIT_MS",
KeyKind::Count,
"Milliseconds a request waits for a free connection before it is answered 503; 5000 when unset.",
),
(
"SOFIE_WORKER_BATCH",
KeyKind::Count,
"Messages the worker claims and handles at once; 2 when unset.",
),
(
"SOFIE_WORKER_POOL_SIZE",
KeyKind::Count,
"Connections of a pool of the worker's own, so jobs cannot take the requests' connections; the app's pool when unset.",
),
(
"SOFIE_CACHE_STORE",
KeyKind::Text,
"With the `cache` feature: `memory`, `database` (with `cache-database`) or `redis` (with `cache-redis`); `memory` when unset.",
),
(
"SOFIE_REDIS_URL",
KeyKind::Text,
"With `SOFIE_CACHE_STORE=redis`: `redis://[user:password@]host[:port][/database]`, or `rediss://` for TLS.",
),
(
"SOFIE_SEARCH",
KeyKind::Text,
"With the `search-meilisearch` feature: `database` or `meilisearch`, the engine searched records are matched by; `database` when unset.",
),
(
"SOFIE_SEARCH_MEILISEARCH_URL",
KeyKind::Text,
"With `SOFIE_SEARCH=meilisearch`: where Meilisearch answers, such as `https://search.example.com`.",
),
(
"SOFIE_SEARCH_MEILISEARCH_API_KEY",
KeyKind::Text,
"With `SOFIE_SEARCH=meilisearch`: the key Meilisearch asks for, allowed to search and to write documents and settings; none when unset.",
),
(
"SOFIE_SEARCH_MEILISEARCH_PRIVATE",
KeyKind::Switch,
"With `SOFIE_SEARCH=meilisearch`: `on` lets it be a private address spoken to over plain HTTP, for a Meilisearch inside the network; `off` when unset.",
),
(
"SOFIE_STORAGE",
KeyKind::Text,
"With the `storage` feature: `local`, or `s3` (with `storage-s3`), where stored files are kept; `local` when unset.",
),
(
"SOFIE_STORAGE_ROOT",
KeyKind::Text,
"With `SOFIE_STORAGE=local`: the directory stored files are kept in; `storage` when unset.",
),
(
"SOFIE_STORAGE_S3_ENDPOINT",
KeyKind::Text,
"With `SOFIE_STORAGE=s3`: where an S3-compatible service answers; AWS's endpoint for the region when unset.",
),
(
"SOFIE_STORAGE_S3_REGION",
KeyKind::Text,
"With `SOFIE_STORAGE=s3`: the bucket's region, such as `eu-central-1`.",
),
(
"SOFIE_STORAGE_S3_BUCKET",
KeyKind::Text,
"With `SOFIE_STORAGE=s3`: the bucket stored files are kept in.",
),
(
"SOFIE_STORAGE_S3_ACCESS_KEY_ID",
KeyKind::Text,
"With `SOFIE_STORAGE=s3`: the access key's id.",
),
(
"SOFIE_STORAGE_S3_SECRET_ACCESS_KEY",
KeyKind::Text,
"With `SOFIE_STORAGE=s3`: the access key's secret.",
),
(
"SOFIE_STORAGE_S3_PATH_STYLE",
KeyKind::Switch,
"With `SOFIE_STORAGE=s3`: `on` names the bucket in the path rather than the host, as MinIO asks; `off` when unset.",
),
(
"SOFIE_STORAGE_S3_PRIVATE",
KeyKind::Switch,
"With `SOFIE_STORAGE=s3`: `on` lets it be a private address spoken to over plain HTTP, for a service inside the network; `off` when unset.",
),
(
"SOFIE_BILLING_PROVIDER",
KeyKind::Text,
"With the `billing` feature: `stripe` or `paddle`, the provider payments go through; none when unset, and plans, trials and quotas still work.",
),
(
"SOFIE_BILLING_API_KEY",
KeyKind::Text,
"With `SOFIE_BILLING_PROVIDER`: the provider's API key.",
),
(
"SOFIE_BILLING_WEBHOOK_SECRET",
KeyKind::Text,
"With `SOFIE_BILLING_PROVIDER`: the secret the provider signs its webhooks with.",
),
(
"SOFIE_BILLING_BASE_URL",
KeyKind::Text,
"With `SOFIE_BILLING_PROVIDER`: where checkouts return to, such as `https://example.com`.",
),
(
"SOFIE_BILLING_PRICES",
KeyKind::Text,
"With `SOFIE_BILLING_PROVIDER`: comma-separated `plan or product=price id`, such as `pro=price_123,credits=price_456`.",
),
(
"SOFIE_BILLING_API_URL",
KeyKind::Text,
"With `SOFIE_BILLING_PROVIDER`: where the provider's API answers; the provider's own when unset.",
),
(
"SOFIE_NOTIFY_VAPID_PUBLIC_KEY",
KeyKind::Text,
"With the `notify` feature: the base64url VAPID public key web push is sent with; set all three VAPID keys or none, and none sends no web push.",
),
(
"SOFIE_NOTIFY_VAPID_PRIVATE_KEY",
KeyKind::Text,
"With the `notify` feature: the base64url VAPID private key.",
),
(
"SOFIE_NOTIFY_VAPID_SUBJECT",
KeyKind::Text,
"With the `notify` feature: the contact push services may reach, such as `mailto:ops@example.com`.",
),
];
const VENDOR_KEYS: [(&str, &str, KeyKind); 8] = [
(
"BASE_URL",
"Such as `https://api.stripe.com`: requests name paths below it; a vendor spoken to over HTTP needs it.",
KeyKind::Text,
),
(
"HOSTS",
"Further hosts the vendor uses, separated by commas.",
KeyKind::Text,
),
(
"PRIVATE",
"`true` lets its hosts be private addresses spoken to over plain HTTP, for a service inside the network; `false` when unset.",
KeyKind::Boolean,
),
(
"CONNECT_TIMEOUT_MS",
"Milliseconds a connection may take; 5000 when unset.",
KeyKind::Count,
),
(
"TIMEOUT_MS",
"Milliseconds a request may take in all; 30000 when unset.",
KeyKind::Count,
),
(
"MAX_RESPONSE_BYTES",
"The largest response body read; 4 MiB when unset.",
KeyKind::Count,
),
(
"RETRIES",
"How often an idempotent request is tried again after a failure; 2 when unset.",
KeyKind::Count,
),
(
"REDIRECTS",
"How many redirects a `GET` follows; none when unset.",
KeyKind::Count,
),
];
const MAIL_KEYS: [(&str, KeyKind, bool, &str); 12] = [
(
"SOFIE_MAIL_TRANSPORT",
KeyKind::Text,
true,
"`smtp`, or `log` in a debug build.",
),
(
"SOFIE_MAIL_FROM",
KeyKind::Text,
true,
"The address mails are sent from unless a mail names its sender.",
),
(
"SOFIE_MAIL_BASE_URL",
KeyKind::Text,
false,
"Where links in mails point, such as `https://shop.example.com`; required by a mail that links back.",
),
(
"SOFIE_MAIL_SMTP_HOST",
KeyKind::Text,
false,
"With `SOFIE_MAIL_TRANSPORT=smtp`: the relay's host.",
),
(
"SOFIE_MAIL_SMTP_PORT",
KeyKind::Count,
false,
"With `smtp`: the relay's port; 587 when unset, 465 with `tls`, 25 with `none`.",
),
(
"SOFIE_MAIL_SMTP_SECURITY",
KeyKind::Text,
false,
"With `smtp`: `starttls`, `tls` or `none`; `starttls` when unset.",
),
(
"SOFIE_MAIL_SMTP_USERNAME",
KeyKind::Text,
false,
"With `smtp`: the relay's user, set together with the password.",
),
(
"SOFIE_MAIL_SMTP_PASSWORD",
KeyKind::Text,
false,
"With `smtp`: the relay's password.",
),
(
"SOFIE_MAIL_PREVIEW_DIR",
KeyKind::Text,
false,
"With `log`: where mails are written to open; `target/mail-preview` when unset, `off` for none.",
),
(
"SOFIE_MAIL_POSTMARK_WEBHOOK_AUTH",
KeyKind::Text,
false,
"With `sofie::mail::web::routes`: the `user:password` Postmark's bounce webhook URL carries; no Postmark route when unset.",
),
(
"SOFIE_MAIL_MAILGUN_SIGNING_KEY",
KeyKind::Text,
false,
"With `sofie::mail::web::routes`: the key Mailgun signs its bounce webhooks with; no Mailgun route when unset.",
),
(
"SOFIE_MAIL_WEBHOOK_SECRET",
KeyKind::Text,
false,
"With `sofie::mail::web::routes`: the HMAC-SHA256 key another provider signs bounce reports with in `X-Sofie-Signature`; no such route when unset.",
),
];
const OAUTH_KEYS: [(&str, &str); 2] = [
(
"SOFIE_OAUTH_PROVIDERS",
"Comma-separated `google`, `microsoft`, `github` or another OpenID Connect provider's name; none offered when unset. \
Each reads `SOFIE_OAUTH_<NAME>_CLIENT_ID` and `_CLIENT_SECRET`, Microsoft `_TENANT`, \
and another provider `_LABEL`, `_ISSUER`, `_AUTHORIZATION_URL` and `_TOKEN_URL`.",
),
(
"SOFIE_OAUTH_BASE_URL",
"With `SOFIE_OAUTH_PROVIDERS`: the app's public URL without a trailing `/`, which providers return to.",
),
];
const OAUTH_HANDLER: &str = "begin_oauth";
const CONTENT_BASE_URL: &str = "SOFIE_CONTENT_BASE_URL";
const ENVIRONMENT: &str = "SOFIE_ENVIRONMENT";
const BASE_URL: &str = sofie_core::BASE_URL;
const BASE_URL_OVERRIDES: [&str; 4] = [
"SOFIE_MAIL_BASE_URL",
"SOFIE_BILLING_BASE_URL",
CONTENT_BASE_URL,
"SOFIE_OAUTH_BASE_URL",
];
const FRAMEWORK_REQUIRED: [&str; 2] = [DATABASE_URL, SIGNING_KEYS];
const FRAMEWORK_SECRETS: [&str; 9] = [
DATABASE_URL,
SIGNING_KEYS,
"SOFIE_METRICS_TOKEN",
"SOFIE_REDIS_URL",
"SOFIE_SEARCH_MEILISEARCH_API_KEY",
"SOFIE_STORAGE_S3_SECRET_ACCESS_KEY",
"SOFIE_BILLING_API_KEY",
"SOFIE_BILLING_WEBHOOK_SECRET",
"SOFIE_NOTIFY_VAPID_PRIVATE_KEY",
];
const MAIL_SECRETS: [&str; 4] = [
"SOFIE_MAIL_SMTP_PASSWORD",
"SOFIE_MAIL_POSTMARK_WEBHOOK_AUTH",
"SOFIE_MAIL_MAILGUN_SIGNING_KEY",
"SOFIE_MAIL_WEBHOOK_SECRET",
];
pub(crate) fn framework_keys(app: &App) -> Vec<ConfigKey> {
let mut keys: Vec<ConfigKey> = FRAMEWORK_KEYS
.iter()
.map(|&(name, kind, doc)| ConfigKey {
secret: FRAMEWORK_SECRETS.contains(&name),
..key(
name.to_owned(),
doc.to_owned(),
kind,
FRAMEWORK_REQUIRED.contains(&name)
|| (name == ENVIRONMENT && app.has_feature_flags()),
)
})
.collect();
if app.modules.iter().any(|module| !module.mails.is_empty()) {
keys.extend(
MAIL_KEYS
.iter()
.map(|&(name, kind, required, doc)| ConfigKey {
secret: MAIL_SECRETS.contains(&name),
..key(name.to_owned(), doc.to_owned(), kind, required)
}),
);
}
if uses_oauth(app) {
keys.extend(
OAUTH_KEYS
.iter()
.map(|&(name, doc)| key(name.to_owned(), doc.to_owned(), KeyKind::Text, false)),
);
}
if app.depends_on(sofie_model::naming::CONTENT_PACKAGE) {
keys.push(key(
CONTENT_BASE_URL.to_owned(),
"Where the app is reached, such as `https://example.com`: pages' canonical URLs and the sitemap name it."
.to_owned(),
KeyKind::Text,
true,
));
}
keys
}
fn uses_oauth(app: &App) -> bool {
app.identity.as_ref().is_some_and(|identity| {
identity
.routes
.iter()
.any(|route| route.handler == OAUTH_HANDLER)
})
}
fn key(name: String, doc: String, kind: KeyKind, required: bool) -> ConfigKey {
ConfigKey {
doc: vec![doc],
kind,
required,
fallback: BASE_URL_OVERRIDES
.contains(&name.as_str())
.then(|| BASE_URL.to_owned()),
name,
secret: false,
}
}
pub(crate) fn remote_prefix(module: &str) -> String {
format!("{}_REMOTE", module.to_ascii_uppercase())
}
pub(crate) fn remote_keys(module: &str) -> Vec<ConfigKey> {
let prefix = remote_prefix(module);
VENDOR_KEYS
.iter()
.map(|&(name, doc, kind)| {
let base_url = name == "BASE_URL";
let doc = if base_url {
format!("Where the app module `{module}` was split into answers, such as `http://{module}.internal`.")
} else {
doc.to_owned()
};
key(format!("{prefix}_{name}"), doc, kind, base_url)
})
.collect()
}
fn gateway_keys(module: &Module) -> Vec<ConfigKey> {
let prefix = module.krate.crate_name.to_ascii_uppercase();
let mut keys = Vec::new();
for gateway in &module.gateways {
let vendors: Vec<_> = gateway
.adapters
.iter()
.filter_map(|adapter| Some((adapter.vendor.as_deref()?, adapter)))
.collect();
let only = vendors.len() == 1;
let names: Vec<&str> = vendors.iter().map(|(vendor, _)| *vendor).collect();
let trait_name = gateway.path.last().map_or("", String::as_str);
let doc = if only {
format!("The vendor `{trait_name}` calls; {} when unset.", names[0])
} else {
format!("The vendor `{trait_name}` calls: {}.", names.join(", "))
};
keys.push(key(
format!("{prefix}_{}", gateway.name.to_ascii_uppercase()),
doc,
KeyKind::Text,
!only,
));
for (vendor, adapter) in vendors {
let vendor_prefix = vendor_prefix(module, vendor);
keys.extend(VENDOR_KEYS.iter().map(|&(name, doc, kind)| {
key(
format!("{vendor_prefix}_{name}"),
doc.to_owned(),
kind,
false,
)
}));
keys.extend(adapter.settings.iter().map(|key| ConfigKey {
required: key.required && only,
..key.clone()
}));
}
}
keys
}
pub(crate) fn vendor_prefix(module: &Module, vendor: &str) -> String {
format!("{}_{}", module.krate.crate_name, vendor).to_ascii_uppercase()
}
pub(crate) fn module_keys(module: &Module) -> Vec<ConfigKey> {
let config = module
.config
.iter()
.flat_map(|config| config.keys.iter().cloned());
config.chain(gateway_keys(module)).collect()
}
pub(crate) fn render_env_examples(app: &App) -> Result<Vec<(PathBuf, Option<String>)>, GenError> {
rendered_env_examples(app)
.into_iter()
.map(|(path, text)| {
let hand_written =
read_existing(&path)?.is_some_and(|existing| !existing.starts_with(ENV_HEADER));
Ok((path, text.filter(|_| !hand_written)))
})
.collect()
}
fn rendered_env_examples(app: &App) -> Vec<(PathBuf, Option<String>)> {
let mut files = Vec::new();
let mut sections = vec![format!(
"# The framework\n\n{}",
entries(&framework_keys(app))
)];
for module in &app.modules {
let keys = module_keys(module);
let reads_keys = module.config.is_some() || !keys.is_empty();
let example = reads_keys.then(|| entries(&keys));
if let Some(example) = &example {
let name = &module.krate.crate_name;
sections.push(format!("# Module {name}\n\n{example}"));
}
let file = module.krate.path.join(".env.example");
files.push((
file,
example.map(|example| format!("{ENV_HEADER}\n{example}")),
));
}
sections.extend(app.split.remote.iter().map(|remote| {
let keys = entries(&remote_keys(&remote.name));
format!("# Module {} in another app\n\n{keys}", remote.name)
}));
let text = format!("{ENV_HEADER}\n{}", sections.join("\n"));
files.push((app.root.join(".env.example"), Some(text)));
files
}
fn entries(keys: &[ConfigKey]) -> String {
keys.iter().map(entry).collect::<Vec<_>>().join("\n")
}
fn entry(key: &ConfigKey) -> String {
let mut lines: Vec<String> = key.doc.iter().map(|line| format!("# {line}")).collect();
lines.extend(
key.fallback
.iter()
.map(|fallback| format!("# {fallback} when unset.")),
);
let comment = if key.required && key.fallback.is_none() {
""
} else {
"# "
};
lines.push(format!("{comment}{}=\n", key.name));
lines.join("\n")
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::{Path, PathBuf};
use sofie_model::testing::{TemporaryDirectory, app, krate, module};
use sofie_model::{
ConfigKey, Gateway, GatewayAdapter, Identity, KeyKind, ListedRoute, RemoteModule,
};
use super::{
CONTENT_BASE_URL, FRAMEWORK_KEYS, MAIL_KEYS, OAUTH_KEYS, VENDOR_KEYS, framework_keys,
gateway_keys, render_env_examples,
};
const TOOLING_PREFIXES: [&str; 6] = [
"SOFIE_CLI_",
"SOFIE_TEST_",
"SOFIE_BENCH_",
"SOFIE_DEV_",
"SOFIE_APP_",
"SOFIE_SCAFFOLD_",
];
#[test]
fn every_key_the_framework_reads_is_listed() {
let crates = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let mut named = Vec::new();
for entry in fs::read_dir(&crates).unwrap() {
let source = entry.unwrap().path().join("src");
if source.is_dir() {
sofie_keys_in(&source, &mut named);
}
}
let listed: Vec<&str> = FRAMEWORK_KEYS
.iter()
.map(|(name, ..)| *name)
.chain(MAIL_KEYS.iter().map(|(name, ..)| *name))
.chain(OAUTH_KEYS.iter().map(|(name, _)| *name))
.chain([CONTENT_BASE_URL, sofie_auth::OLD_SIGNING_KEYS])
.collect();
assert!(named.len() > 40, "{named:?}");
for (file, name) in named {
let tooling = TOOLING_PREFIXES
.iter()
.any(|prefix| name.starts_with(prefix));
let known = if name.ends_with('_') {
listed.iter().any(|listed| listed.starts_with(&name))
} else {
listed.contains(&name.as_str())
};
assert!(
tooling || known,
"{name}, named in {}, is missing from the .env.example lists",
file.display()
);
}
}
fn sofie_keys_in(directory: &Path, named: &mut Vec<(PathBuf, String)>) {
for entry in fs::read_dir(directory).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
sofie_keys_in(&path, named);
} else if path.extension().is_some_and(|extension| extension == "rs") {
let source = fs::read_to_string(&path).unwrap();
named.extend(sofie_keys(&source).map(|name| (path.clone(), name)));
}
}
}
fn sofie_keys(source: &str) -> impl Iterator<Item = String> + '_ {
source.match_indices("SOFIE_").filter_map(|(start, _)| {
let before = source[..start].chars().next_back();
if before.is_some_and(|letter| letter.is_ascii_alphanumeric() || letter == '_') {
return None;
}
let name: String = source[start..]
.chars()
.take_while(|letter| {
letter.is_ascii_uppercase() || letter.is_ascii_digit() || *letter == '_'
})
.collect();
Some(name)
})
}
#[test]
fn an_app_sending_mail_lists_every_key_sofie_mail_reads() {
let settings = Path::new(env!("CARGO_MANIFEST_DIR")).join("../sofie-mail/src/settings.rs");
let source = fs::read_to_string(&settings).unwrap();
let read: Vec<&str> = source
.lines()
.filter_map(|line| {
line.strip_prefix("const ")?
.split_once(": &str = \"SOFIE_MAIL_")
.map(|(_, rest)| rest.strip_suffix("\";").unwrap())
})
.collect();
let mut order = module("order", "shop/modules/order");
order.mails = vec![sofie_model::MailTemplate {
key: "order/receipt".to_owned(),
file: PathBuf::from("shop/modules/order/src/mails/receipt.html"),
}];
let silent = framework_keys(&app("shop", vec![module("order", "shop/modules/order")]));
let sending = framework_keys(&app("shop", vec![order]));
assert!(!silent.iter().any(|key| key.name.starts_with("SOFIE_MAIL_")));
for name in &read {
let listed = sending
.iter()
.find(|key| key.name == format!("SOFIE_MAIL_{name}"))
.unwrap_or_else(|| panic!("SOFIE_MAIL_{name} is not listed"));
assert_eq!(
listed.required,
["TRANSPORT", "FROM"].contains(name),
"{name}"
);
}
assert!(read.len() >= 8, "{read:?}");
}
#[test]
fn only_an_app_with_oauth_lists_its_keys() {
let route = |handler: &str| ListedRoute {
mount: "routes".to_owned(),
method: "POST".to_owned(),
path: "/sign-in".to_owned(),
handler: handler.to_owned(),
permission: None,
};
let listed = |handler: &str| {
let mut shop = app("shop", Vec::new());
shop.identity = Some(Identity {
routes: vec![route(handler)],
..Identity::default()
});
framework_keys(&shop)
.iter()
.any(|key| key.name == "SOFIE_OAUTH_PROVIDERS")
};
assert!(listed("begin_oauth"));
assert!(!listed("sign_in"));
}
#[test]
fn every_vendor_key_sofie_client_reads_is_listed() {
let settings =
Path::new(env!("CARGO_MANIFEST_DIR")).join("../sofie-client/src/settings.rs");
let source = fs::read_to_string(&settings).unwrap();
let fields: Vec<String> = source
.split("pub struct Vendor {")
.nth(1)
.and_then(|rest| rest.split('}').next())
.unwrap()
.lines()
.filter_map(|line| {
line.trim()
.trim_start_matches("pub ")
.split_once(": Option<")
})
.map(|(field, _)| field.to_ascii_uppercase())
.collect();
let listed: Vec<&str> = VENDOR_KEYS.iter().map(|(name, ..)| *name).collect();
assert_eq!(fields, listed);
}
fn adapter(vendor: &str, settings: Vec<ConfigKey>) -> GatewayAdapter {
GatewayAdapter {
ty: vec![vendor.to_owned()],
is_adapter: true,
vendor: Some(vendor.to_owned()),
settings,
file: "gateways.rs".into(),
line: 1,
}
}
fn payment(adapters: Vec<GatewayAdapter>) -> Vec<(String, bool)> {
let mut payment = module("payment", "modules/payment");
payment.gateways = vec![Gateway {
name: "payments".to_owned(),
path: vec!["Payments".to_owned()],
adapters,
file: "gateways.rs".into(),
line: 1,
}];
gateway_keys(&payment)
.into_iter()
.map(|key| (key.name, key.required))
.collect()
}
#[test]
fn a_gateway_reads_its_vendor_choice_and_each_vendor_s_keys() {
let secret = ConfigKey {
name: "PAYMENT_STRIPE_SECRET_KEY".to_owned(),
doc: Vec::new(),
kind: KeyKind::Text,
required: true,
fallback: None,
secret: true,
};
let one = payment(vec![adapter("stripe", vec![secret.clone()])]);
let two = payment(vec![
adapter("stripe", vec![secret]),
adapter("adyen", Vec::new()),
]);
assert_eq!(
&one[..2],
[
("PAYMENT_PAYMENTS".to_owned(), false),
("PAYMENT_STRIPE_BASE_URL".to_owned(), false),
]
);
assert_eq!(
one.last(),
Some(&("PAYMENT_STRIPE_SECRET_KEY".to_owned(), true))
);
assert_eq!(two[0], ("PAYMENT_PAYMENTS".to_owned(), true));
assert!(two.contains(&("PAYMENT_STRIPE_SECRET_KEY".to_owned(), false)));
assert!(two.contains(&("PAYMENT_ADYEN_BASE_URL".to_owned(), false)));
}
#[test]
fn leaves_a_hand_written_env_example_alone() {
let directory = TemporaryDirectory::new("sofie-env-example");
directory.write(".env.example", "DATABASE_URL=postgres://localhost/shop\n");
let shop = app(directory.root(), Vec::new());
let files = render_env_examples(&shop).unwrap();
assert_eq!(files, [(directory.path(".env.example"), None)]);
}
#[test]
fn a_module_split_into_another_app_needs_where_that_app_answers() {
let mut shop = app("shop", Vec::new());
shop.split.remote = vec![RemoteModule {
name: "order".to_owned(),
contract: krate("order-contract", "shop/split/order/modules/order/contract"),
defined_areas: Vec::new(),
exports: Vec::new(),
events: Vec::new(),
traits: Vec::new(),
}];
let files = render_env_examples(&shop).unwrap();
let (_, text) = files.last().unwrap();
let text = text.as_deref().unwrap();
assert!(
text.contains("# Module order in another app\n\n# Where the app module `order`"),
"{text}"
);
assert!(text.contains("\nORDER_REMOTE_BASE_URL=\n"), "{text}");
assert!(text.contains("\n# ORDER_REMOTE_PRIVATE=\n"), "{text}");
}
}