6f58453aae
req: security/006 req: operations/006 req: operations/008
765 lines
22 KiB
Rust
765 lines
22 KiB
Rust
#[hemx::surface]
|
|
pub mod ui {}
|
|
|
|
use hemplate::Hemplate;
|
|
use hemx::{push, Html, IntoEffect};
|
|
use hemx_axum::{
|
|
interactions, runtime_js_path, Form, HandlerErrorContext, HandlerFailure, IntoHandlerFailure,
|
|
Registry, State,
|
|
};
|
|
use std::convert::Infallible;
|
|
use std::fmt::Display;
|
|
use std::fs;
|
|
use std::io::{self, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::str::FromStr;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
|
|
use ui::dashboard;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct SessionId(u64);
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct Session {
|
|
session_id: SessionId,
|
|
user_id: UserId,
|
|
email: String,
|
|
csrf: CsrfToken,
|
|
origin: String,
|
|
bearer: String,
|
|
}
|
|
|
|
impl Session {
|
|
pub fn demo() -> Self {
|
|
Self {
|
|
session_id: SessionId(1),
|
|
user_id: UserId(42),
|
|
email: "founder@example.com".to_owned(),
|
|
csrf: CsrfToken("demo-csrf".to_owned()),
|
|
origin: "http://127.0.0.1:3000".to_owned(),
|
|
bearer: "Bearer demo-session".to_owned(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct UserId(u64);
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct CsrfToken(String);
|
|
|
|
impl FromStr for CsrfToken {
|
|
type Err = Infallible;
|
|
|
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
|
Ok(Self(value.to_owned()))
|
|
}
|
|
}
|
|
|
|
impl Display for CsrfToken {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(&self.0)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct ProjectName(String);
|
|
|
|
impl ProjectName {
|
|
fn as_str(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl FromStr for ProjectName {
|
|
type Err = Infallible;
|
|
|
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
|
Ok(Self(value.trim().to_owned()))
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
#[hemx::form("new_project")]
|
|
pub struct NewProject {
|
|
csrf: CsrfToken,
|
|
name: ProjectName,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct ProjectId(u64);
|
|
|
|
impl Display for ProjectId {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}", self.0)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct ProjectRecord {
|
|
id: ProjectId,
|
|
name: String,
|
|
owner: String,
|
|
}
|
|
|
|
impl ProjectRecord {
|
|
fn encode(&self) -> String {
|
|
format!("{}\t{}\t{}\n", self.id.0, self.owner, self.name)
|
|
}
|
|
|
|
fn decode(line: &str) -> io::Result<Self> {
|
|
let mut fields = line.splitn(3, '\t');
|
|
let id = fields
|
|
.next()
|
|
.and_then(|value| value.parse().ok())
|
|
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid project id"))?;
|
|
let owner = fields
|
|
.next()
|
|
.filter(|value| !value.is_empty())
|
|
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid project owner"))?;
|
|
let name = fields
|
|
.next()
|
|
.filter(|value| !value.is_empty() && !value.contains(['\n', '\r', '\t']))
|
|
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid project name"))?;
|
|
Ok(Self {
|
|
id: ProjectId(id),
|
|
name: name.to_owned(),
|
|
owner: owner.to_owned(),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
pub struct LocalProjectStore {
|
|
projects: Arc<Mutex<Vec<ProjectRecord>>>,
|
|
path: Option<Arc<PathBuf>>,
|
|
}
|
|
|
|
impl LocalProjectStore {
|
|
pub fn durable(path: impl Into<PathBuf>) -> io::Result<Self> {
|
|
let path = path.into();
|
|
let projects = match fs::read_to_string(&path) {
|
|
Ok(contents) => contents
|
|
.lines()
|
|
.map(ProjectRecord::decode)
|
|
.collect::<io::Result<Vec<_>>>()?,
|
|
Err(error) if error.kind() == io::ErrorKind::NotFound => Vec::new(),
|
|
Err(error) => return Err(error),
|
|
};
|
|
Ok(Self {
|
|
projects: Arc::new(Mutex::new(projects)),
|
|
path: Some(Arc::new(path)),
|
|
})
|
|
}
|
|
|
|
pub fn insert(&self, name: ProjectName, session: &Session) -> Result<ProjectRecord, AppError> {
|
|
if name.as_str() == "fail-store" {
|
|
return Err(AppError::StoreUnavailable);
|
|
}
|
|
|
|
let mut projects = self.projects.lock().unwrap();
|
|
let id = ProjectId(projects.last().map_or(1, |project| project.id.0 + 1));
|
|
let record = ProjectRecord {
|
|
id,
|
|
name: name.as_str().to_owned(),
|
|
owner: session.email.clone(),
|
|
};
|
|
let mut next = projects.clone();
|
|
next.push(record.clone());
|
|
if let Some(path) = self.path.as_deref() {
|
|
persist_projects(path, &next).map_err(|_| AppError::StoreUnavailable)?;
|
|
}
|
|
*projects = next;
|
|
Ok(record)
|
|
}
|
|
|
|
pub fn list(&self) -> Vec<ProjectRecord> {
|
|
self.projects.lock().unwrap().clone()
|
|
}
|
|
|
|
fn ready(&self) -> bool {
|
|
let Some(path) = self.path.as_deref() else {
|
|
return true;
|
|
};
|
|
if path.exists() && !path.is_file() {
|
|
return false;
|
|
}
|
|
path.parent().unwrap_or_else(|| Path::new(".")).is_dir()
|
|
}
|
|
}
|
|
|
|
fn persist_projects(path: &Path, projects: &[ProjectRecord]) -> io::Result<()> {
|
|
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
|
fs::create_dir_all(parent)?;
|
|
let temporary = path.with_extension("tmp");
|
|
let mut file = fs::File::create(&temporary)?;
|
|
for project in projects {
|
|
file.write_all(project.encode().as_bytes())?;
|
|
}
|
|
file.sync_all()?;
|
|
if let Err(error) = fs::rename(&temporary, path) {
|
|
let _ = fs::remove_file(temporary);
|
|
return Err(error);
|
|
}
|
|
#[cfg(unix)]
|
|
fs::File::open(parent)?.sync_all()?;
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct RequestCorrelationId(String);
|
|
|
|
impl Display for RequestCorrelationId {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(&self.0)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct MutationDiagnostic {
|
|
pub request_id: RequestCorrelationId,
|
|
pub session_id: SessionId,
|
|
pub user_id: UserId,
|
|
pub outcome: &'static str,
|
|
pub duration_micros: u64,
|
|
}
|
|
|
|
pub trait DiagnosticSink: Send + Sync {
|
|
fn record(&self, diagnostic: MutationDiagnostic);
|
|
}
|
|
|
|
struct StderrDiagnosticSink;
|
|
|
|
impl DiagnosticSink for StderrDiagnosticSink {
|
|
fn record(&self, diagnostic: MutationDiagnostic) {
|
|
eprintln!(
|
|
"event=saas.project_mutation request_id={} session_id={} user_id={} outcome={} duration_micros={}",
|
|
diagnostic.request_id,
|
|
diagnostic.session_id.0,
|
|
diagnostic.user_id.0,
|
|
diagnostic.outcome,
|
|
diagnostic.duration_micros
|
|
);
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct MutationMetrics {
|
|
attempts: AtomicU64,
|
|
succeeded: AtomicU64,
|
|
denied: AtomicU64,
|
|
invalid: AtomicU64,
|
|
mismatch: AtomicU64,
|
|
failed: AtomicU64,
|
|
duration_micros: AtomicU64,
|
|
next_request_id: AtomicU64,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct AppContext {
|
|
session: Session,
|
|
store: LocalProjectStore,
|
|
metrics: Arc<MutationMetrics>,
|
|
diagnostics: Arc<dyn DiagnosticSink>,
|
|
}
|
|
|
|
impl AppContext {
|
|
pub fn demo() -> Self {
|
|
Self {
|
|
session: Session::demo(),
|
|
store: LocalProjectStore::default(),
|
|
metrics: Arc::default(),
|
|
diagnostics: Arc::new(StderrDiagnosticSink),
|
|
}
|
|
}
|
|
|
|
pub fn durable(path: impl Into<PathBuf>, origin: impl Into<String>) -> io::Result<Self> {
|
|
let mut session = Session::demo();
|
|
session.origin = origin.into();
|
|
Ok(Self {
|
|
session,
|
|
store: LocalProjectStore::durable(path)?,
|
|
metrics: Arc::default(),
|
|
diagnostics: Arc::new(StderrDiagnosticSink),
|
|
})
|
|
}
|
|
|
|
pub fn authorize_mutation(
|
|
&self,
|
|
bearer: &str,
|
|
csrf: &CsrfToken,
|
|
origin: &str,
|
|
) -> Result<(), AppError> {
|
|
if self.session.email.is_empty() || bearer != self.session.bearer {
|
|
return Err(AppError::MissingSession);
|
|
}
|
|
if csrf != &self.session.csrf {
|
|
return Err(AppError::CsrfRejected);
|
|
}
|
|
if origin != self.session.origin {
|
|
return Err(AppError::OriginRejected);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn csrf(&self) -> &CsrfToken {
|
|
&self.session.csrf
|
|
}
|
|
|
|
pub fn projects(&self) -> Vec<ProjectRecord> {
|
|
self.store.list()
|
|
}
|
|
|
|
pub fn ready(&self) -> bool {
|
|
self.store.ready()
|
|
}
|
|
|
|
pub fn with_diagnostic_sink(mut self, diagnostics: Arc<dyn DiagnosticSink>) -> Self {
|
|
self.diagnostics = diagnostics;
|
|
self
|
|
}
|
|
|
|
pub fn next_request_id(&self) -> RequestCorrelationId {
|
|
let sequence = self
|
|
.metrics
|
|
.next_request_id
|
|
.fetch_add(1, Ordering::Relaxed)
|
|
.saturating_add(1);
|
|
RequestCorrelationId(format!("req-{}-{sequence}", std::process::id()))
|
|
}
|
|
|
|
pub fn record_mutation(
|
|
&self,
|
|
request_id: RequestCorrelationId,
|
|
outcome: &'static str,
|
|
duration: Duration,
|
|
) {
|
|
self.metrics.attempts.fetch_add(1, Ordering::Relaxed);
|
|
match outcome {
|
|
"succeeded" => &self.metrics.succeeded,
|
|
"denied" => &self.metrics.denied,
|
|
"invalid" => &self.metrics.invalid,
|
|
"mismatch" => &self.metrics.mismatch,
|
|
_ => &self.metrics.failed,
|
|
}
|
|
.fetch_add(1, Ordering::Relaxed);
|
|
let duration_micros = duration.as_micros().min(u128::from(u64::MAX)) as u64;
|
|
self.metrics
|
|
.duration_micros
|
|
.fetch_add(duration_micros, Ordering::Relaxed);
|
|
self.diagnostics.record(MutationDiagnostic {
|
|
request_id,
|
|
session_id: self.session.session_id,
|
|
user_id: self.session.user_id,
|
|
outcome,
|
|
duration_micros,
|
|
});
|
|
}
|
|
|
|
pub fn metrics_json(&self) -> String {
|
|
format!(
|
|
"{{\"project_mutation\":{{\"attempts\":{},\"succeeded\":{},\"denied\":{},\"invalid\":{},\"mismatch\":{},\"failed\":{},\"duration_micros\":{}}}}}",
|
|
self.metrics.attempts.load(Ordering::Relaxed),
|
|
self.metrics.succeeded.load(Ordering::Relaxed),
|
|
self.metrics.denied.load(Ordering::Relaxed),
|
|
self.metrics.invalid.load(Ordering::Relaxed),
|
|
self.metrics.mismatch.load(Ordering::Relaxed),
|
|
self.metrics.failed.load(Ordering::Relaxed),
|
|
self.metrics.duration_micros.load(Ordering::Relaxed),
|
|
)
|
|
}
|
|
|
|
pub fn create_project_authorized(
|
|
&self,
|
|
name: &str,
|
|
bearer: &str,
|
|
csrf: &str,
|
|
origin: &str,
|
|
) -> Result<ProjectRecord, AppError> {
|
|
let csrf = CsrfToken::from_str(csrf).expect("CSRF tokens are infallible strings");
|
|
self.authorize_mutation(bearer, &csrf, origin)?;
|
|
self.create_project(
|
|
ProjectName::from_str(name).expect("project names are infallible strings"),
|
|
)
|
|
}
|
|
|
|
fn create_project(&self, name: ProjectName) -> Result<ProjectRecord, AppError> {
|
|
if name.as_str().is_empty() {
|
|
return Err(AppError::Validation("Project name required"));
|
|
}
|
|
if name.as_str().len() > 100 || name.as_str().contains(['\n', '\r', '\t']) {
|
|
return Err(AppError::Validation("Project name is invalid"));
|
|
}
|
|
self.store.insert(name, &self.session)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum AppError {
|
|
MissingSession,
|
|
CsrfRejected,
|
|
OriginRejected,
|
|
StoreUnavailable,
|
|
Validation(&'static str),
|
|
}
|
|
|
|
impl AppError {
|
|
fn message(&self) -> &'static str {
|
|
match self {
|
|
Self::MissingSession => "Sign in to continue",
|
|
Self::CsrfRejected => "Refresh the page before creating another project",
|
|
Self::OriginRejected => "Origin verification failed",
|
|
Self::StoreUnavailable => "Project storage is temporarily unavailable",
|
|
Self::Validation(message) => message,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Display for AppError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(self.message())
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for AppError {}
|
|
|
|
impl IntoHandlerFailure for AppError {
|
|
fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure {
|
|
match self {
|
|
Self::Validation(message) => HandlerFailure::effects(
|
|
(
|
|
dashboard::new_project.error("name", message),
|
|
dashboard::new_project.focus("name"),
|
|
),
|
|
context,
|
|
),
|
|
other => HandlerFailure::effects(dashboard::flash.set(other.message()), context),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
pub struct Dashboard {
|
|
csrf: CsrfToken,
|
|
flash: String,
|
|
summary: String,
|
|
rows: Vec<ProjectRow>,
|
|
project_count: usize,
|
|
show_projects: bool,
|
|
settings: SettingsPage,
|
|
}
|
|
|
|
impl Dashboard {
|
|
pub fn from_context(ctx: &AppContext) -> Self {
|
|
let projects = ctx.projects();
|
|
Self {
|
|
csrf: ctx.csrf().clone(),
|
|
flash: "Signed in with a demo session".to_owned(),
|
|
summary: project_summary(projects.len()),
|
|
project_count: projects.len(),
|
|
show_projects: true,
|
|
settings: SettingsPage::production_boundaries(),
|
|
rows: projects.into_iter().map(ProjectRow::from).collect(),
|
|
}
|
|
}
|
|
|
|
pub fn settings(ctx: &AppContext) -> Self {
|
|
let mut dashboard = Self::from_context(ctx);
|
|
dashboard.show_projects = false;
|
|
dashboard.flash.clear();
|
|
dashboard
|
|
}
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
pub struct ProjectRow {
|
|
id: ProjectId,
|
|
name: String,
|
|
owner: String,
|
|
}
|
|
|
|
impl From<ProjectRecord> for ProjectRow {
|
|
fn from(record: ProjectRecord) -> Self {
|
|
Self {
|
|
id: record.id,
|
|
name: record.name,
|
|
owner: record.owner,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl hemx::KeyedPartial for ProjectRow {
|
|
fn hemx_key(&self) -> String {
|
|
self.id.to_string()
|
|
}
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
pub struct SettingsPage {
|
|
message: &'static str,
|
|
}
|
|
|
|
impl SettingsPage {
|
|
fn production_boundaries() -> Self {
|
|
Self {
|
|
message: "Auth, CSRF, persistence, metrics, and deploy stay explicit app integrations.",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
pub struct AppShell {
|
|
title: &'static str,
|
|
runtime_src: &'static str,
|
|
body: Html,
|
|
}
|
|
|
|
pub fn home_page(ctx: &AppContext) -> Html {
|
|
ui::page(&AppShell {
|
|
title: "hemx SaaS tutorial",
|
|
runtime_src: runtime_js_path(),
|
|
body: ui::page(&Dashboard::from_context(ctx)),
|
|
})
|
|
}
|
|
|
|
pub fn settings_page(ctx: &AppContext) -> Html {
|
|
ui::page(&AppShell {
|
|
title: "hemx SaaS tutorial settings",
|
|
runtime_src: runtime_js_path(),
|
|
body: ui::page(&Dashboard::settings(ctx)),
|
|
})
|
|
}
|
|
|
|
#[hemx::app(dashboard_handlers)]
|
|
pub fn registry(ctx: AppContext) -> Registry {
|
|
interactions(ui::BUILD_FINGERPRINT)
|
|
}
|
|
|
|
#[hemx::component("dashboard")]
|
|
mod dashboard_handlers {
|
|
use super::*;
|
|
|
|
#[hemx::handler]
|
|
pub async fn create_project(
|
|
State(ctx): State<AppContext>,
|
|
Form(form): Form<NewProject>,
|
|
) -> Result<impl IntoEffect, AppError> {
|
|
if ctx.session.email.is_empty() {
|
|
return Err(AppError::MissingSession);
|
|
}
|
|
if form.csrf != ctx.session.csrf {
|
|
return Err(AppError::CsrfRejected);
|
|
}
|
|
let project = ctx.create_project(form.name)?;
|
|
let total = ctx.projects().len();
|
|
Ok((
|
|
dashboard::project_row.append(ProjectRow::from(project)),
|
|
dashboard::summary.set(project_summary(total)),
|
|
dashboard::new_project.clear(),
|
|
dashboard::flash.set("Project created"),
|
|
dashboard::live_status.set(format!("{total} projects persisted locally")),
|
|
))
|
|
}
|
|
|
|
#[hemx::handler]
|
|
pub async fn open_settings(State(_ctx): State<AppContext>) -> impl IntoEffect {
|
|
(
|
|
dashboard::page_panel.put(&SettingsPage::production_boundaries()),
|
|
dashboard::nav.set("Settings"),
|
|
push("/settings"),
|
|
)
|
|
}
|
|
}
|
|
|
|
pub fn live_status(projects: usize) -> impl IntoEffect {
|
|
dashboard::live_status.set(format!("heartbeat: {projects} projects"))
|
|
}
|
|
|
|
fn project_summary(total: usize) -> String {
|
|
match total {
|
|
0 => "No projects yet".to_owned(),
|
|
1 => "1 project".to_owned(),
|
|
total => format!("{total} projects"),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use hemx_axum::{InteractionForm, InteractionRequest};
|
|
use hemx_test::{any_root_selector, inspect, inspect_batch, target_selector};
|
|
use scraper::{Html as ParsedHtml, Selector};
|
|
|
|
fn form<I>(handle: hemx::Handle<I>, fields: &[(&str, &str)]) -> InteractionForm {
|
|
InteractionForm::for_handle(
|
|
handle,
|
|
fields
|
|
.iter()
|
|
.map(|(name, value)| ((*name).to_owned(), (*value).to_owned())),
|
|
)
|
|
}
|
|
|
|
fn selector(value: &str) -> Selector {
|
|
Selector::parse(value).expect("test selector parses")
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct RecordingDiagnostics(Mutex<Vec<MutationDiagnostic>>);
|
|
|
|
impl DiagnosticSink for RecordingDiagnostics {
|
|
fn record(&self, diagnostic: MutationDiagnostic) {
|
|
self.0.lock().unwrap().push(diagnostic);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mutation_diagnostics_are_structured_and_cannot_carry_request_secrets() {
|
|
// req: operations/003 req: operations/005
|
|
let diagnostics = Arc::new(RecordingDiagnostics::default());
|
|
let ctx = AppContext::demo().with_diagnostic_sink(diagnostics.clone());
|
|
let request_id = ctx.next_request_id();
|
|
ctx.record_mutation(request_id.clone(), "denied", Duration::from_micros(7));
|
|
|
|
let recorded = diagnostics.0.lock().unwrap();
|
|
assert_eq!(recorded.len(), 1);
|
|
assert_eq!(recorded[0].request_id, request_id);
|
|
assert_eq!(recorded[0].session_id, SessionId(1));
|
|
assert_eq!(recorded[0].user_id, UserId(42));
|
|
assert_eq!(recorded[0].outcome, "denied");
|
|
assert_eq!(recorded[0].duration_micros, 7);
|
|
}
|
|
|
|
#[test]
|
|
fn home_page_documents_the_production_app_boundaries() {
|
|
// req: examples/001 req: auth/001 req: auth/004 req: interop/003
|
|
let ctx = AppContext::demo();
|
|
let html = home_page(&ctx);
|
|
let document = ParsedHtml::parse_document(html.as_str());
|
|
|
|
assert_eq!(document.select(&selector(any_root_selector())).count(), 1);
|
|
assert_eq!(
|
|
document
|
|
.select(&selector(&format!(
|
|
"form{}",
|
|
target_selector(dashboard::new_project)
|
|
)))
|
|
.count(),
|
|
1
|
|
);
|
|
assert_eq!(document.select(&selector("input[name='csrf']")).count(), 1);
|
|
assert_eq!(
|
|
document
|
|
.select(&selector("[data-hemx-sse='/events']"))
|
|
.count(),
|
|
1
|
|
);
|
|
assert_eq!(
|
|
document
|
|
.select(&selector("[data-hemx-island='metrics']"))
|
|
.count(),
|
|
1
|
|
);
|
|
assert!(html.as_str().contains("/app.css"));
|
|
assert!(html.as_str().contains("/metrics.js"));
|
|
}
|
|
|
|
#[test]
|
|
fn settings_page_renders_the_full_page_fallback() {
|
|
// req: examples/001 req: page_swap/002
|
|
let ctx = AppContext::demo();
|
|
let html = settings_page(&ctx);
|
|
let document = ParsedHtml::parse_document(html.as_str());
|
|
|
|
assert_eq!(document.select(&selector(any_root_selector())).count(), 1);
|
|
assert_eq!(
|
|
document
|
|
.select(&selector(&format!(
|
|
"{} .settings-page",
|
|
target_selector(dashboard::page_panel)
|
|
)))
|
|
.count(),
|
|
1
|
|
);
|
|
assert!(html.as_str().contains("explicit app integrations"));
|
|
assert!(!html
|
|
.as_str()
|
|
.contains("form data-hemx-handle=\"create_project\""));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_project_is_auth_csrf_checked_and_persisted_locally() {
|
|
// req: examples/001 req: auth/002 req: auth/004 req: form/001 req: failure/004
|
|
let ctx = AppContext::demo();
|
|
|
|
let rejected = inspect_batch(
|
|
InteractionRequest::from(form(
|
|
dashboard::create_project,
|
|
&[("csrf", "stale"), ("name", "Launch checklist")],
|
|
))
|
|
.dispatch_async(registry(ctx.clone()))
|
|
.await
|
|
.unwrap()
|
|
.batch,
|
|
);
|
|
assert!(ctx.projects().is_empty());
|
|
assert!(rejected.updates_text(dashboard::flash));
|
|
assert!(rejected.payload_contains("Refresh the page"));
|
|
|
|
let validation = inspect_batch(
|
|
InteractionRequest::from(form(
|
|
dashboard::create_project,
|
|
&[("csrf", "demo-csrf"), ("name", " ")],
|
|
))
|
|
.dispatch_async(registry(ctx.clone()))
|
|
.await
|
|
.unwrap()
|
|
.batch,
|
|
);
|
|
assert!(ctx.projects().is_empty());
|
|
assert!(validation.payload_contains("Project name required"));
|
|
|
|
let created = inspect_batch(
|
|
InteractionRequest::from(form(
|
|
dashboard::create_project,
|
|
&[("csrf", "demo-csrf"), ("name", "Launch checklist")],
|
|
))
|
|
.dispatch_async(registry(ctx.clone()))
|
|
.await
|
|
.unwrap()
|
|
.batch,
|
|
);
|
|
assert_eq!(ctx.projects()[0].name, "Launch checklist");
|
|
assert!(created.inserts_html_containing(dashboard::project_row, "1", "Launch checklist"));
|
|
assert!(created.updates_text(dashboard::summary));
|
|
assert!(created.resets_form(dashboard::new_project));
|
|
assert!(created.updates_text(dashboard::live_status));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn page_swap_and_push_shape_use_generated_targets() {
|
|
// req: page_swap/002 req: push/003 req: examples/001
|
|
let ctx = AppContext::demo();
|
|
let settings = inspect_batch(
|
|
InteractionRequest::from(form(dashboard::open_settings, &[]))
|
|
.dispatch_async(registry(ctx.clone()))
|
|
.await
|
|
.unwrap()
|
|
.batch,
|
|
);
|
|
assert!(
|
|
settings.updates_html_containing(dashboard::page_panel, "explicit app integrations")
|
|
);
|
|
assert!(settings.updates_text(dashboard::nav));
|
|
assert!(settings.pushes_to("/settings"));
|
|
|
|
let heartbeat = inspect(live_status(ctx.projects().len()));
|
|
assert!(heartbeat.updates_text(dashboard::live_status));
|
|
assert!(heartbeat.payload_contains("heartbeat"));
|
|
}
|
|
}
|