use std::fmt::Write as _;
use std::path::Path;
use super::emit::Plan;
use super::model::{ensure_cargo_dependencies, validate_resource_name};
use super::naming::snake;
use super::schema_edit::{
add_mod_declaration, ensure_dev_dependency_tokio_test_features, update_main_rs,
};
use super::{GenerateError, ensure_project_root, read_or_empty};
const WEBHOOK_DEPS: &[(&str, &str)] = &[("serde_json", "\"1\""), ("tracing", "\"0.1\"")];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Provider {
Stripe,
Github,
Slack,
Generic,
}
const PROVIDERS: &[Provider] = &[
Provider::Stripe,
Provider::Github,
Provider::Slack,
Provider::Generic,
];
impl Provider {
pub fn parse(value: &str) -> Result<Self, GenerateError> {
match value.trim().to_ascii_lowercase().as_str() {
"stripe" => Ok(Self::Stripe),
"github" => Ok(Self::Github),
"slack" => Ok(Self::Slack),
"generic" => Ok(Self::Generic),
other => Err(GenerateError::Config(format!(
"unsupported webhook provider {other:?} — supported presets are {}. Use \
`generic` for any provider that signs the raw body with HMAC-SHA256, then \
adjust the header names under `[[security.webhooks.endpoints]]` in \
autumn.toml.",
PROVIDERS
.iter()
.map(|provider| format!("`{}`", provider.as_str()))
.collect::<Vec<_>>()
.join(", ")
))),
}
}
const fn as_str(self) -> &'static str {
match self {
Self::Stripe => "stripe",
Self::Github => "github",
Self::Slack => "slack",
Self::Generic => "generic",
}
}
const fn variant(self) -> &'static str {
match self {
Self::Stripe => "Stripe",
Self::Github => "Github",
Self::Slack => "Slack",
Self::Generic => "Generic",
}
}
const fn label(self) -> &'static str {
match self {
Self::Stripe => "Stripe",
Self::Github => "GitHub",
Self::Slack => "Slack",
Self::Generic => "a generic HMAC-SHA256 provider",
}
}
fn default_secret_env(self) -> String {
format!("{}_WEBHOOK_SECRET", self.as_str().to_ascii_uppercase())
}
const fn stub_events(self) -> &'static [&'static str] {
match self {
Self::Stripe => &[
"payment_intent.succeeded",
"customer.subscription.updated",
"customer.subscription.deleted",
],
Self::Github => &["push", "pull_request", "issues"],
Self::Slack => &["app_mention", "message"],
Self::Generic => &["example.created", "example.updated"],
}
}
const fn is_timestamped(self) -> bool {
matches!(self, Self::Stripe | Self::Slack)
}
const fn delivery_id_source(self) -> &'static str {
match self {
Self::Stripe => "the JSON `id` field",
Self::Github => "the `X-GitHub-Delivery` header",
Self::Slack => "the JSON `event_id` field",
Self::Generic => "the `X-Webhook-Delivery` header",
}
}
const fn sim_payload(self) -> &'static str {
match self {
Self::Stripe => r#"{"id":"evt_1","type":"payment_intent.succeeded"}"#,
Self::Github => r#"{"ref":"refs/heads/main"}"#,
Self::Slack => {
r#"{"event_id":"Ev1","type":"event_callback","event":{"type":"app_mention"}}"#
}
Self::Generic => r#"{"data":{}}"#,
}
}
const fn sim_event(self) -> Option<&'static str> {
match self {
Self::Github => Some("push"),
Self::Generic => Some("example.created"),
Self::Stripe | Self::Slack => None,
}
}
const fn dashboard_hint(self) -> &'static str {
match self {
Self::Stripe => "Stripe Dashboard → Developers → Webhooks",
Self::Github => "GitHub repository/org → Settings → Webhooks",
Self::Slack => "Slack app config → Event Subscriptions",
Self::Generic => "your provider's webhook configuration",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct WebhookOptions {
pub path: Option<String>,
pub secret_env: Option<String>,
}
struct EndpointSpec {
provider: Provider,
name: String,
display_name: String,
path: String,
secret_env: String,
handler_fn: String,
}
pub fn plan_webhook(
project_root: &Path,
provider: &str,
name: &str,
options: &WebhookOptions,
) -> Result<Plan, GenerateError> {
ensure_project_root(project_root)?;
let spec = resolve_spec(provider, name, options)?;
let snake_name = spec.name.clone();
if project_root.join("src").join("webhooks.rs").is_file() {
return Err(GenerateError::Config(
"src/webhooks.rs already exists, and this generator needs src/webhooks/ — rustc rejects both spellings of one module (E0761). Move it to src/webhooks/mod.rs first, then re-run."
.to_owned(),
));
}
let mut plan = Plan::new(project_root);
plan.create(
project_root
.join("src")
.join("webhooks")
.join(format!("{snake_name}.rs")),
render_handler_file(&spec),
);
let mod_path = project_root.join("src").join("webhooks").join("mod.rs");
plan.modify(
mod_path.clone(),
add_mod_declaration(&read_or_empty(&mod_path), &snake_name),
);
plan.push_revert(crate::generate::emit::Revert::ModDecl {
path: mod_path,
name: snake_name.clone(),
});
let main_path = project_root.join("src").join("main.rs");
let main_existing = std::fs::read_to_string(&main_path).map_err(|error| {
GenerateError::Io(std::io::Error::new(
error.kind(),
format!("{}: {error}", main_path.display()),
))
})?;
let route_entries = vec![format!("webhooks::{snake_name}::{}", spec.handler_fn)];
let updated_main = update_main_rs(&main_existing, &["webhooks"], &route_entries);
if !updated_main.contains(&route_entries[0]) {
plan.warn(format!(
"src/main.rs has no `routes![…]` list to register the handler in — add `{}` to your router by hand, or the endpoint will not be mounted.",
route_entries[0]
));
}
plan.modify(main_path.clone(), updated_main);
plan.push_revert(crate::generate::emit::Revert::RoutesEntries {
path: main_path,
entries: route_entries,
});
plan_autumn_toml(&mut plan, project_root, &spec)?;
let cargo_path = project_root.join("Cargo.toml");
let cargo_existing = read_or_empty(&cargo_path);
let updated_cargo = ensure_dev_dependency_tokio_test_features(&ensure_cargo_dependencies(
&cargo_existing,
WEBHOOK_DEPS,
));
if updated_cargo != cargo_existing {
plan.modify(cargo_path.clone(), updated_cargo);
}
plan.push_revert(crate::generate::emit::Revert::CargoDeps {
path: cargo_path,
names: WEBHOOK_DEPS
.iter()
.map(|(name, _)| (*name).to_owned())
.collect(),
owner_dir: project_root.join("src").join("webhooks"),
});
Ok(plan)
}
pub fn plan_webhook_for_revert(
project_root: &Path,
provider: &str,
name: &str,
options: &WebhookOptions,
) -> Result<Plan, GenerateError> {
plan_webhook(
project_root,
provider,
name,
&adopt_recorded_overrides(project_root, name, options),
)
}
fn adopt_recorded_overrides(
project_root: &Path,
name: &str,
options: &WebhookOptions,
) -> WebhookOptions {
if options.path.is_some() && options.secret_env.is_some() {
return options.clone();
}
let endpoint_name = snake(name);
let existing = read_or_empty(&project_root.join("autumn.toml"));
let Ok(doc) = existing.parse::<toml_edit::DocumentMut>() else {
return options.clone();
};
let recorded = doc
.get("security")
.and_then(|security| security.get("webhooks"))
.and_then(|webhooks| webhooks.get("endpoints"))
.and_then(toml_edit::Item::as_array_of_tables)
.into_iter()
.flatten()
.find(|endpoint| {
endpoint.get("name").and_then(|value| value.as_str()) == Some(endpoint_name.as_str())
});
let Some(recorded) = recorded else {
return options.clone();
};
WebhookOptions {
path: options.path.clone().or_else(|| {
recorded
.get("path")
.and_then(|value| value.as_str())
.map(str::to_owned)
}),
secret_env: options.secret_env.clone().or_else(|| {
recorded
.get("secret_env")
.and_then(|value| value.as_str())
.map(str::to_owned)
}),
}
}
fn resolve_spec(
provider: &str,
name: &str,
options: &WebhookOptions,
) -> Result<EndpointSpec, GenerateError> {
validate_resource_name(name)?;
let provider = Provider::parse(provider)?;
let snake_name = snake(name);
let path = match options.path.as_deref() {
Some(path) => validate_route_path(path)?,
None => format!("/webhooks/{}", provider.as_str()),
};
let secret_env = match options.secret_env.as_deref() {
Some(variable) => validate_secret_env(variable)?,
None => provider.default_secret_env(),
};
Ok(EndpointSpec {
provider,
name: snake_name.clone(),
display_name: name.to_owned(),
path,
secret_env,
handler_fn: format!("{snake_name}_webhook"),
})
}
#[must_use]
pub fn next_steps(provider: &str, name: &str, options: &WebhookOptions) -> Option<String> {
let spec = resolve_spec(provider, name, options).ok()?;
let EndpointSpec {
provider,
name,
path,
secret_env,
..
} = &spec;
Some(format!(
"\nNext steps:\n\
\x20 1. Set the signing secret — the app refuses to start while a configured\n\
\x20 endpoint has none. Add {secret_env}=… to `.env` (gitignored, auto-loaded\n\
\x20 in dev/test) or export it; autumn.toml only names the variable, so the\n\
\x20 secret itself is never committed.\n\
\x20 2. Point {dashboard} at POST {path}. The endpoint is\n\
\x20 installed from autumn.toml's `[[security.webhooks.endpoints]]`, so there\n\
\x20 is no builder wiring to add.\n\
\x20 3. Try it locally without the provider — this reaches a stub arm, so a\n\
\x20 filled-in handler actually runs:\n\
\x20 autumn webhook sim {slug} http://localhost:3000{path} \\\n\
\x20 --secret \"${secret_env}\" --payload '{payload}'{event_flag}\n\
\x20 4. Fill in the `on_*` stub functions in src/webhooks/{name}.rs.\n\
\x20 5. Before deploying: replay protection is on, and its default `memory`\n\
\x20 backend is process-local — production config validation rejects it. Set\n\
\x20 [security.webhooks.replay] backend = \"redis\" with a redis url (needs\n\
\x20 autumn-web's `redis` feature).\n",
dashboard = provider.dashboard_hint(),
slug = provider.as_str(),
payload = provider.sim_payload(),
event_flag = provider
.sim_event()
.map_or_else(String::new, |event| format!(
" \\\n --event {event}"
)),
))
}
fn validate_route_path(path: &str) -> Result<String, GenerateError> {
let trimmed = path.trim();
if !trimmed.starts_with('/') || trimmed == "/" {
return Err(GenerateError::Config(format!(
"invalid webhook path {path:?}: it must start with '/' and must not be the site \
root (e.g. `--path /webhooks/stripe-billing`)"
)));
}
if let Some(bad) = trimmed.chars().find(|ch| !is_allowed_path_char(*ch)) {
return Err(GenerateError::Config(format!(
"invalid webhook path {path:?}: character {bad:?} is not allowed. Webhook endpoint \
paths are matched exactly — no path parameters (`{{tenant}}`) or wildcards — and \
the path is emitted into generated Rust source, so only unreserved URL path \
characters are accepted (e.g. `--path /webhooks/stripe-billing`)."
)));
}
Ok(trimmed.to_owned())
}
fn is_allowed_path_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || "-._~/%:@!$&'()+,;=".contains(ch)
}
fn validate_secret_env(name: &str) -> Result<String, GenerateError> {
let trimmed = name.trim();
let valid = !trimmed.is_empty()
&& trimmed
.chars()
.next()
.is_some_and(|ch| ch.is_ascii_alphabetic() || ch == '_')
&& trimmed
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_');
if !valid {
return Err(GenerateError::Config(format!(
"invalid secret environment variable name {name:?}: it must be a non-empty \
identifier of ASCII letters, digits, and underscores, not starting with a digit \
(e.g. `--secret-env PARTNER_WEBHOOK_SECRET`)"
)));
}
Ok(trimmed.to_owned())
}
fn plan_autumn_toml(
plan: &mut Plan,
project_root: &Path,
spec: &EndpointSpec,
) -> Result<(), GenerateError> {
let toml_path = project_root.join("autumn.toml");
let existing = read_or_empty(&toml_path);
reject_conflicting_endpoint(&existing, spec)?;
if endpoint_diverges_from_stub(&existing, spec) {
plan.warn(format!(
"autumn.toml's {:?} webhook endpoint carries hand edits: generating refreshes only its path/provider/secret_env, and `autumn destroy webhook` will leave the block in place rather than delete your changes.",
spec.name
));
}
match upsert_webhook_endpoint(&existing, spec) {
Some(updated) => {
plan.modify(toml_path.clone(), updated);
plan.push_revert(crate::generate::emit::Revert::WebhookEndpointStub {
path: toml_path,
name: spec.name.clone(),
route_path: spec.path.clone(),
});
}
None => {
plan.warn(format!(
"Could not edit {} (it does not parse, or `security.webhooks` uses an inline \
form — convert `endpoints` to `[[security.webhooks.endpoints]]` table form). \
Add this by hand:\n{}",
toml_path.display(),
manual_config_block(spec)
));
}
}
Ok(())
}
fn endpoint_diverges_from_stub(existing: &str, spec: &EndpointSpec) -> bool {
let Ok(doc) = existing.parse::<toml_edit::DocumentMut>() else {
return false;
};
doc.get("security")
.and_then(|security| security.get("webhooks"))
.and_then(|webhooks| webhooks.get("endpoints"))
.and_then(toml_edit::Item::as_array_of_tables)
.into_iter()
.flatten()
.find(|endpoint| endpoint.get("name").and_then(|v| v.as_str()) == Some(spec.name.as_str()))
.is_some_and(|endpoint| !is_generated_endpoint_stub(endpoint))
}
fn reject_conflicting_endpoint(existing: &str, spec: &EndpointSpec) -> Result<(), GenerateError> {
let Ok(doc) = existing.parse::<toml_edit::DocumentMut>() else {
return Ok(());
};
let Some(endpoints) = doc
.get("security")
.and_then(|security| security.get("webhooks"))
.and_then(|webhooks| webhooks.get("endpoints"))
else {
return Ok(());
};
let table_form = endpoints
.as_array_of_tables()
.into_iter()
.flatten()
.map(|endpoint| {
(
endpoint.get("name").and_then(|v| v.as_str()),
endpoint.get("path").and_then(|v| v.as_str()),
)
});
let inline_form = endpoints
.as_array()
.into_iter()
.flatten()
.filter_map(toml_edit::Value::as_inline_table)
.map(|endpoint| {
(
endpoint.get("name").and_then(|v| v.as_str()),
endpoint.get("path").and_then(|v| v.as_str()),
)
});
for (name, path) in table_form.chain(inline_form) {
if path == Some(spec.path.as_str()) && name != Some(spec.name.as_str()) {
return Err(GenerateError::Config(format!(
"autumn.toml already configures webhook endpoint {:?} on path {:?}; two \
endpoints on one path fail config validation at boot. Re-run with a distinct \
path, e.g. `--path {}-{}`.",
name.unwrap_or("<unnamed>"),
spec.path,
spec.path,
spec.name
)));
}
}
Ok(())
}
fn manual_config_block(spec: &EndpointSpec) -> String {
format!(
"\n[security.webhooks.replay]\nbackend = \"memory\" # production must use redis\n\n\
[[security.webhooks.endpoints]]\nname = \"{}\"\npath = \"{}\"\nprovider = \"{}\"\n\
secret_env = \"{}\"\nreplay_protection = true\n",
spec.name,
spec.path,
spec.provider.as_str(),
spec.secret_env,
)
}
const REPLAY_COMMENT_MARKER: &str = "\n# Replay-protection storage for signed webhooks.";
const ENDPOINT_COMMENT_MARKER: &str = "\n# Signed webhook intake generated by";
fn upsert_webhook_endpoint(existing: &str, spec: &EndpointSpec) -> Option<String> {
use toml_edit::{Array, ArrayOfTables, DocumentMut, Item, Table, Value, value};
let mut doc = existing.parse::<DocumentMut>().ok()?;
let mut pending_trailing = doc.trailing().as_str().unwrap_or_default().to_owned();
if !pending_trailing.is_empty() {
doc.set_trailing("");
}
let security_missing = !doc.as_table().contains_key("security");
let security = doc
.as_table_mut()
.entry("security")
.or_insert_with(|| Item::Table(Table::new()))
.as_table_mut()?;
if security_missing {
security.set_implicit(true);
}
let webhooks_missing = !security.contains_key("webhooks");
let webhooks = security
.entry("webhooks")
.or_insert_with(|| Item::Table(Table::new()))
.as_table_mut()?;
if webhooks_missing {
webhooks.set_implicit(true);
}
if !webhooks.contains_key("replay") {
let mut replay = Table::new();
replay.decor_mut().set_prefix(
std::mem::take(&mut pending_trailing)
+ REPLAY_COMMENT_MARKER
+ " \"memory\" is process-local:\n\
# fine for tests and development, but production config validation rejects it,\n\
# so a deployed app must use redis (shared across every replica). The redis\n\
# backend needs autumn-web built with its `redis` feature:\n\
#\n\
# backend = \"redis\"\n\
# [security.webhooks.replay.redis]\n\
# url = \"redis://redis:6379/0\"\n",
);
replay.insert("backend", value("memory"));
webhooks.insert("replay", Item::Table(replay));
}
let endpoints = webhooks
.entry("endpoints")
.or_insert_with(|| Item::ArrayOfTables(ArrayOfTables::new()))
.as_array_of_tables_mut()?;
if let Some(existing_entry) = endpoints
.iter_mut()
.find(|entry| entry.get("name").and_then(|v| v.as_str()) == Some(spec.name.as_str()))
{
existing_entry.insert("path", value(spec.path.as_str()));
existing_entry.insert("provider", value(spec.provider.as_str()));
existing_entry.insert("secret_env", value(spec.secret_env.as_str()));
if !existing_entry.contains_key("replay_protection") {
existing_entry.insert("replay_protection", value(true));
}
} else {
let mut endpoint = Table::new();
endpoint.decor_mut().set_prefix(
std::mem::take(&mut pending_trailing)
+ "\n# Signed webhook intake generated by `autumn generate webhook`. The signing\n\
# secret lives in the secret_env variable below — never inline it here. During\n\
# rotation, add the previous value's variable to previous_secret_envs.\n\
#\n\
# This block is all the wiring the endpoint needs: Autumn installs the webhook\n\
# registry from it at startup, and derives the endpoint's CSRF, submit-token,\n\
# and CAPTCHA path exemptions from it on every boot (a provider callback\n\
# carries no browser session; its signature is its authentication).\n",
);
endpoint.insert("name", value(spec.name.as_str()));
endpoint.insert("path", value(spec.path.as_str()));
endpoint.insert("provider", value(spec.provider.as_str()));
endpoint.insert("secret_env", value(spec.secret_env.as_str()));
endpoint.insert(
"previous_secret_envs",
Item::Value(Value::Array(Array::new())),
);
endpoint.insert("replay_protection", value(true));
endpoints.push(endpoint);
}
if !pending_trailing.is_empty() {
doc.set_trailing(&pending_trailing);
}
Some(doc.to_string())
}
const GENERATED_ENDPOINT_KEYS: &[&str] = &[
"name",
"path",
"provider",
"secret_env",
"previous_secret_envs",
"replay_protection",
];
fn is_generated_endpoint_stub(endpoint: &toml_edit::Table) -> bool {
let unknown_key = endpoint
.iter()
.any(|(key, _)| !GENERATED_ENDPOINT_KEYS.contains(&key));
let rotation_filled = endpoint
.get("previous_secret_envs")
.and_then(toml_edit::Item::as_array)
.is_some_and(|envs| !envs.is_empty());
let replay_disabled = endpoint
.get("replay_protection")
.and_then(toml_edit::Item::as_bool)
== Some(false);
!unknown_key && !rotation_filled && !replay_disabled
}
fn is_generated_replay_stub(replay: &toml_edit::Table) -> bool {
replay.len() == 1 && replay.get("backend").and_then(toml_edit::Item::as_str) == Some("memory")
}
fn parked_trivia(prefix: &str) -> &str {
for marker in [REPLAY_COMMENT_MARKER, ENDPOINT_COMMENT_MARKER] {
if let Some(index) = prefix.find(marker) {
return &prefix[..index];
}
}
""
}
fn parked_trivia_of(table: &toml_edit::Table) -> String {
parked_trivia(
table
.decor()
.prefix()
.and_then(toml_edit::RawString::as_str)
.unwrap_or_default(),
)
.to_owned()
}
pub(super) fn remove_webhook_endpoint(existing: &str, name: &str, route_path: &str) -> String {
let Ok(mut doc) = existing.parse::<toml_edit::DocumentMut>() else {
return existing.to_owned();
};
let Some(security) = doc
.as_table_mut()
.get_mut("security")
.and_then(toml_edit::Item::as_table_mut)
else {
return existing.to_owned();
};
let Some(webhooks) = security
.get_mut("webhooks")
.and_then(toml_edit::Item::as_table_mut)
else {
return existing.to_owned();
};
let mut salvaged = String::new();
if let Some(endpoints) = webhooks
.get_mut("endpoints")
.and_then(toml_edit::Item::as_array_of_tables_mut)
{
let ours = endpoints.iter().position(|endpoint| {
endpoint.get("name").and_then(|v| v.as_str()) == Some(name)
&& endpoint.get("path").and_then(|v| v.as_str()) == Some(route_path)
&& is_generated_endpoint_stub(endpoint)
});
match ours {
Some(index) => {
if let Some(endpoint) = endpoints.get(index) {
salvaged.push_str(&parked_trivia_of(endpoint));
}
endpoints.remove(index);
if endpoints.is_empty() {
webhooks.remove("endpoints");
}
}
None if !endpoints.is_empty() => return existing.to_owned(),
None => {
webhooks.remove("endpoints");
}
}
}
if !webhooks.contains_key("endpoints") {
let generated_replay = webhooks
.get("replay")
.and_then(toml_edit::Item::as_table)
.filter(|replay| is_generated_replay_stub(replay))
.map(parked_trivia_of);
if let Some(trivia) = generated_replay {
salvaged.insert_str(0, &trivia);
webhooks.remove("replay");
}
}
if webhooks.is_empty() {
security.remove("webhooks");
}
if security.is_empty() {
doc.as_table_mut().remove("security");
}
if !salvaged.is_empty() {
let trailing = doc.trailing().as_str().unwrap_or_default().to_owned();
doc.set_trailing(salvaged + &trailing);
}
doc.to_string()
}
fn render_handler_file(spec: &EndpointSpec) -> String {
let EndpointSpec {
provider,
display_name,
path,
secret_env,
handler_fn,
..
} = spec;
let label = provider.label();
let dispatch = render_dispatch(spec);
let stubs = render_event_stubs(spec);
let tests = render_tests(spec);
format!(
r#"//! Signed inbound webhook intake for {label} — generated by
//! `autumn generate webhook {provider_name} {display_name}`. Edit freely; once
//! generated this is ordinary application code.
//!
//! The `SignedWebhook` extractor verifies the provider signature against the
//! exact request bytes *before* this handler runs, applies the configured
//! timestamp tolerance, and rejects replayed deliveries. The endpoint —
//! path, provider preset, and the `{secret_env}` environment variable holding
//! the signing secret — is configured in `autumn.toml` under
//! `[[security.webhooks.endpoints]]`, and installed automatically at startup.
//! Nothing here hand-rolls HMAC verification.
//!
//! Rejections happen before the handler body (see
//! `docs/guide/signed-webhooks.md`):
//!
//! | Failure | Status |
//! |---------|--------|
//! | missing/malformed signature, timestamp, or delivery id | `400` |
//! | stale timestamp or signature mismatch | `401` |
//! | duplicate delivery inside the replay window | `409` |
//! | replay backend unavailable | `503` |
//!
//! Replay protection keys on {delivery_source}.
//!
//! Keep this handler fast and idempotent: webhook delivery is at-least-once, so
//! the same event can arrive more than once. Enqueue a job for slow work
//! (mail, external API calls, long transactions) and return promptly.
use autumn_web::prelude::*;
/// `POST {path}` — verified {label} intake.
#[post("{path}")]
pub async fn {handler_fn}(webhook: SignedWebhook) -> AutumnResult<Json<serde_json::Value>> {{
// The bytes are already verified; this only decodes them. Swap
// `serde_json::Value` for a typed provider struct when you have one.
let event: serde_json::Value = webhook.json::<serde_json::Value>().map_err(|error| {{
AutumnError::bad_request_msg(format!("invalid {provider_name} webhook payload: {{error}}"))
}})?;
let event_type = webhook.event_type().unwrap_or("unknown");
// Log the envelope, never the payload — provider callbacks routinely carry
// personal and financial data.
tracing::info!(
provider = webhook.provider(),
endpoint = webhook.endpoint(),
delivery_id = webhook.delivery_id().unwrap_or("-"),
event_type,
"signed webhook accepted"
);
{dispatch}
Ok(Json(serde_json::json!({{ "received": true }})))
}}
{stubs}{tests}"#,
provider_name = provider.as_str(),
delivery_source = provider.delivery_id_source(),
)
}
fn render_dispatch(spec: &EndpointSpec) -> String {
if spec.provider == Provider::Slack {
let arms = dispatch_arms(spec, 16, "inner_event");
return format!(
r#" match event_type {{
// Slack's one-time endpoint handshake: echo the challenge verbatim.
"url_verification" => {{
let challenge = event
.get("challenge")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
return Ok(Json(serde_json::json!({{ "challenge": challenge }})));
}}
"event_callback" => {{
// The envelope's `event` object is what the handlers care about.
let inner_event = event.get("event").unwrap_or(&event);
let inner_type = inner_event
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown");
match inner_type {{
{arms} // Acknowledge and ignore: a 2xx tells Slack not to retry
// an event this app does not handle.
other => {{
tracing::debug!(event_type = other, "unhandled Slack event — acknowledged")
}}
}}
}}
// Acknowledge and ignore every other envelope type.
_ => tracing::debug!(event_type, "unhandled Slack callback — acknowledged"),
}}
"#
);
}
let arms = dispatch_arms(spec, 8, "&event");
format!(
r#" match event_type {{
{arms} // Acknowledge and ignore: returning 2xx stops the provider from
// retrying an event this app does not handle.
_ => tracing::debug!(
event_type,
"unhandled webhook event — acknowledged and ignored"
),
}}
"#
)
}
fn dispatch_arms(spec: &EndpointSpec, indent: usize, payload: &str) -> String {
let pad = " ".repeat(indent);
spec.provider
.stub_events()
.iter()
.fold(String::new(), |mut out, event| {
let _ = writeln!(out, "{pad}// TODO: fill this in");
let _ = writeln!(
out,
"{pad}{event:?} => {}({payload}).await?,",
stub_fn(event),
);
out
})
}
fn render_event_stubs(spec: &EndpointSpec) -> String {
let payload_doc = if spec.provider == Provider::Slack {
"`event` is the verified callback's inner event object"
} else {
"`event` is the verified payload"
};
spec.provider
.stub_events()
.iter()
.fold(String::new(), |mut out, event| {
let _ = write!(
out,
r#"/// TODO: handle the `{event}` event.
///
/// {payload_doc}. Keep this idempotent — the same delivery
/// can arrive twice — and enqueue a job for anything slow.
async fn {fn_name}(event: &serde_json::Value) -> AutumnResult<()> {{
let _ = event; // TODO: remove once the payload is used.
tracing::info!("TODO: handle {event}");
Ok(())
}}
"#,
fn_name = stub_fn(event),
);
out
})
}
fn stub_fn(event: &str) -> String {
let mut out = String::with_capacity(event.len() + 3);
out.push_str("on_");
for ch in event.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
} else {
out.push('_');
}
}
out
}
fn render_tests(spec: &EndpointSpec) -> String {
let EndpointSpec {
provider,
name,
path,
handler_fn,
..
} = spec;
let provider = *provider;
let body_const = render_body_const(fixture_body(provider));
let signature_helper = signature_helper(provider);
let timestamp_fn = if provider.is_timestamped() {
" fn now_secs() -> u64 {\n \
std::time::SystemTime::now()\n \
.duration_since(std::time::UNIX_EPOCH)\n \
.expect(\"system clock is after the Unix epoch\")\n \
.as_secs()\n }\n\n"
} else {
""
};
let cases = render_test_cases(spec);
format!(
r#"#[cfg(test)]
mod tests {{
use super::*;
use autumn_web::config::AutumnConfig;
use autumn_web::test::{{TestApp, TestClient}};
use autumn_web::webhook::{{
WebhookConfig, WebhookEndpointConfig, WebhookProvider, hmac_sha256_hex,
}};
/// Fixture-only secret. Real deployments read the secret from the
/// `{secret_env}` environment variable via `autumn.toml`'s `secret_env`.
const TEST_SECRET: &str = "test-{provider_name}-webhook-secret-32-bytes";
/// A delivery shaped like the real thing. Replace it with a captured
/// payload from your provider dashboard as you fill the handlers in.
{body_const}
fn test_config() -> AutumnConfig {{
AutumnConfig {{
security: autumn_web::security::SecurityConfig {{
webhooks: WebhookConfig {{
endpoints: vec![WebhookEndpointConfig::new(
"{name}",
"{path}",
WebhookProvider::{variant},
TEST_SECRET,
)],
..Default::default()
}},
..Default::default()
}},
..Default::default()
}}
}}
fn client() -> TestClient {{
TestApp::new()
.config(test_config())
.routes(routes![{handler_fn}])
.build()
}}
{timestamp_fn}{signature_helper}{cases}}}
"#,
provider_name = provider.as_str(),
variant = provider.variant(),
secret_env = spec.secret_env,
)
}
fn render_test_cases(spec: &EndpointSpec) -> String {
let provider = spec.provider;
let path = spec.path.as_str();
let valid_headers = request_headers(provider, valid_signature_expr(provider));
let invalid_headers = request_headers(provider, invalid_signature_expr(provider));
let timestamp_binding = if provider.is_timestamped() {
" let timestamp = now_secs();\n"
} else {
""
};
format!(
r#" #[tokio::test]
async fn valid_signature_is_accepted() {{
{timestamp_binding} client()
.post("{path}")
{valid_headers} .body(BODY)
.send()
.await
.assert_status(200);
}}
#[tokio::test]
async fn missing_signature_header_is_rejected() {{
// A delivery with no signature at all cannot be verified: it is
// rejected as a malformed request before the handler runs.
client()
.post("{path}")
.body(BODY)
.send()
.await
.assert_status(400);
}}
#[tokio::test]
async fn invalid_signature_is_rejected() {{
// Well-formed but wrong: this is the unauthorized case, not a
// malformed-request one.
{timestamp_binding} client()
.post("{path}")
{invalid_headers} .body(BODY)
.send()
.await
.assert_status(401);
}}
#[tokio::test]
async fn replayed_delivery_is_rejected() {{
// Replay protection keys on {delivery_source}, so re-sending the same
// delivery is rejected with 409 inside the replay window.
let app = client();
{timestamp_binding} app.post("{path}")
{valid_headers} .body(BODY)
.send()
.await
.assert_status(200);
app.post("{path}")
{valid_headers} .body(BODY)
.send()
.await
.assert_status(409);
}}
"#,
delivery_source = provider.delivery_id_source(),
)
}
fn render_body_const(fixture: &str) -> String {
const DECL: &str = " const BODY: &str = ";
if DECL.len() + fixture.len() < 100 {
format!("{DECL}{fixture};")
} else {
format!(" const BODY: &str =\n {fixture};")
}
}
const fn fixture_body(provider: Provider) -> &'static str {
match provider {
Provider::Stripe => {
r##"r#"{"id":"evt_test_1","type":"payment_intent.succeeded","data":{"object":{}}}"#"##
}
Provider::Github => {
r##"r#"{"ref":"refs/heads/main","repository":{"full_name":"acme/app"}}"#"##
}
Provider::Slack => {
r##"r#"{"event_id":"Ev00000001","type":"event_callback","event":{"type":"app_mention"}}"#"##
}
Provider::Generic => r##"r#"{"data":{"id":"1"}}"#"##,
}
}
const fn signature_helper(provider: Provider) -> &'static str {
match provider {
Provider::Stripe => {
" /// Stripe signs `{timestamp}.{raw_body}` and sends `t=…,v1=…`.\n\
\x20 fn signature(body: &str, timestamp: u64) -> String {\n\
\x20 let signed = format!(\"{timestamp}.{body}\");\n\
\x20 let digest = hmac_sha256_hex(TEST_SECRET.as_bytes(), signed.as_bytes());\n\
\x20 format!(\"t={timestamp},v1={digest}\")\n\
\x20 }\n\n"
}
Provider::Github | Provider::Generic => {
" /// Both presets sign the raw body and send `sha256=<hex>`.\n\
\x20 fn signature(body: &str) -> String {\n\
\x20 format!(\n\
\x20 \"sha256={}\",\n\
\x20 hmac_sha256_hex(TEST_SECRET.as_bytes(), body.as_bytes())\n\
\x20 )\n\
\x20 }\n\n"
}
Provider::Slack => {
" /// Slack signs `v0:{timestamp}:{raw_body}` and sends `v0=<hex>`.\n\
\x20 fn signature(body: &str, timestamp: u64) -> String {\n\
\x20 let signed = format!(\"v0:{timestamp}:{body}\");\n\
\x20 format!(\n\
\x20 \"v0={}\",\n\
\x20 hmac_sha256_hex(TEST_SECRET.as_bytes(), signed.as_bytes())\n\
\x20 )\n\
\x20 }\n\n"
}
}
}
const fn valid_signature_expr(provider: Provider) -> &'static str {
if provider.is_timestamped() {
"&signature(BODY, timestamp)"
} else {
"&signature(BODY)"
}
}
const fn invalid_signature_expr(provider: Provider) -> &'static str {
match provider {
Provider::Stripe => "&format!(\"t={timestamp},v1={}\", \"0\".repeat(64))",
Provider::Github | Provider::Generic => "&format!(\"sha256={}\", \"0\".repeat(64))",
Provider::Slack => "&format!(\"v0={}\", \"0\".repeat(64))",
}
}
fn request_headers(provider: Provider, signature_expr: &str) -> String {
let signature = match provider {
Provider::Stripe => header_call("stripe-signature", signature_expr),
Provider::Github => header_call("x-hub-signature-256", signature_expr),
Provider::Slack => header_call("x-slack-signature", signature_expr),
Provider::Generic => header_call("x-webhook-signature", signature_expr),
};
let metadata = match provider {
Provider::Stripe => String::new(),
Provider::Github => {
header_call("x-github-event", "\"push\"")
+ &header_call(
"x-github-delivery",
"\"00000000-0000-0000-0000-000000000001\"",
)
}
Provider::Slack => header_call("x-slack-request-timestamp", "×tamp.to_string()"),
Provider::Generic => {
header_call("x-webhook-event", "\"example.created\"")
+ &header_call("x-webhook-delivery", "\"delivery-1\"")
}
};
signature + &metadata
}
fn header_call(name: &str, value_expr: &str) -> String {
const FN_CALL_WIDTH: usize = 60;
let arguments = format!("\"{name}\", {value_expr}");
if arguments.chars().count() <= FN_CALL_WIDTH {
return format!(" .header({arguments})\n");
}
format!(
" .header(\n \"{name}\",\n {value_expr},\n )\n"
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::Flags;
use std::fs;
use tempfile::TempDir;
fn default_main() -> &'static str {
r#"use autumn_web::prelude::*;
#[get("/")]
async fn index() -> &'static str { "ok" }
#[autumn_web::main]
async fn main() {
autumn_web::app()
.routes(routes![index])
.run()
.await;
}
"#
}
fn project() -> TempDir {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname=\"x\"\n\n[dependencies]\nautumn-web = \"0.6\"\n\n\
[dev-dependencies]\ntokio = { version = \"1\", features = [\"macros\"] }\n",
)
.unwrap();
fs::write(
tmp.path().join("autumn.toml"),
"[server]\nhost = \"127.0.0.1\"\nport = 3000\n\n[health]\npath = \"/health\"\n\
# live_path = \"/live\"\n# ready_path = \"/ready\"\n",
)
.unwrap();
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(tmp.path().join("src/main.rs"), default_main()).unwrap();
tmp
}
fn generated(tmp: &TempDir, provider: &str, name: &str) -> String {
let plan = plan_webhook(tmp.path(), provider, name, &WebhookOptions::default()).unwrap();
plan.execute(Flags::default()).unwrap();
fs::read_to_string(
tmp.path()
.join("src/webhooks")
.join(format!("{}.rs", crate::generate::naming::snake(name))),
)
.unwrap()
}
#[test]
fn generated_handler_uses_post_route_and_signed_webhook_extractor() {
let tmp = project();
let src = generated(&tmp, "stripe", "Payments");
assert!(
src.contains("#[post(\"/webhooks/stripe\")]"),
"handler must expose a #[post] route at the provider path:\n{src}"
);
assert!(
src.contains("webhook: SignedWebhook"),
"handler must take the shipped SignedWebhook extractor:\n{src}"
);
assert!(
!src.contains("hmac_sha256_hex(") || src.contains("mod tests"),
"no hand-rolled HMAC outside the test module:\n{src}"
);
let handler_body = src.split("#[cfg(test)]").next().unwrap();
assert!(
!handler_body.contains("Hmac") && !handler_body.contains("hmac"),
"handler must not hand-roll signature verification:\n{handler_body}"
);
}
#[test]
fn generated_handler_dispatches_on_event_type_with_stubs_and_default_arm() {
let tmp = project();
let src = generated(&tmp, "stripe", "Payments");
assert!(
src.contains("webhook.event_type()"),
"handler must dispatch on event_type():\n{src}"
);
assert!(
src.contains("\"payment_intent.succeeded\" =>"),
"expected a Stripe stub arm:\n{src}"
);
assert!(src.contains("TODO"), "stub arms must be clearly marked");
assert!(
src.contains("_ =>"),
"expected a default acknowledge-and-ignore arm:\n{src}"
);
}
#[test]
fn every_supported_provider_preset_generates_its_own_shape() {
for (provider, variant, arm) in [
("stripe", "Stripe", "\"payment_intent.succeeded\" =>"),
("github", "Github", "\"push\" =>"),
("slack", "Slack", "\"app_mention\" =>"),
("generic", "Generic", "\"example.created\" =>"),
] {
let tmp = project();
let src = generated(&tmp, provider, "Intake");
assert!(
src.contains(&format!("#[post(\"/webhooks/{provider}\")]")),
"{provider}: wrong route path:\n{src}"
);
assert!(
src.contains(&format!("WebhookProvider::{variant}")),
"{provider}: generated test config must use WebhookProvider::{variant}:\n{src}"
);
assert!(
src.contains(arm),
"{provider}: missing preset stub arm:\n{src}"
);
let toml = fs::read_to_string(tmp.path().join("autumn.toml")).unwrap();
assert!(
toml.contains(&format!("provider = \"{provider}\"")),
"{provider}: autumn.toml endpoint must name the provider preset:\n{toml}"
);
}
}
#[test]
fn unknown_provider_is_rejected_with_the_supported_list() {
let tmp = project();
let err = plan_webhook(tmp.path(), "twilio", "Sms", &WebhookOptions::default())
.expect_err("unknown provider must be rejected");
let message = err.to_string();
assert!(message.contains("twilio"), "{message}");
assert!(message.contains("generic"), "{message}");
}
#[test]
fn autumn_toml_gets_a_secret_env_endpoint_stub_with_replay_protection() {
let tmp = project();
generated(&tmp, "stripe", "Payments");
let toml = fs::read_to_string(tmp.path().join("autumn.toml")).unwrap();
assert!(
toml.contains("[[security.webhooks.endpoints]]"),
"expected an endpoint stub:\n{toml}"
);
assert!(toml.contains("name = \"payments\""), "{toml}");
assert!(toml.contains("path = \"/webhooks/stripe\""), "{toml}");
assert!(toml.contains("provider = \"stripe\""), "{toml}");
assert!(
toml.contains("secret_env = \"STRIPE_WEBHOOK_SECRET\""),
"{toml}"
);
assert!(toml.contains("replay_protection = true"), "{toml}");
assert!(
!toml.contains("\nsecret ="),
"a plaintext secret must never be written:\n{toml}"
);
}
#[test]
fn autumn_toml_writes_no_redundant_csrf_or_captcha_exemptions() {
let tmp = project();
generated(&tmp, "stripe", "Payments");
let toml = fs::read_to_string(tmp.path().join("autumn.toml")).unwrap();
assert!(
!toml.contains("exempt_paths"),
"path exemptions are derived from the endpoint block, not copied:\n{toml}"
);
assert!(
!toml.contains("captcha_exempt_paths"),
"path exemptions are derived from the endpoint block, not copied:\n{toml}"
);
assert_eq!(
toml.matches("\"/webhooks/stripe\"").count(),
1,
"the route path must appear exactly once — in the endpoint block:\n{toml}"
);
assert!(
toml.contains("CAPTCHA path exemptions from it on every boot"),
"the generated block should say the exemptions are automatic:\n{toml}"
);
}
#[test]
fn autumn_toml_declares_the_replay_backend_so_prod_boot_validation_passes() {
let tmp = project();
generated(&tmp, "stripe", "Payments");
let toml = fs::read_to_string(tmp.path().join("autumn.toml")).unwrap();
assert!(
toml.contains("[security.webhooks.replay]"),
"expected a replay backend section:\n{toml}"
);
assert!(
toml.contains("redis"),
"prod guidance for the redis backend must be present:\n{toml}"
);
}
#[test]
fn regenerating_the_same_webhook_does_not_duplicate_the_toml_stub() {
let tmp = project();
generated(&tmp, "stripe", "Payments");
let plan =
plan_webhook(tmp.path(), "stripe", "Payments", &WebhookOptions::default()).unwrap();
plan.execute(Flags {
force: true,
..Flags::default()
})
.unwrap();
let toml = fs::read_to_string(tmp.path().join("autumn.toml")).unwrap();
assert_eq!(
toml.matches("[[security.webhooks.endpoints]]").count(),
1,
"endpoint stub must be idempotent:\n{toml}"
);
assert_eq!(
toml.matches("\"/webhooks/stripe\"").count(),
1,
"the endpoint path must not be duplicated:\n{toml}"
);
}
#[test]
fn a_second_webhook_on_the_same_path_is_rejected_with_a_path_hint() {
let tmp = project();
generated(&tmp, "stripe", "Payments");
let err = plan_webhook(tmp.path(), "stripe", "Billing", &WebhookOptions::default())
.expect_err("a duplicate endpoint path must be rejected");
let message = err.to_string();
assert!(message.contains("/webhooks/stripe"), "{message}");
assert!(message.contains("--path"), "{message}");
}
#[test]
fn missing_autumn_toml_is_created_with_the_endpoint_stub() {
let tmp = project();
fs::remove_file(tmp.path().join("autumn.toml")).unwrap();
generated(&tmp, "github", "Repo");
let toml = fs::read_to_string(tmp.path().join("autumn.toml")).unwrap();
assert!(toml.contains("[[security.webhooks.endpoints]]"), "{toml}");
}
#[test]
fn path_and_secret_env_overrides_are_honored_everywhere() {
let tmp = project();
let options = WebhookOptions {
path: Some("/hooks/billing".to_owned()),
secret_env: Some("BILLING_SECRET".to_owned()),
};
let plan = plan_webhook(tmp.path(), "stripe", "Billing", &options).unwrap();
plan.execute(Flags::default()).unwrap();
let src = fs::read_to_string(tmp.path().join("src/webhooks/billing.rs")).unwrap();
assert!(src.contains("#[post(\"/hooks/billing\")]"), "{src}");
let toml = fs::read_to_string(tmp.path().join("autumn.toml")).unwrap();
assert!(toml.contains("path = \"/hooks/billing\""), "{toml}");
assert!(toml.contains("secret_env = \"BILLING_SECRET\""), "{toml}");
}
#[test]
fn an_invalid_path_override_is_rejected() {
let tmp = project();
for bad in [
"hooks/billing", "/", "/hooks billing",
"/a\")]pub fn evil(){}//",
"/hooks\\billing",
"/hooks\nbilling",
"/hooks/{tenant}/in",
"/hooks/*rest",
] {
let options = WebhookOptions {
path: Some(bad.to_owned()),
secret_env: None,
};
assert!(
plan_webhook(tmp.path(), "stripe", "Billing", &options).is_err(),
"path {bad:?} must be rejected"
);
}
assert!(
!tmp.path().join("src/webhooks").exists(),
"a rejected invocation must write nothing"
);
}
#[test]
fn an_invalid_secret_env_override_is_rejected() {
let tmp = project();
for bad in [
"",
" ",
"1BAD",
"BAD-NAME",
"BAD NAME",
"BAD\"NAME",
"X\n\n[[security.webhooks.endpoints]]\nname = \"evil\"\nsecret = \"known\"\n# ",
] {
let options = WebhookOptions {
path: None,
secret_env: Some(bad.to_owned()),
};
let Err(error) = plan_webhook(tmp.path(), "stripe", "Payments", &options) else {
panic!("secret env {bad:?} must be rejected");
};
assert!(
error.to_string().contains("secret environment variable"),
"unexpected error for {bad:?}: {error}"
);
}
let toml = fs::read_to_string(tmp.path().join("autumn.toml")).unwrap();
assert!(
!toml.contains("secret ="),
"no plaintext secret may ever reach autumn.toml:\n{toml}"
);
}
#[test]
fn the_route_is_registered_in_main_rs_and_next_steps_are_printed() {
let tmp = project();
let plan =
plan_webhook(tmp.path(), "stripe", "Payments", &WebhookOptions::default()).unwrap();
assert!(
plan.warnings.is_empty(),
"a clean run must print no warnings — next steps go to stdout: {:?}",
plan.warnings
);
plan.execute(Flags::default()).unwrap();
let steps = next_steps("stripe", "Payments", &WebhookOptions::default())
.expect("resolvable arguments have next steps");
assert!(
steps.contains("STRIPE_WEBHOOK_SECRET"),
"the secret env var must be named in the next steps:\n{steps}"
);
assert!(
steps.contains("autumn webhook sim stripe"),
"the next steps should show how to fire a test delivery:\n{steps}"
);
assert!(
steps.contains("payment_intent.succeeded"),
"the simulated delivery must reach a generated stub arm:\n{steps}"
);
assert!(
!steps.contains("--event"),
"stripe carries its event type in the payload:\n{steps}"
);
for (provider, event) in [("github", "push"), ("generic", "example.created")] {
let steps = next_steps(provider, "Intake", &WebhookOptions::default())
.expect("resolvable arguments have next steps");
assert!(
steps.contains(&format!("--event {event}")),
"{provider}: the printed sim must target a generated arm:\n{steps}"
);
}
assert!(
steps.contains("POST /webhooks/stripe"),
"the next steps should name the endpoint to register:\n{steps}"
);
let main_rs = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert!(main_rs.contains("mod webhooks;"), "{main_rs}");
assert!(
main_rs.contains("webhooks::payments::payments_webhook"),
"the generated route must be registered in routes![...]:\n{main_rs}"
);
let mod_rs = fs::read_to_string(tmp.path().join("src/webhooks/mod.rs")).unwrap();
assert!(mod_rs.contains("pub mod payments;"), "{mod_rs}");
}
#[test]
fn generated_test_module_covers_valid_invalid_and_replayed_deliveries() {
let tmp = project();
let src = generated(&tmp, "stripe", "Payments");
let tests = src
.split_once("#[cfg(test)]")
.expect("a generated test module")
.1;
assert!(tests.contains("assert_status(200)"), "{tests}");
assert!(
tests.contains("assert_status(401)"),
"an invalid signature must be asserted as 401:\n{tests}"
);
assert!(
tests.contains("assert_status(400)"),
"a missing signature header must be asserted as 400:\n{tests}"
);
assert!(
tests.contains("assert_status(409)"),
"a replayed delivery id must be asserted as 409:\n{tests}"
);
assert!(
tests.contains("replay"),
"the replay case must be named:\n{tests}"
);
assert!(
tests.contains("\"id\""),
"the fixture body needs an `id` — Stripe's replay key comes from it:\n{tests}"
);
}
#[test]
fn cargo_toml_gets_serde_json_and_tokio_test_features() {
let tmp = project();
generated(&tmp, "stripe", "Payments");
let cargo = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap();
assert!(cargo.contains("serde_json"), "{cargo}");
assert!(cargo.contains("tracing"), "{cargo}");
assert!(
cargo.contains("\"rt\"") && cargo.contains("\"macros\""),
"#[tokio::test] needs the rt + macros dev features:\n{cargo}"
);
}
#[test]
fn the_generated_autumn_toml_deserializes_into_a_usable_webhook_config() {
#[derive(serde::Deserialize)]
struct Root {
security: Security,
}
#[derive(serde::Deserialize)]
struct Security {
webhooks: autumn_web::webhook::WebhookConfig,
}
let tmp = project();
generated(&tmp, "stripe", "Payments");
let toml = fs::read_to_string(tmp.path().join("autumn.toml")).unwrap();
let root: Root = toml::from_str(&toml).expect("generated autumn.toml must deserialize");
let webhooks = root.security.webhooks;
assert_eq!(webhooks.endpoints.len(), 1, "endpoint must be registered");
let endpoint = &webhooks.endpoints[0];
assert_eq!(endpoint.name, "payments");
assert_eq!(endpoint.path, "/webhooks/stripe");
assert_eq!(
endpoint.provider,
autumn_web::webhook::WebhookProvider::Stripe
);
assert_eq!(
endpoint.secret_env.as_deref(),
Some("STRIPE_WEBHOOK_SECRET")
);
assert!(endpoint.replay_protection, "replay protection must be on");
assert!(endpoint.secret.is_none(), "no inline secret may be written");
assert_eq!(
webhooks.replay.backend,
autumn_web::webhook::WebhookReplayBackend::Memory
);
let mut with_secret = webhooks;
with_secret.endpoints[0].secret = Some("test-secret-32-bytes-long-enough".to_owned());
autumn_web::webhook::WebhookRegistry::from_config(&with_secret)
.expect("the generated endpoint must build a registry");
}
#[test]
fn generating_preserves_a_trailing_comment_block_in_autumn_toml() {
let tmp = project();
let with_trailer = "[server]\nport = 3000\n\n[health]\npath = \"/health\"\n\
# live_path = \"/live\"\n# ready_path = \"/ready\"\n";
fs::write(tmp.path().join("autumn.toml"), with_trailer).unwrap();
generated(&tmp, "stripe", "Payments");
let toml = fs::read_to_string(tmp.path().join("autumn.toml")).unwrap();
let health = toml.find("[health]").expect("health table survives");
let live = toml.find("# live_path").expect("comment survives");
let endpoint = toml
.find("[[security.webhooks.endpoints]]")
.expect("endpoint added");
assert!(
live > health && live < endpoint,
"the [health] comment trailer must stay under [health]:\n{toml}"
);
let uncommented = toml.replace("# live_path", "live_path");
let parsed: toml::Value = toml::from_str(&uncommented).unwrap();
assert!(
parsed["health"].get("live_path").is_some(),
"uncommenting must set health.live_path, not a webhook key:\n{uncommented}"
);
}
#[test]
fn generating_over_existing_security_tables_leaves_them_alone() {
let tmp = project();
let existing = "[security]\ncaptcha_exempt_paths = [\"/hook\"]\n\n\
[security.csrf]\nenabled = true\nexempt_paths = [\"/api/\"]\n\n\
[security.webhooks.replay]\nbackend = \"redis\"\n\n\
[security.webhooks.replay.redis]\nurl = \"redis://localhost:6379\"\n";
fs::write(tmp.path().join("autumn.toml"), existing).unwrap();
generated(&tmp, "github", "Repo");
let toml = fs::read_to_string(tmp.path().join("autumn.toml")).unwrap();
assert!(toml.contains("enabled = true"), "{toml}");
assert!(toml.contains("exempt_paths = [\"/api/\"]"), "{toml}");
assert!(
toml.contains("backend = \"redis\""),
"a configured replay backend must survive:\n{toml}"
);
assert!(
!toml.contains("backend = \"memory\""),
"the replay stub must not be added over an existing one:\n{toml}"
);
assert!(toml.contains("[[security.webhooks.endpoints]]"), "{toml}");
}
#[test]
fn regenerating_with_a_changed_path_updates_the_endpoint_block() {
let tmp = project();
generated(&tmp, "stripe", "Payments");
let moved = WebhookOptions {
path: Some("/hooks/pay".to_owned()),
secret_env: Some("PAY_SECRET".to_owned()),
};
plan_webhook(tmp.path(), "stripe", "Payments", &moved)
.unwrap()
.execute(Flags {
force: true,
..Flags::default()
})
.unwrap();
let toml = fs::read_to_string(tmp.path().join("autumn.toml")).unwrap();
assert!(toml.contains("path = \"/hooks/pay\""), "{toml}");
assert!(toml.contains("secret_env = \"PAY_SECRET\""), "{toml}");
assert!(
!toml.contains("/webhooks/stripe"),
"the stale path must be gone:\n{toml}"
);
assert_eq!(
toml.matches("[[security.webhooks.endpoints]]").count(),
1,
"the entry must be updated, not duplicated:\n{toml}"
);
plan_webhook(tmp.path(), "stripe", "Payments", &moved)
.unwrap()
.revert(Flags::default())
.unwrap();
let toml = fs::read_to_string(tmp.path().join("autumn.toml")).unwrap();
assert!(!toml.contains("[[security.webhooks.endpoints]]"), "{toml}");
}
#[test]
fn destroy_recovers_a_custom_path_and_secret_env_without_repeating_the_flags() {
let tmp = project();
let options = WebhookOptions {
path: Some("/hooks/pay".to_owned()),
secret_env: Some("PAY_SECRET".to_owned()),
};
plan_webhook(tmp.path(), "stripe", "Payments", &options)
.unwrap()
.execute(Flags::default())
.unwrap();
plan_webhook_for_revert(tmp.path(), "stripe", "Payments", &WebhookOptions::default())
.unwrap()
.revert(Flags::default())
.unwrap();
let toml = fs::read_to_string(tmp.path().join("autumn.toml")).unwrap();
assert!(
!toml.contains("[[security.webhooks.endpoints]]"),
"the endpoint block must be removed:\n{toml}"
);
assert!(
!toml.contains("/hooks/pay") && !toml.contains("PAY_SECRET"),
"no trace of the endpoint may remain:\n{toml}"
);
assert!(
!tmp.path().join("src/webhooks").exists(),
"the handler must be removed without --force"
);
let main_rs = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert!(!main_rs.contains("payments_webhook"), "{main_rs}");
}
#[test]
fn an_explicit_flag_still_wins_over_the_recorded_endpoint() {
let tmp = project();
plan_webhook(
tmp.path(),
"stripe",
"Payments",
&WebhookOptions {
path: Some("/hooks/pay".to_owned()),
secret_env: None,
},
)
.unwrap()
.execute(Flags::default())
.unwrap();
let adopted = adopt_recorded_overrides(
tmp.path(),
"Payments",
&WebhookOptions {
path: Some("/elsewhere".to_owned()),
secret_env: None,
},
);
assert_eq!(adopted.path.as_deref(), Some("/elsewhere"));
assert_eq!(
adopted.secret_env.as_deref(),
Some("STRIPE_WEBHOOK_SECRET"),
"the unspecified flag is still recovered"
);
}
#[test]
fn destroying_one_of_two_webhooks_keeps_the_other_working() {
let tmp = project();
generated(&tmp, "stripe", "Payments");
generated(&tmp, "github", "Repo");
plan_webhook(tmp.path(), "stripe", "Payments", &WebhookOptions::default())
.unwrap()
.revert(Flags::default())
.unwrap();
let toml = fs::read_to_string(tmp.path().join("autumn.toml")).unwrap();
assert!(!toml.contains("/webhooks/stripe"), "{toml}");
assert!(
toml.contains("name = \"repo\""),
"the sibling must survive:\n{toml}"
);
assert!(
toml.contains("[security.webhooks.replay]"),
"the shared replay block is still needed:\n{toml}"
);
assert!(tmp.path().join("src/webhooks/repo.rs").exists());
assert!(!tmp.path().join("src/webhooks/payments.rs").exists());
let main_rs = fs::read_to_string(tmp.path().join("src/main.rs")).unwrap();
assert!(main_rs.contains("mod webhooks;"), "{main_rs}");
assert!(
main_rs.contains("webhooks::repo::repo_webhook"),
"{main_rs}"
);
assert!(!main_rs.contains("payments_webhook"), "{main_rs}");
}
#[test]
fn destroy_leaves_a_hand_edited_endpoint_and_a_configured_replay_backend_alone() {
let tmp = project();
generated(&tmp, "stripe", "Payments");
let toml = fs::read_to_string(tmp.path().join("autumn.toml")).unwrap();
let edited = toml
.replace(
"previous_secret_envs = []",
"previous_secret_envs = [\"STRIPE_WEBHOOK_SECRET_PREVIOUS\"]",
)
.replace("backend = \"memory\"", "backend = \"redis\"");
fs::write(tmp.path().join("autumn.toml"), &edited).unwrap();
plan_webhook(tmp.path(), "stripe", "Payments", &WebhookOptions::default())
.unwrap()
.revert(Flags::default())
.unwrap();
let after = fs::read_to_string(tmp.path().join("autumn.toml")).unwrap();
assert_eq!(
after, edited,
"hand-edited webhook config must survive destroy untouched"
);
assert!(
!tmp.path().join("src/webhooks").exists(),
"the generated code is still removed"
);
}
#[test]
fn destroy_cleans_up_a_replay_stub_orphaned_by_a_hand_removed_endpoint() {
let tmp = project();
generated(&tmp, "stripe", "Payments");
let toml = fs::read_to_string(tmp.path().join("autumn.toml")).unwrap();
let endpoint_start = toml.find("# Signed webhook intake generated").unwrap();
fs::write(tmp.path().join("autumn.toml"), &toml[..endpoint_start]).unwrap();
plan_webhook(tmp.path(), "stripe", "Payments", &WebhookOptions::default())
.unwrap()
.revert(Flags::default())
.unwrap();
let after = fs::read_to_string(tmp.path().join("autumn.toml")).unwrap();
assert!(
!after.contains("[security.webhooks.replay]"),
"an orphaned replay stub must not linger:\n{after}"
);
}
#[test]
fn a_conflicting_src_webhooks_module_file_is_rejected() {
let tmp = project();
fs::write(tmp.path().join("src/webhooks.rs"), "// hand-written\n").unwrap();
let error = plan_webhook(tmp.path(), "stripe", "Payments", &WebhookOptions::default())
.expect_err("E0761 would break the whole build");
assert!(error.to_string().contains("src/webhooks.rs"), "{error}");
}
#[test]
fn a_main_rs_without_a_routes_list_warns_instead_of_silently_not_registering() {
let tmp = project();
fs::write(
tmp.path().join("src/main.rs"),
"#[autumn_web::main]\nasync fn main() {\n autumn_web::app().run().await;\n}\n",
)
.unwrap();
let plan =
plan_webhook(tmp.path(), "stripe", "Payments", &WebhookOptions::default()).unwrap();
assert!(
plan.warnings
.iter()
.any(|warning| warning.contains("routes![")),
"an unroutable handler must be called out: {:?}",
plan.warnings
);
}
#[test]
fn generate_then_destroy_round_trips_to_the_original_project_state() {
let tmp = project();
let main_path = tmp.path().join("src/main.rs");
let toml_path = tmp.path().join("autumn.toml");
let original_main = fs::read_to_string(&main_path).unwrap();
let original_toml = fs::read_to_string(&toml_path).unwrap();
let plan =
plan_webhook(tmp.path(), "stripe", "Payments", &WebhookOptions::default()).unwrap();
plan.execute(Flags::default()).unwrap();
let destroy =
plan_webhook(tmp.path(), "stripe", "Payments", &WebhookOptions::default()).unwrap();
destroy.revert(Flags::default()).unwrap();
assert!(!tmp.path().join("src/webhooks").exists());
assert_eq!(fs::read_to_string(&main_path).unwrap(), original_main);
assert_eq!(fs::read_to_string(&toml_path).unwrap(), original_toml);
}
}