use serde::{Deserialize, Serialize}; /// One page: a prompt plus the paths on from it. `id` doubles as the URL /// path it's served at ("/" is the landing page). Loaded from a plain /// YAML file per question in a content directory kept in its own git /// repo (see ../portal-content) - editing content is a content-repo /// commit, not a Rust rebuild. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Question { pub id: String, #[serde(default)] pub route: Option, pub name: String, #[serde(default)] pub description: String, /// Kanidm group required to view/submit this question - `None` means /// open to anyone, matching every question today. Content-driven /// on purpose: a gated page like "/review" is just a Question with /// this set, not a bespoke Rust route. #[serde(default)] pub qualifies: Option, #[serde(default)] pub alternatives: Vec, /// Who to contact if a visitor gets stuck - rendered as a small line /// on the page. #[serde(default)] pub responsible: Option, /// A page that only makes sense after answering something (the /// post-submission pages) - kept out of the question nav unless the /// visitor's context carries an answer chain. #[serde(default)] pub followup: bool, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Responsible { pub name: String, /// A mailto address or plain email - deliberately just a string, /// same as everywhere else content keeps contact info simple. pub contact: String, } /// Whether `user` may view/submit `question`. `true` when the question /// has no `qualifies` requirement. Mirrors `chat::is_authorized_for_room` /// in cnats - same synchronous, I/O-free shape, same staleness tradeoff /// (group membership is fixed at login, not re-checked live). pub fn is_qualified(user: Option<&crate::auth::User>, question: &Question) -> bool { match &question.qualifies { None => true, Some(group) => user.is_some_and(|u| u.groups.iter().any(|g| g == group)), } } /// One path through a question: a short pitch, an optional next question /// to advance to on submit, and the form (via `features`) that collects /// what's needed to get there. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Alternative { pub name: String, #[serde(default)] pub description: String, #[serde(default)] pub action: Option, #[serde(default)] pub consequence: Vec, #[serde(default)] pub encouragements: Vec, /// Banner image urls, rendered above the description - one renders /// as a plain image, several as a swipeable card deck (Swiper /// Element, vendored in `public/`). Purely decorative, no /// upload/hosting mechanism of their own, just already-hosted urls /// the browser fetches directly (unlike `ResourceSource::Url`, /// never fetched server-side, so none of that variant's SSRF /// concern). #[serde(default)] pub images: Vec, #[serde(default)] pub features: Vec, /// NATS KV bucket to durably store this submission into - just a /// bucket name, never a keyword the runtime special-cases. #[serde(default)] pub record_as: Option, /// A transition fireable by anyone holding one specific item's own /// reference (`?chain=` link) plus a matching `email` - the /// anonymous, single-item counterpart to `ResourceSpec.transitions`' /// group-gated bucket browsing. The email is a second factor /// checked against the stored item, not the lookup key. #[serde(default)] pub self_transition: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SelfTransition { pub bucket: String, pub to: String, pub label: String, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Feature { pub name: String, #[serde(default)] pub description: String, /// Any valid CSS color - set as the feature's `--feature-accent` /// custom property (never interpolated into a stylesheet, so a bad /// value fails to apply instead of injecting CSS). Unset means no /// accent border at all. #[serde(default)] pub color: Option, /// An Iconify icon name (`{prefix}:{name}`, e.g. `lucide:star`), /// rendered via Iconify's public SVG API - no icon library bundled. #[serde(default)] pub icon: Option, #[serde(default)] pub requirements: Vec, /// Live data this feature pulls in. Read-only unless `transitions` /// is non-empty, in which case listed answers get one action button /// per transition (see `src/resource.rs`, `src/answers.rs`). #[serde(default)] pub resource: Option, } /// A live-data read declared in content. The bucket/key are only ever /// resolved server-side from trusted content - a client names a /// question + feature, never a bucket directly, so a visitor can't /// probe arbitrary buckets. No render-mode tag: what a resource /// displays as follows from its data's shape. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ResourceSpec { pub source: ResourceSource, /// A single item; omit to list the whole bucket. Only meaningful /// for a `Kv` source. #[serde(default)] pub key: Option, /// Kanidm group required to read this resource. #[serde(default)] pub requires_group: Option, /// Must be explicitly set for an anonymous-readable resource - a /// spec with neither this nor `requires_group` is unreachable /// (fail closed). Reads only: mutations always require /// `requires_group` regardless of this flag. #[serde(default)] pub public: bool, /// The moves a listed answer may make, one button each - empty /// means read-only. Server calls are checked against this /// allow-list, so a client can never fire a transition content /// didn't declare. `Kv` sources only. #[serde(default)] pub transitions: Vec, /// A jq filter (evaluated via `jaq`, no shell-out) reshaping the /// fetched value before it reaches the frontend - e.g. /// `.[] | {name, url: .html_url}`. `None` returns it as-is. #[serde(default)] pub jq: Option, } /// Where a resource's live data comes from. `Kv` (a NATS KV bucket /// this server owns) is the only mutable source; the rest are /// read-only live pulls. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ResourceSource { Kv { bucket: String }, GiteaStarred { username: String }, GiteaOrgRepos { org: String }, /// Any other HTTPS JSON endpoint. Scheme-restricted and checked /// against loopback/private/link-local addresses at fetch time /// (`resource::fetch_url_resource`) - a server-side fetch of a /// content-supplied URL is SSRF surface, so it fails closed. Url { url: String }, } impl ResourceSpec { /// The KV bucket this resource reads/writes - `None` for a live /// external pull. pub fn bucket(&self) -> Option<&str> { match &self.source { ResourceSource::Kv { bucket } => Some(bucket), ResourceSource::GiteaStarred { .. } | ResourceSource::GiteaOrgRepos { .. } | ResourceSource::Url { .. } => None, } } } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Transition { /// The state a row must be in for this transition's button to /// render and its server call to be accepted. Defaults to "open". #[serde(default = "default_transition_from")] pub from: String, pub to: String, pub label: String, } fn default_transition_from() -> String { crate::answers::OPEN_STATE.to_string() } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Requirement { pub name: String, #[serde(default)] pub label: Option, #[serde(default)] pub placeholder: Option, #[serde(default = "default_requirement_type", rename = "type")] pub kind: String, #[serde(default)] pub optional: bool, /// `type: file` - accept multiple files. `type: select` - pick more /// than one option (checkbox-style toggle) instead of exactly one /// (radio-style); the submitted value is a JSON array of ids /// instead of a single id string. #[serde(default)] pub multiple: bool, /// `type: file` only - HTML `accept` hint (UX only, not a security /// boundary - the upload handler re-checks content-type itself). #[serde(default)] pub accept: Option, /// `type: select` only - where the options come from; the same /// `ResourceSpec` mechanism a `Feature.resource` uses. #[serde(default)] pub resource: Option, /// `type: select` only - which field in each item is the option's /// stable id. Defaults to trying `_id` then `id`. #[serde(default)] pub id_field: Option, } fn default_requirement_type() -> String { "text".to_string() } impl Requirement { pub fn display_label(&self) -> String { self.label.clone().unwrap_or_else(|| self.name.clone()) } } /// Extracts `scheme://host` from a repo's normal browser URL - the /// Gitea API base every helper in this module builds requests against. #[cfg(feature = "ssr")] pub fn gitea_api_base(repo_url: &str) -> anyhow::Result { let parsed = url::Url::parse(repo_url) .map_err(|e| anyhow::anyhow!("parsing repo url {repo_url}: {e}"))?; Ok(format!( "{}://{}", parsed.scheme(), parsed .host_str() .ok_or_else(|| anyhow::anyhow!("no host in repo url {repo_url}"))? )) } /// Splits a repo's normal browser URL into `(owner, repo)` - shared by /// every loader in this module that needs to build a Gitea contents API /// URL (`load_questions_from_gitea`, `load_aggregates_from_gitea`). #[cfg(feature = "ssr")] fn parse_owner_repo(repo_url: &str) -> anyhow::Result<(String, String)> { let parsed = url::Url::parse(repo_url) .map_err(|e| anyhow::anyhow!("parsing content repo url {repo_url}: {e}"))?; let mut segments = parsed .path_segments() .ok_or_else(|| anyhow::anyhow!("no path in content repo url {repo_url}"))?; let owner = segments .next() .filter(|s| !s.is_empty()) .ok_or_else(|| anyhow::anyhow!("missing owner in content repo url {repo_url}"))? .to_string(); let repo = segments .next() .filter(|s| !s.is_empty()) .ok_or_else(|| anyhow::anyhow!("missing repo name in content repo url {repo_url}"))? .to_string(); Ok((owner, repo)) } /// Fetches one file from a Gitea repo's contents API and returns its raw /// text - the single-file counterpart to `load_questions_from_gitea`'s /// directory-listing loop, used by `load_aggregates_from_gitea` for the /// one `aggregates.yaml` file at the repo root. #[cfg(feature = "ssr")] async fn fetch_gitea_file( client: &openidconnect::reqwest::Client, api_base: &str, owner: &str, repo: &str, branch: &str, path: &str, ) -> anyhow::Result { let meta_url = format!("{api_base}/api/v1/repos/{owner}/{repo}/contents/{path}?ref={branch}"); let meta_text = client .get(&meta_url) .send() .await .map_err(|e| anyhow::anyhow!("fetching {meta_url}: {e}"))? .error_for_status() .map_err(|e| anyhow::anyhow!("fetching {meta_url}: {e}"))? .text() .await .map_err(|e| anyhow::anyhow!("reading contents response from {meta_url}: {e}"))?; let meta: serde_json::Value = serde_json::from_str(&meta_text) .map_err(|e| anyhow::anyhow!("parsing contents response from {meta_url}: {e}"))?; let download_url = meta .get("download_url") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("no download_url for {path}"))?; client .get(download_url) .send() .await .map_err(|e| anyhow::anyhow!("fetching {path}: {e}"))? .error_for_status() .map_err(|e| anyhow::anyhow!("fetching {path}: {e}"))? .text() .await .map_err(|e| anyhow::anyhow!("reading {path}: {e}")) } /// Fetches and parses `aggregates.yaml` from the repo root (sibling to /// the pages `subdir`, so the "every yaml under subdir is a page" /// convention needs no exclusion). A malformed file fails here, before /// ever reaching `AppState`. #[cfg(feature = "ssr")] pub async fn load_aggregates_from_gitea( repo_url: &str, branch: &str, ) -> anyhow::Result> { let (owner, repo) = parse_owner_repo(repo_url)?; let api_base = gitea_api_base(repo_url)?; let client = openidconnect::reqwest::Client::new(); let raw = fetch_gitea_file(&client, &api_base, &owner, &repo, branch, "aggregates.yaml").await?; crate::aggregates::parse_aggregates_yaml(&raw) } /// Fetches every `*.yaml` file under `subdir` in a Gitea repo as a /// `Question`, keyed by its own `id`. `repo_url` is the repo's normal /// browser URL (e.g. `https://project.uhhm.no/uhhm/questions`) - the /// Gitea host, owner and repo name are all read from it. Called once at /// startup, and again on every `CONTENT_RELOAD_SUBJECT` message (see /// `watch_for_reload`), over Gitea's public contents API (no auth - the /// content repo is public). #[cfg(feature = "ssr")] pub async fn load_questions_from_gitea( repo_url: &str, branch: &str, subdir: &str, ) -> anyhow::Result> { let (owner, repo) = parse_owner_repo(repo_url)?; let api_base = gitea_api_base(repo_url)?; let client = openidconnect::reqwest::Client::new(); let list_url = format!("{api_base}/api/v1/repos/{owner}/{repo}/contents/{subdir}?ref={branch}"); let listing = client .get(&list_url) .send() .await .map_err(|e| anyhow::anyhow!("listing {list_url}: {e}"))? .error_for_status() .map_err(|e| anyhow::anyhow!("listing {list_url}: {e}"))? .text() .await .map_err(|e| anyhow::anyhow!("reading directory listing from {list_url}: {e}"))?; let entries: Vec = serde_json::from_str(&listing) .map_err(|e| anyhow::anyhow!("parsing directory listing from {list_url}: {e}"))?; let mut out = std::collections::HashMap::new(); for entry in entries { let name = entry.get("name").and_then(|v| v.as_str()).unwrap_or(""); if !name.ends_with(".yaml") { continue; } let download_url = entry .get("download_url") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("no download_url for {name}"))?; let raw = client .get(download_url) .send() .await .map_err(|e| anyhow::anyhow!("fetching {name}: {e}"))? .error_for_status() .map_err(|e| anyhow::anyhow!("fetching {name}: {e}"))? .text() .await .map_err(|e| anyhow::anyhow!("reading {name}: {e}"))?; let question: Question = serde_yaml::from_str(&raw).map_err(|e| anyhow::anyhow!("parsing {name}: {e}"))?; out.insert(question.id.clone(), question); } Ok(out) } /// Validates every declared transition target (`SelfTransition.to`, /// `ResourceSpec.transitions[].to`) against `aggregates` - the real /// state graph loaded from `aggregates.yaml` - for every bucket that /// has one declared. A bucket with no entry in `aggregates` is left /// alone entirely (no validation applied): not every bucket needs to /// be event-sourced to keep working. Called on every content /// load/reload (`watch_for_reload`, `main.rs`'s boot path) and by the /// standalone `question-lint` binary, so a YAML typo becomes a caught, /// logged rejection instead of a silently-accepted, later-broken /// string. #[cfg(feature = "ssr")] pub fn validate_questions( questions: &std::collections::HashMap, aggregates: &std::collections::HashMap, ) -> anyhow::Result<()> { for question in questions.values() { for alternative in &question.alternatives { // A dangling action is a literal dead end: the submit // button navigates to "Nothing here". if let Some(action) = &alternative.action { if !questions.contains_key(action) { anyhow::bail!( "question {:?} alternative {:?}: action {:?} does not match any declared question id", question.id, alternative.name, action ); } } if let Some(st) = &alternative.self_transition { if let Some(schema) = aggregates.get(&st.bucket) { if !schema.has_state(&st.to) { anyhow::bail!( "question {:?} alternative {:?}: self_transition.to {:?} is not a declared state for bucket {:?}", question.id, alternative.name, st.to, st.bucket ); } } } for feature in &alternative.features { for requirement in &feature.requirements { if requirement.kind == "select" && requirement.resource.is_none() { anyhow::bail!( "question {:?} alternative {:?} feature {:?}: requirement {:?} is type: select but declares no resource to select from", question.id, alternative.name, feature.name, requirement.name ); } } let Some(resource) = &feature.resource else { continue; }; let Some(bucket) = resource.bucket() else { continue; }; let Some(schema) = aggregates.get(bucket) else { continue; }; for transition in &resource.transitions { if !schema.has_state(&transition.from) { anyhow::bail!( "question {:?} alternative {:?} feature {:?}: transition.from {:?} is not a declared state for bucket {:?}", question.id, alternative.name, feature.name, transition.from, bucket ); } if !schema.has_state(&transition.to) { anyhow::bail!( "question {:?} alternative {:?} feature {:?}: transition.to {:?} is not a declared state for bucket {:?}", question.id, alternative.name, feature.name, transition.to, bucket ); } if !schema.allowed(&transition.from).iter().any(|s| s == &transition.to) { anyhow::bail!( "question {:?} alternative {:?} feature {:?}: transition {:?} -> {:?} is not a declared edge for bucket {:?}", question.id, alternative.name, feature.name, transition.from, transition.to, bucket ); } } } } } Ok(()) } /// Published by the content repo's own CI (after it lints a push) to /// tell every running instance to pick up the change - a plain fire /// and forget NATS publish, no payload, matching `events.rs`'s /// `ANSWERS_SUBJECT` pattern. #[cfg(feature = "ssr")] pub const CONTENT_RELOAD_SUBJECT: &str = "portal.content.reload"; /// Runs for the life of the process: re-fetches `repo_url`/`branch` and /// atomically swaps both `questions` and `aggregates` on every /// `CONTENT_RELOAD_SUBJECT` message - the two only ever swap together, /// after both have loaded and validated successfully, so a reader never /// sees pages that reference a half-updated state graph. A fetch/parse /// failure logs and keeps serving the last-good content rather than /// clearing it - a bad push to the content repo (which should already /// have been caught by its own lint step) doesn't take the site down. #[cfg(feature = "ssr")] pub async fn watch_for_reload( nats: async_nats::Client, repo_url: String, branch: String, subdir: String, questions: std::sync::Arc>>, aggregates: std::sync::Arc< arc_swap::ArcSwap>, >, ) { let mut sub = match nats.subscribe(CONTENT_RELOAD_SUBJECT).await { Ok(sub) => sub, Err(e) => { tracing::error!(error = %e, "failed to subscribe to content reload subject"); return; } }; use futures::StreamExt; while sub.next().await.is_some() { let loaded_aggregates = match load_aggregates_from_gitea(&repo_url, &branch).await { Ok(loaded) => loaded, Err(e) => { tracing::error!(error = %e, "aggregates.yaml reload failed, keeping last-good content"); continue; } }; match load_questions_from_gitea(&repo_url, &branch, &subdir).await { Ok(loaded) => { if let Err(e) = validate_questions(&loaded, &loaded_aggregates) { tracing::error!(error = %e, "content reload failed validation, keeping last-good content"); continue; } let count = loaded.len(); questions.store(std::sync::Arc::new(loaded)); aggregates.store(std::sync::Arc::new(loaded_aggregates)); tracing::info!(count, "reloaded content"); } Err(e) => { tracing::error!(error = %e, "content reload failed, keeping last-good content"); } } } } /// A public Gitea repo's basic info - what `resolve_gitea_repo` returns /// for the prosekit editor's repo-embed node to render as a static /// card, baked in once at embed time rather than re-fetched by every /// reader (an emailed newsletter can't run JS to do that anyway). #[derive(Clone, Debug, Serialize, Deserialize)] pub struct GiteaRepoInfo { pub owner: String, pub repo: String, pub description: String, pub url: String, } #[derive(Deserialize)] #[cfg(feature = "ssr")] pub struct GiteaRepoQuery { pub owner: String, pub repo: String, } /// Looks up `owner/repo` on the same Gitea instance content is loaded /// from (`AppState.gitea_base`) - a raw Axum handler (mounted at /// `/gitea-repo` in `main.rs`), not a Leptos server fn, since the /// caller here is the prosekit editor's own paste-to-embed rule (see /// `prosekit-editor.js`) doing a plain `fetch`, the same reason /// `/upload` (`src/upload.rs`) is a raw handler rather than a `#[server]` /// fn. Keeping this server-resolved (rather than having the browser /// call Gitea's API directly) is consistent with every other backing /// store in this app, and sidesteps needing a CORS allowance on Gitea's /// side just for this. No auth, same as content loading - resolves /// only what's already public. #[cfg(feature = "ssr")] pub async fn gitea_repo_handler( axum::extract::State(state): axum::extract::State, axum::extract::Query(query): axum::extract::Query, ) -> Result, (axum::http::StatusCode, String)> { let is_safe_segment = |s: &str| { !s.is_empty() && s.chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') }; if !is_safe_segment(&query.owner) || !is_safe_segment(&query.repo) { return Err((axum::http::StatusCode::BAD_REQUEST, "invalid owner/repo".to_string())); } let GiteaRepoQuery { owner, repo } = query; let client = openidconnect::reqwest::Client::new(); let api_url = format!("{}/api/v1/repos/{owner}/{repo}", state.gitea_base); let body = client .get(&api_url) .send() .await .and_then(|r| r.error_for_status()) .map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, format!("fetching {api_url}: {e}")))? .text() .await .map_err(|e| { ( axum::http::StatusCode::BAD_GATEWAY, format!("reading repo info from {api_url}: {e}"), ) })?; let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| { ( axum::http::StatusCode::BAD_GATEWAY, format!("parsing repo info from {api_url}: {e}"), ) })?; Ok(axum::Json(GiteaRepoInfo { owner: owner.clone(), repo: repo.clone(), description: json .get("description") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(), url: json .get("html_url") .and_then(|v| v.as_str()) .map(|s| s.to_string()) .unwrap_or_else(|| format!("{}/{owner}/{repo}", state.gitea_base)), })) } /// Lists every entry in a NATS KV bucket as JSON - a raw Axum handler /// (mounted at `/automation/kv/{bucket}` in `main.rs`), for backing /// automations (e.g. an n8n workflow reading `portal_subscribers` to /// send a newsletter) that aren't a signed-in browser session and so /// can't go through `resource::get_resource`'s Kanidm-group check. /// Gated by a single shared bearer token (`AUTOMATION_READ_TOKEN`) - /// deliberately not per-caller/per-bucket scoped, since every current /// caller is a trusted internal automation, not a third party. Read /// only, matching `get_resource`'s own "reads can be public/shared, /// mutations always need real identity" split - nothing here writes. #[cfg(feature = "ssr")] pub async fn automation_kv_handler( axum::extract::State(state): axum::extract::State, axum::extract::Path(bucket): axum::extract::Path, headers: axum::http::HeaderMap, ) -> Result, (axum::http::StatusCode, String)> { let expected = std::env::var("AUTOMATION_READ_TOKEN").unwrap_or_default(); let presented = headers .get(axum::http::header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")) .unwrap_or(""); if expected.is_empty() || presented != expected { return Err((axum::http::StatusCode::UNAUTHORIZED, "unauthorized".to_string())); } let store = state .jetstream .get_key_value(&bucket) .await .map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, format!("bucket unavailable: {e}")))?; use futures::TryStreamExt; let keys: Vec = store .keys() .await .map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, e.to_string()))? .try_collect() .await .map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, e.to_string()))?; let mut items = Vec::new(); for key in keys { if let Ok(Some(bytes)) = store.get(&key).await { if let Ok(value) = serde_json::from_slice::(&bytes) { items.push(value); } } } Ok(axum::Json(serde_json::Value::Array(items))) } #[cfg(all(test, feature = "ssr"))] mod tests { use super::*; fn schema_fixture() -> std::collections::HashMap { crate::aggregates::parse_aggregates_yaml( r#" aggregates: - bucket: things initial: open states: open: { event: opened } middle: { event: advanced } done: { event: finished } transitions: open: [middle] middle: [done] "#, ) .unwrap() } fn question_with_transitions(transitions_yaml: &str) -> std::collections::HashMap { let question: Question = serde_yaml::from_str(&format!( r#" id: /t name: T alternatives: - name: A features: - name: "" resource: source: {{ kind: kv, bucket: things }} requires_group: owners transitions: {transitions_yaml} "# )) .unwrap(); std::collections::HashMap::from([(question.id.clone(), question)]) } #[test] fn declared_edge_passes() { let questions = question_with_transitions( " - { from: open, to: middle, label: Advance }\n - { from: middle, to: done, label: Finish }", ); assert!(validate_questions(&questions, &schema_fixture()).is_ok()); } #[test] fn from_defaults_to_open() { let questions = question_with_transitions(" - { to: middle, label: Advance }"); assert!(validate_questions(&questions, &schema_fixture()).is_ok()); } #[test] fn undeclared_from_state_is_rejected() { let questions = question_with_transitions(" - { from: bogus, to: middle, label: X }"); let err = validate_questions(&questions, &schema_fixture()).unwrap_err(); assert!(err.to_string().contains("transition.from")); } #[test] fn undeclared_edge_is_rejected() { // Both states exist, but open -> done skips a step the graph // never declared. let questions = question_with_transitions(" - { from: open, to: done, label: Skip }"); let err = validate_questions(&questions, &schema_fixture()).unwrap_err(); assert!(err.to_string().contains("not a declared edge")); } #[test] fn dangling_action_is_rejected() { let question: Question = serde_yaml::from_str("id: /a\nname: A\nalternatives:\n - name: Go\n action: /nowhere\n") .unwrap(); let questions = std::collections::HashMap::from([(question.id.clone(), question)]); let err = validate_questions(&questions, &Default::default()).unwrap_err(); assert!(err.to_string().contains("does not match any declared question id")); } #[test] fn self_referencing_action_passes() { let question: Question = serde_yaml::from_str("id: /a\nname: A\nalternatives:\n - name: Go\n action: /a\n") .unwrap(); let questions = std::collections::HashMap::from([(question.id.clone(), question)]); assert!(validate_questions(&questions, &Default::default()).is_ok()); } }