Skip to main content

cratefield_core/
surface.rs

1//! The UI surface (ADR 0010, issue #70). A module declares the actions it
2//! serves and the views that compose them; `Harness::build` validates the
3//! declaration and `GET /__surface` serves the composed document.
4//!
5//! An axum `Router` is opaque, so nothing here is discovered: a module says
6//! what it offers, and the input schema of each action is derived with
7//! `schemars` from the same serde type the handler deserializes, which is
8//! what keeps the declaration from drifting.
9//!
10//! UI hints ride on the schema as `x-cf-*` extension keywords set with
11//! `#[schemars(extend("x-cf-label" = "Email"))]` on a field. The keywords
12//! the renderer understands are listed in [`HINT_KEYWORDS`].
13
14use std::collections::{BTreeSet, HashSet};
15
16use http::Method;
17use schemars::{JsonSchema, Schema, SchemaGenerator};
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20
21use crate::config::ConfigError;
22use crate::module::{HARNESS_API, Module};
23use crate::venture::Venture;
24
25/// Contract version of the surface document, independent of
26/// [`HARNESS_API`]: a renderer or the control plane checks it before
27/// reading the document.
28pub const SURFACE_API: u32 = 1;
29
30/// The `x-cf-*` extension keywords the renderer understands on a field
31/// schema. Anything else under `x-cf-` is ignored, never an error, so a
32/// module can target a newer renderer than the one that serves it.
33///
34/// | Keyword | Value | Meaning |
35/// |---|---|---|
36/// | `x-cf-label` | string | Field label; defaults to the field name |
37/// | `x-cf-placeholder` | string | Input placeholder |
38/// | `x-cf-help` | string | Help text under the input |
39/// | `x-cf-widget` | `"text"`, `"email"`, `"select"`, `"textarea"`, `"checkbox"`, `"hidden"` | Input widget; inferred from the schema when absent |
40/// | `x-cf-hidden` | bool | Never rendered; the renderer supplies it (`captchaToken`) or omits it |
41/// | `x-cf-options` | array of `{value, label}` | Choices for a `select`, when `enum` on the schema is not enough |
42pub const HINT_KEYWORDS: &[&str] = &[
43    "x-cf-label",
44    "x-cf-placeholder",
45    "x-cf-help",
46    "x-cf-widget",
47    "x-cf-hidden",
48    "x-cf-options",
49];
50
51/// Who an action is for. Drives the public/admin split of `/__surface`
52/// and, in the renderer, which pages need the admin session.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "kebab-case")]
55pub enum Audience {
56    /// Anyone; the action is the venture's public face (`join`,
57    /// `subscribe`).
58    Public,
59    /// Needs `Authorization: Bearer <ADMIN_TOKEN>`; path must be under
60    /// `/admin/`.
61    Admin,
62    /// Reached only through a signed link the module minted (`confirm`,
63    /// `unsubscribe`, `status`); rendered as a landing page, never as a
64    /// form.
65    Link,
66}
67
68/// What the browser should do with a successful response.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(tag = "kind", rename_all = "kebab-case")]
71pub enum Outcome {
72    /// The module answers `202` (or `200`) with nothing the user needs to
73    /// see; show `message`.
74    Accepted { message: String },
75    /// The module answers with a redirect the browser follows.
76    Redirect,
77    /// The module answers with a JSON body the view renders (`status`).
78    Json,
79}
80
81/// One route the module serves, described for a renderer.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct Action {
84    /// Kebab-case, unique within the module (`join`, `confirm`,
85    /// `export-csv`).
86    pub name: String,
87    /// HTTP method, serialized as its upper-case name.
88    #[serde(with = "method_serde")]
89    pub method: Method,
90    /// Path relative to `/v1/<module>`, always starting with `/`.
91    pub path: String,
92    pub audience: Audience,
93    /// JSON Schema of the request body (or of the query for a `GET`).
94    /// `None` for an action that takes nothing.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub input: Option<Schema>,
97    pub outcome: Outcome,
98    /// Whether the module verifies a captcha token on this action when the
99    /// `Captcha` port is configured; the renderer then includes the widget.
100    pub captcha: bool,
101}
102
103impl Action {
104    /// A public `POST` that answers `202`, the common case for a signup
105    /// form. Add `.input::<Body>()`, `.captcha()` and friends.
106    #[must_use]
107    pub fn post(name: impl Into<String>, path: impl Into<String>) -> Self {
108        Self::new(name, Method::POST, path)
109    }
110
111    /// A `GET`. Audience defaults to `Link` because a module's `GET`s are
112    /// the signed-link landings; call `.audience(..)` otherwise.
113    #[must_use]
114    pub fn get(name: impl Into<String>, path: impl Into<String>) -> Self {
115        Self::new(name, Method::GET, path)
116            .audience(Audience::Link)
117            .outcome(Outcome::Redirect)
118    }
119
120    /// A `DELETE`, admin by default.
121    #[must_use]
122    pub fn delete(name: impl Into<String>, path: impl Into<String>) -> Self {
123        Self::new(name, Method::DELETE, path)
124            .audience(Audience::Admin)
125            .outcome(Outcome::Json)
126    }
127
128    #[must_use]
129    pub fn new(name: impl Into<String>, method: Method, path: impl Into<String>) -> Self {
130        Self {
131            name: name.into(),
132            method,
133            path: path.into(),
134            audience: Audience::Public,
135            input: None,
136            outcome: Outcome::Accepted {
137                message: "Thanks, you're in.".to_owned(),
138            },
139            captcha: false,
140        }
141    }
142
143    #[must_use]
144    pub fn audience(mut self, audience: Audience) -> Self {
145        self.audience = audience;
146        self
147    }
148
149    /// Derives the input schema from the handler's own body type.
150    #[must_use]
151    pub fn input<T: JsonSchema>(mut self) -> Self {
152        self.input = Some(schema_for::<T>());
153        self
154    }
155
156    /// Supplies a schema built by hand or adjusted after derivation (a
157    /// `select` whose options come from runtime settings).
158    #[must_use]
159    pub fn input_schema(mut self, schema: Schema) -> Self {
160        self.input = Some(schema);
161        self
162    }
163
164    #[must_use]
165    pub fn outcome(mut self, outcome: Outcome) -> Self {
166        self.outcome = outcome;
167        self
168    }
169
170    /// Shorthand for `.outcome(Outcome::Accepted { message })`.
171    #[must_use]
172    pub fn accepted(self, message: impl Into<String>) -> Self {
173        self.outcome(Outcome::Accepted {
174            message: message.into(),
175        })
176    }
177
178    #[must_use]
179    pub fn captcha(mut self) -> Self {
180        self.captcha = true;
181        self
182    }
183}
184
185/// A column of a [`View::Table`].
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct Column {
188    /// Field name in each row (the CSV header or JSON key).
189    pub key: String,
190    pub label: String,
191}
192
193impl Column {
194    #[must_use]
195    pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
196        Self {
197            key: key.into(),
198            label: label.into(),
199        }
200    }
201}
202
203/// How actions compose into something to render.
204#[derive(Debug, Clone, Serialize, Deserialize)]
205#[serde(tag = "kind", rename_all = "kebab-case")]
206pub enum View {
207    /// A form for one action.
208    Form { action: String },
209    /// A page that reads a `Json` action (the waitlist status).
210    Status { action: String },
211    /// A table over an action that returns rows (an admin export).
212    Table {
213        source: String,
214        columns: Vec<Column>,
215    },
216}
217
218impl View {
219    #[must_use]
220    pub fn form(action: impl Into<String>) -> Self {
221        View::Form {
222            action: action.into(),
223        }
224    }
225
226    #[must_use]
227    pub fn status(action: impl Into<String>) -> Self {
228        View::Status {
229            action: action.into(),
230        }
231    }
232
233    #[must_use]
234    pub fn table(source: impl Into<String>, columns: Vec<Column>) -> Self {
235        View::Table {
236            source: source.into(),
237            columns,
238        }
239    }
240
241    fn action_names(&self) -> Vec<&str> {
242        match self {
243            View::Form { action } | View::Status { action } => vec![action],
244            View::Table { source, .. } => vec![source],
245        }
246    }
247}
248
249/// What a module declares from [`Module::surface`].
250#[derive(Debug, Clone, Default, Serialize, Deserialize)]
251pub struct Surface {
252    pub actions: Vec<Action>,
253    pub views: Vec<View>,
254}
255
256impl Surface {
257    /// A module with no UI. The default of [`Module::surface`].
258    #[must_use]
259    pub fn none() -> Self {
260        Self::default()
261    }
262
263    #[must_use]
264    pub fn new() -> Self {
265        Self::default()
266    }
267
268    #[must_use]
269    pub fn action(mut self, action: Action) -> Self {
270        self.actions.push(action);
271        self
272    }
273
274    #[must_use]
275    pub fn view(mut self, view: View) -> Self {
276        self.views.push(view);
277        self
278    }
279
280    /// `true` when nothing is declared.
281    #[must_use]
282    pub fn is_empty(&self) -> bool {
283        self.actions.is_empty() && self.views.is_empty()
284    }
285
286    /// Validates the declaration for `module` into `errors`, collecting
287    /// every problem (the `Harness::build` convention): duplicate or
288    /// malformed action names, paths not starting with `/`, an `Admin`
289    /// action outside `/admin/` (or a non-admin one inside it), an input
290    /// schema that is not an object, and views naming unknown actions.
291    pub fn validate(&self, module: &str, errors: &mut ConfigError) {
292        let mut seen: HashSet<&str> = HashSet::new();
293        for action in &self.actions {
294            let name = action.name.as_str();
295            if !is_kebab(name) {
296                errors.push(format!(
297                    "module `{module}` surface action `{name}` must be kebab-case"
298                ));
299            }
300            if !seen.insert(name) {
301                errors.push(format!(
302                    "module `{module}` surface declares action `{name}` twice"
303                ));
304            }
305            if !action.path.starts_with('/') {
306                errors.push(format!(
307                    "module `{module}` surface action `{name}` path `{}` must start with '/' \
308                     (relative to /v1/{module})",
309                    action.path
310                ));
311            }
312            let under_admin = action.path == "/admin" || action.path.starts_with("/admin/");
313            match action.audience {
314                Audience::Admin if !under_admin => errors.push(format!(
315                    "module `{module}` surface action `{name}` is admin but its path `{}` \
316                     is not under /admin/",
317                    action.path
318                )),
319                Audience::Public | Audience::Link if under_admin => errors.push(format!(
320                    "module `{module}` surface action `{name}` is under /admin/ but its \
321                     audience is not admin",
322                )),
323                _ => {}
324            }
325            if let Some(schema) = &action.input
326                && !is_object_schema(schema)
327            {
328                errors.push(format!(
329                    "module `{module}` surface action `{name}` input schema must describe an \
330                     object (a struct with named fields), so a renderer can lay out fields"
331                ));
332            }
333        }
334        for view in &self.views {
335            for referenced in view.action_names() {
336                if !seen.contains(referenced) {
337                    errors.push(format!(
338                        "module `{module}` surface view references action `{referenced}` \
339                         which the module does not declare"
340                    ));
341                }
342            }
343        }
344    }
345
346    /// The subset a renderer may show without the admin session: every
347    /// non-admin action and every view that references only those.
348    #[must_use]
349    pub fn public(&self) -> Surface {
350        let actions: Vec<Action> = self
351            .actions
352            .iter()
353            .filter(|action| action.audience != Audience::Admin)
354            .cloned()
355            .collect();
356        let names: BTreeSet<&str> = actions.iter().map(|a| a.name.as_str()).collect();
357        let views = self
358            .views
359            .iter()
360            .filter(|view| view.action_names().iter().all(|n| names.contains(n)))
361            .cloned()
362            .collect();
363        Surface { actions, views }
364    }
365}
366
367/// One module's entry in the composed document.
368#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct ModuleSurface {
370    pub name: String,
371    pub version: String,
372    #[serde(flatten)]
373    pub surface: Surface,
374}
375
376/// The venture identity a renderer needs.
377#[derive(Debug, Clone, Serialize, Deserialize)]
378pub struct VentureSurface {
379    pub name: String,
380    pub public_url: String,
381}
382
383/// The document `GET /__surface` serves: composed at `Harness::build`, one
384/// entry per module in mount order, modules with an empty surface omitted.
385#[derive(Debug, Clone, Serialize, Deserialize)]
386pub struct SurfaceDocument {
387    pub surface_api: u32,
388    pub harness_api: u32,
389    pub venture: VentureSurface,
390    pub modules: Vec<ModuleSurface>,
391    /// The mounted renderer's build-time configuration (`UiMount::describe`).
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    pub ui: Option<serde_json::Value>,
394}
395
396impl SurfaceDocument {
397    /// Composes the full document (admin actions included).
398    #[must_use]
399    pub fn compose(venture: &Venture, modules: &[std::sync::Arc<dyn Module>]) -> Self {
400        let modules = modules
401            .iter()
402            .map(|module| ModuleSurface {
403                name: module.name().to_owned(),
404                version: module.version().to_owned(),
405                surface: module.surface(),
406            })
407            .filter(|entry| !entry.surface.is_empty())
408            .collect();
409        Self {
410            surface_api: SURFACE_API,
411            harness_api: HARNESS_API,
412            venture: VentureSurface {
413                name: venture.name.clone(),
414                public_url: venture.public_url.clone(),
415            },
416            modules,
417            ui: None,
418        }
419    }
420
421    /// The public subset: admin actions and the views over them removed,
422    /// modules left with nothing omitted.
423    #[must_use]
424    pub fn public(&self) -> Self {
425        Self {
426            surface_api: self.surface_api,
427            harness_api: self.harness_api,
428            venture: self.venture.clone(),
429            modules: self
430                .modules
431                .iter()
432                .map(|entry| ModuleSurface {
433                    name: entry.name.clone(),
434                    version: entry.version.clone(),
435                    surface: entry.surface.public(),
436                })
437                .filter(|entry| !entry.surface.is_empty())
438                .collect(),
439            ui: self.ui.clone(),
440        }
441    }
442}
443
444/// Where the current surface comes from (issue #76). With no sidecar
445/// mounted this is the document composed at build; with sidecars, each
446/// call fetches every mounted sidecar's `/__surface` (public part) and
447/// merges it in, so a sidecar redeploy is seen on the next request
448/// (ADR 0009). An unreachable sidecar contributes nothing and is logged.
449#[async_trait::async_trait]
450pub trait SurfaceSource: Send + Sync {
451    /// The full document: admin actions of in-process modules included,
452    /// sidecar modules appended.
453    async fn current(&self) -> std::sync::Arc<SurfaceDocument>;
454    /// The build-time document alone, for checks that must not wait on a
455    /// network (a `UiSpec` validated against what the artifact ships).
456    fn built(&self) -> std::sync::Arc<SurfaceDocument>;
457    /// A prerendered build-time document (admin variant when `admin`),
458    /// if the source keeps one; `None` means render the current document.
459    fn rendered(&self, admin: bool) -> Option<&RenderedSurface> {
460        let _ = admin;
461        None
462    }
463}
464
465/// What a UI renderer gets from the harness (ADR 0010). Built by
466/// `Harness::router` for every router it assembles.
467pub struct UiContext {
468    /// The composed surface, admin actions included; the renderer applies
469    /// its own audience rules per page. Sidecar modules arrive through
470    /// [`SurfaceSource::current`].
471    pub surface: std::sync::Arc<dyn SurfaceSource>,
472    /// The `/v1` API router, for in-process dispatch: a form post becomes
473    /// the JSON request the module accepts and is sent through this
474    /// service, so every module layer runs and nothing leaves the process.
475    /// The request must carry the caller's [`crate::Scope`] in its
476    /// extensions, because the scope layer sits above `/v1`.
477    pub api: axum::Router,
478    pub config: std::sync::Arc<dyn crate::config::Config>,
479    pub venture: std::sync::Arc<Venture>,
480    /// Whether the `Captcha` port is configured, so the renderer knows to
481    /// include the widget on actions that declare `captcha`.
482    pub captcha_configured: bool,
483    /// The `Signer`, for the admin session cookie (issue #74). Absent
484    /// means no admin UI, the way an unset `ADMIN_TOKEN` does.
485    pub signer: Option<std::sync::Arc<dyn crate::ports::Signer>>,
486    /// The `RateLimiter`, for the admin login form.
487    pub rate_limiter: Option<std::sync::Arc<dyn crate::ports::RateLimiter>>,
488}
489
490/// A renderer the venture mounts at `/ui` with `HarnessBuilder::ui`
491/// (ADR 0010). Core defines the seam; `cratefield-ui` is the implementation,
492/// kept out of core so a venture without a UI carries no `maud`.
493pub trait UiMount: Send + Sync + 'static {
494    /// The router nested at `/ui`, built per `Harness::router` call.
495    fn router(&self, ctx: UiContext) -> axum::Router;
496    /// Build-time check of whatever the renderer was configured with (a
497    /// `UiSpec`) against the composed surface; problems go into the same
498    /// list as every other build error.
499    fn validate(&self, surface: &SurfaceDocument, errors: &mut ConfigError) {
500        let _ = (surface, errors);
501    }
502    /// The renderer's build-time configuration as JSON, published in the
503    /// surface document as `ui` so tooling can read the copy and theme a
504    /// venture ships with.
505    fn describe(&self) -> Option<serde_json::Value> {
506        None
507    }
508}
509
510/// A document serialized once, with the strong `ETag` clients revalidate
511/// against. Built at `Harness::build` for both the public and the admin
512/// variant.
513#[derive(Debug, Clone)]
514pub struct RenderedSurface {
515    pub json: String,
516    /// Quoted strong validator: `"<first 32 hex of sha256(json)>"`.
517    pub etag: String,
518}
519
520impl RenderedSurface {
521    #[must_use]
522    pub fn render(document: &SurfaceDocument) -> Self {
523        let json = serde_json::to_string(document).unwrap_or_else(|_| "{}".to_owned());
524        let digest = Sha256::digest(json.as_bytes());
525        let mut hex = String::with_capacity(32);
526        for byte in &digest[..16] {
527            use std::fmt::Write as _;
528            let _ = write!(hex, "{byte:02x}");
529        }
530        Self {
531            json,
532            etag: format!("\"{hex}\""),
533        }
534    }
535}
536
537/// Generates the schema for `T` the way every action does: draft 2020-12,
538/// definitions inlined so a renderer never has to resolve `$ref`.
539#[must_use]
540pub fn schema_for<T: JsonSchema>() -> Schema {
541    let mut settings = schemars::generate::SchemaSettings::draft2020_12();
542    settings.inline_subschemas = true;
543    SchemaGenerator::new(settings).into_root_schema_for::<T>()
544}
545
546/// Sets one `x-cf-*` (or any) keyword on a field of an object schema after
547/// derivation, for hints that only exist at runtime: a `select` whose
548/// options are the configured product list. Unknown fields are ignored so
549/// a rename in the body type cannot panic at build.
550pub fn hint_field(schema: &mut Schema, field: &str, key: &str, value: serde_json::Value) {
551    if let Some(properties) = schema
552        .as_object_mut()
553        .and_then(|root| root.get_mut("properties"))
554        .and_then(serde_json::Value::as_object_mut)
555        && let Some(property) = properties
556            .get_mut(field)
557            .and_then(serde_json::Value::as_object_mut)
558    {
559        property.insert(key.to_owned(), value);
560    }
561}
562
563fn is_object_schema(schema: &Schema) -> bool {
564    let value = schema.as_value();
565    match value.get("type") {
566        Some(serde_json::Value::String(t)) => t == "object",
567        Some(serde_json::Value::Array(types)) => types.iter().any(|t| t == "object"),
568        _ => value.get("properties").is_some(),
569    }
570}
571
572fn is_kebab(name: &str) -> bool {
573    !name.is_empty()
574        && name.split('-').all(|part| {
575            !part.is_empty()
576                && part
577                    .chars()
578                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
579        })
580}
581
582mod method_serde {
583    use http::Method;
584    use serde::{Deserialize, Deserializer, Serialize, Serializer};
585
586    pub fn serialize<S: Serializer>(method: &Method, serializer: S) -> Result<S::Ok, S::Error> {
587        method.as_str().serialize(serializer)
588    }
589
590    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Method, D::Error> {
591        let text = String::deserialize(deserializer)?;
592        Method::from_bytes(text.as_bytes()).map_err(serde::de::Error::custom)
593    }
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599
600    #[derive(JsonSchema)]
601    #[allow(dead_code)]
602    struct JoinBody {
603        #[schemars(extend("x-cf-label" = "Email", "x-cf-widget" = "email"))]
604        email: String,
605        product: String,
606        #[schemars(extend("x-cf-hidden" = true))]
607        #[serde(rename = "captchaToken")]
608        captcha_token: Option<String>,
609    }
610
611    fn join() -> Action {
612        Action::post("join", "/").input::<JoinBody>().captcha()
613    }
614
615    fn errors_of(surface: &Surface) -> Vec<String> {
616        let mut errors = ConfigError::default();
617        surface.validate("waitlist", &mut errors);
618        match errors.into_result() {
619            Ok(()) => Vec::new(),
620            Err(err) => err.to_string().lines().skip(1).map(str::to_owned).collect(),
621        }
622    }
623
624    #[test]
625    fn schema_carries_hints_and_is_inlined() {
626        let schema = schema_for::<JoinBody>();
627        let value = schema.as_value();
628        assert_eq!(value["type"], "object");
629        assert_eq!(value["properties"]["email"]["x-cf-label"], "Email");
630        assert_eq!(value["properties"]["email"]["x-cf-widget"], "email");
631        assert_eq!(value["properties"]["captchaToken"]["x-cf-hidden"], true);
632        assert!(value.get("$defs").is_none(), "subschemas must be inlined");
633    }
634
635    #[test]
636    fn hint_field_sets_a_keyword_and_ignores_unknown_fields() {
637        let mut schema = schema_for::<JoinBody>();
638        hint_field(
639            &mut schema,
640            "product",
641            "enum",
642            serde_json::json!(["a", "b"]),
643        );
644        hint_field(&mut schema, "missing", "x-cf-label", serde_json::json!("x"));
645        let value = schema.as_value();
646        assert_eq!(
647            value["properties"]["product"]["enum"],
648            serde_json::json!(["a", "b"])
649        );
650        assert!(value["properties"].get("missing").is_none());
651    }
652
653    #[test]
654    fn valid_surface_has_no_errors() {
655        let surface = Surface::new()
656            .action(join())
657            .action(Action::get("confirm", "/confirm"))
658            .action(
659                Action::get("export", "/admin/export.csv")
660                    .audience(Audience::Admin)
661                    .outcome(Outcome::Json),
662            )
663            .view(View::form("join"))
664            .view(View::table("export", vec![Column::new("email", "Email")]));
665        assert!(errors_of(&surface).is_empty());
666    }
667
668    #[test]
669    fn every_validation_rule_names_the_module_and_action() {
670        #[derive(JsonSchema)]
671        #[allow(dead_code)]
672        struct NotAnObject(Vec<String>);
673
674        let surface = Surface::new()
675            .action(join())
676            .action(join())
677            .action(Action::post("Bad Name", "no-slash"))
678            .action(Action::post("hidden", "/admin/thing"))
679            .action(Action::delete("wipe", "/wipe"))
680            .action(Action::post("list", "/list").input::<NotAnObject>())
681            .view(View::form("missing"));
682        let errors = errors_of(&surface);
683        let joined = errors.join("\n");
684        for needle in [
685            "declares action `join` twice",
686            "action `Bad Name` must be kebab-case",
687            "path `no-slash` must start with '/'",
688            "action `hidden` is under /admin/ but its audience is not admin",
689            "action `wipe` is admin but its path `/wipe` is not under /admin/",
690            "action `list` input schema must describe an object",
691            "view references action `missing`",
692        ] {
693            assert!(joined.contains(needle), "missing `{needle}` in:\n{joined}");
694        }
695        assert!(joined.lines().all(|l| l.contains("`waitlist`")), "{joined}");
696    }
697
698    #[test]
699    fn public_subset_drops_admin_actions_and_their_views() {
700        let surface = Surface::new()
701            .action(join())
702            .action(
703                Action::get("export", "/admin/export.csv")
704                    .audience(Audience::Admin)
705                    .outcome(Outcome::Json),
706            )
707            .view(View::form("join"))
708            .view(View::table("export", vec![]));
709        let public = surface.public();
710        assert_eq!(public.actions.len(), 1);
711        assert_eq!(public.views.len(), 1);
712        assert!(matches!(public.views[0], View::Form { .. }));
713    }
714
715    #[test]
716    fn rendered_surface_etag_is_stable_and_differs_per_variant() {
717        let doc = SurfaceDocument {
718            surface_api: SURFACE_API,
719            harness_api: HARNESS_API,
720            venture: VentureSurface {
721                name: "v".into(),
722                public_url: "https://v.test".into(),
723            },
724            modules: vec![ModuleSurface {
725                name: "waitlist".into(),
726                version: "0.1.0".into(),
727                surface: Surface::new()
728                    .action(join())
729                    .action(Action::delete("wipe", "/admin/wipe")),
730            }],
731            ui: None,
732        };
733        let full = RenderedSurface::render(&doc);
734        let again = RenderedSurface::render(&doc);
735        let public = RenderedSurface::render(&doc.public());
736        assert_eq!(full.etag, again.etag);
737        assert_ne!(full.etag, public.etag);
738        assert!(full.etag.starts_with('"') && full.etag.ends_with('"'));
739        assert_eq!(full.etag.len(), 34);
740        let parsed: serde_json::Value = serde_json::from_str(&full.json).unwrap();
741        assert_eq!(parsed["modules"][0]["actions"][0]["method"], "POST");
742        assert_eq!(parsed["modules"][0]["actions"][1]["audience"], "admin");
743        assert_eq!(parsed["surface_api"], SURFACE_API);
744    }
745
746    #[test]
747    fn document_omits_modules_without_a_surface() {
748        struct Silent;
749        impl Module for Silent {
750            fn name(&self) -> &'static str {
751                "silent"
752            }
753            fn version(&self) -> &'static str {
754                "0.0.0"
755            }
756            fn requires(&self) -> &'static [crate::ports::Port] {
757                &[]
758            }
759            fn migrations(&self) -> crate::module::Migrations {
760                crate::module::Migrations::EMPTY
761            }
762            fn validate_config(&self, _: &dyn crate::config::Config) -> Result<(), ConfigError> {
763                Ok(())
764            }
765            fn router(&self, _: crate::module::ModuleContext) -> axum::Router {
766                axum::Router::new()
767            }
768        }
769        let venture = Venture::new("v", "v.test");
770        let modules: Vec<std::sync::Arc<dyn Module>> = vec![std::sync::Arc::new(Silent)];
771        let doc = SurfaceDocument::compose(&venture, &modules);
772        assert!(doc.modules.is_empty());
773    }
774}