feat(saas): prove durable authenticated mutation
req: auth/001 req: auth/002 req: auth/004 req: security/004 req: v1_release/003
This commit is contained in:
@@ -60,13 +60,13 @@ encryption, retention, backup, and deployment policy remain host concerns.
|
|||||||
## Slice 6 — production integration reference
|
## Slice 6 — production integration reference
|
||||||
|
|
||||||
- [ ] **User value:** adopters can copy a proven boundary for durable storage, auth, transactions, security controls, observability, and restart recovery without hemx owning vendor policy.
|
- [ ] **User value:** adopters can copy a proven boundary for durable storage, auth, transactions, security controls, observability, and restart recovery without hemx owning vendor policy.
|
||||||
- **State:** Active; Slice 5 is complete.
|
- **State:** In progress — one public server-first project mutation now proves current bearer authentication, origin/CSRF denial, bounded input, atomic durable commit, rollback on persistence failure, and process-restart recovery.
|
||||||
- **Build:** evolve one existing reference app using ordinary integration adapters; add durable app storage, authenticated/authorized allowed and denied mutations, CSRF/origin checks, transaction rollback, bounded input, structured failures, health/readiness, tracing/metrics hooks, and restart/deploy recovery.
|
- **Build:** evolve one existing reference app using ordinary integration adapters; add durable app storage, authenticated/authorized allowed and denied mutations, CSRF/origin checks, transaction rollback, bounded input, structured failures, health/readiness, tracing/metrics hooks, and restart/deploy recovery.
|
||||||
- **Refusals:** no built-in database/auth provider, compliance claim, telemetry vendor, deployment system, or repository framework.
|
- **Refusals:** no built-in database/auth provider, compliance claim, telemetry vendor, deployment system, or repository framework.
|
||||||
- **Requirements:** `security/001-009`, `operations/001-008`, `v1_release/003`, existing `adapter/*`, `integration/*`, and `diagnostics/*` contracts.
|
- **Requirements:** `security/001-009`, `operations/001-008`, `v1_release/003`, existing `adapter/*`, `integration/*`, and `diagnostics/*` contracts.
|
||||||
- **Proof:** end-to-end test survives process restart and mixed deployment, proves allowed/denied/rolled-back mutations and redacted diagnostics, and maps each framework-owned ASVS-relevant control to a failing/passing case.
|
- **Proof:** end-to-end test survives process restart and mixed deployment, proves allowed/denied/rolled-back mutations and redacted diagnostics, and maps each framework-owned ASVS-relevant control to a failing/passing case.
|
||||||
|
|
||||||
Execution cursor: evolve the existing SaaS reference so one authenticated project mutation is authorized, origin/CSRF checked, transactionally durable across process restart, and proven alongside its denied and rolled-back cases through the public server-first entry point.
|
`cargo test -p hemx-saas-example --test production_reference` proves that first production-reference path. Execution cursor: add structured/redacted failure diagnostics plus health/readiness and vendor-neutral tracing/metrics hooks around this mutation, then prove mixed-deploy fail-closed recovery.
|
||||||
|
|
||||||
## Slice 7 — v1 compatibility and closure
|
## Slice 7 — v1 compatibility and closure
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ What it proves:
|
|||||||
- typed form/newtype inputs for project creation
|
- typed form/newtype inputs for project creation
|
||||||
- auth/session context passed through normal Rust state
|
- auth/session context passed through normal Rust state
|
||||||
- CSRF-safe mutation checked before persistence
|
- CSRF-safe mutation checked before persistence
|
||||||
- local in-memory persistence adapter instead of a vendored SQL/auth provider
|
- local atomic-file persistence adapter with rollback and process-restart proof instead of a vendored SQL/auth provider
|
||||||
|
- a bounded `POST /projects` reference boundary requiring the current bearer session, exact origin, and CSRF token
|
||||||
- generated form, slot, keyed row, page-swap, and live-status commands
|
- generated form, slot, keyed row, page-swap, and live-status commands
|
||||||
- page shell with plain CSS and one explicit metrics island script
|
- page shell with plain CSS and one explicit metrics island script
|
||||||
- compile-time surface generation plus interaction tests
|
- compile-time surface generation plus interaction tests
|
||||||
@@ -25,6 +26,7 @@ Those production concerns belong in app adapters and recipes so the tutorial rem
|
|||||||
Run:
|
Run:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo run -p hemx-saas-example
|
HEMX_SAAS_STORE=/tmp/hemx-saas-projects.tsv cargo run -p hemx-saas-example
|
||||||
|
cargo test -p hemx-saas-example --test production_reference
|
||||||
cargo test -p hemx-saas-example
|
cargo test -p hemx-saas-example
|
||||||
```
|
```
|
||||||
|
|||||||
+137
-6
@@ -9,6 +9,9 @@ use hemx_axum::{
|
|||||||
};
|
};
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
@@ -19,6 +22,8 @@ pub struct Session {
|
|||||||
user_id: UserId,
|
user_id: UserId,
|
||||||
email: String,
|
email: String,
|
||||||
csrf: CsrfToken,
|
csrf: CsrfToken,
|
||||||
|
origin: String,
|
||||||
|
bearer: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
@@ -27,6 +32,8 @@ impl Session {
|
|||||||
user_id: UserId(42),
|
user_id: UserId(42),
|
||||||
email: "founder@example.com".to_owned(),
|
email: "founder@example.com".to_owned(),
|
||||||
csrf: CsrfToken("demo-csrf".to_owned()),
|
csrf: CsrfToken("demo-csrf".to_owned()),
|
||||||
|
origin: "http://127.0.0.1:3000".to_owned(),
|
||||||
|
bearer: "Bearer demo-session".to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,12 +98,56 @@ pub struct ProjectRecord {
|
|||||||
owner: 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)]
|
#[derive(Clone, Default)]
|
||||||
pub struct LocalProjectStore {
|
pub struct LocalProjectStore {
|
||||||
projects: Arc<Mutex<Vec<ProjectRecord>>>,
|
projects: Arc<Mutex<Vec<ProjectRecord>>>,
|
||||||
|
path: Option<Arc<PathBuf>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LocalProjectStore {
|
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> {
|
pub fn insert(&self, name: ProjectName, session: &Session) -> Result<ProjectRecord, AppError> {
|
||||||
if name.as_str() == "fail-store" {
|
if name.as_str() == "fail-store" {
|
||||||
return Err(AppError::StoreUnavailable);
|
return Err(AppError::StoreUnavailable);
|
||||||
@@ -109,7 +160,12 @@ impl LocalProjectStore {
|
|||||||
name: name.as_str().to_owned(),
|
name: name.as_str().to_owned(),
|
||||||
owner: session.email.clone(),
|
owner: session.email.clone(),
|
||||||
};
|
};
|
||||||
projects.push(record.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)
|
Ok(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +174,24 @@ impl LocalProjectStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct AppContext {
|
pub struct AppContext {
|
||||||
session: Session,
|
session: Session,
|
||||||
@@ -132,6 +206,33 @@ impl AppContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
pub fn csrf(&self) -> &CsrfToken {
|
||||||
&self.session.csrf
|
&self.session.csrf
|
||||||
}
|
}
|
||||||
@@ -139,12 +240,37 @@ impl AppContext {
|
|||||||
pub fn projects(&self) -> Vec<ProjectRecord> {
|
pub fn projects(&self) -> Vec<ProjectRecord> {
|
||||||
self.store.list()
|
self.store.list()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)]
|
#[derive(Debug)]
|
||||||
pub enum AppError {
|
pub enum AppError {
|
||||||
MissingSession,
|
MissingSession,
|
||||||
CsrfRejected,
|
CsrfRejected,
|
||||||
|
OriginRejected,
|
||||||
StoreUnavailable,
|
StoreUnavailable,
|
||||||
Validation(&'static str),
|
Validation(&'static str),
|
||||||
}
|
}
|
||||||
@@ -154,12 +280,21 @@ impl AppError {
|
|||||||
match self {
|
match self {
|
||||||
Self::MissingSession => "Sign in to continue",
|
Self::MissingSession => "Sign in to continue",
|
||||||
Self::CsrfRejected => "Refresh the page before creating another project",
|
Self::CsrfRejected => "Refresh the page before creating another project",
|
||||||
|
Self::OriginRejected => "Origin verification failed",
|
||||||
Self::StoreUnavailable => "Project storage is temporarily unavailable",
|
Self::StoreUnavailable => "Project storage is temporarily unavailable",
|
||||||
Self::Validation(message) => message,
|
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 {
|
impl IntoHandlerFailure for AppError {
|
||||||
fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure {
|
fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure {
|
||||||
match self {
|
match self {
|
||||||
@@ -289,11 +424,7 @@ mod dashboard_handlers {
|
|||||||
if form.csrf != ctx.session.csrf {
|
if form.csrf != ctx.session.csrf {
|
||||||
return Err(AppError::CsrfRejected);
|
return Err(AppError::CsrfRejected);
|
||||||
}
|
}
|
||||||
if form.name.as_str().is_empty() {
|
let project = ctx.create_project(form.name)?;
|
||||||
return Err(AppError::Validation("Project name required"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let project = ctx.store.insert(form.name, &ctx.session)?;
|
|
||||||
let total = ctx.projects().len();
|
let total = ctx.projects().len();
|
||||||
Ok((
|
Ok((
|
||||||
dashboard::project_row.append(ProjectRow::from(project)),
|
dashboard::project_row.append(ProjectRow::from(project)),
|
||||||
|
|||||||
+46
-10
@@ -1,7 +1,8 @@
|
|||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{DefaultBodyLimit, Form, Query, State};
|
||||||
|
use axum::http::{HeaderMap, StatusCode};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::get;
|
use axum::routing::{get, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use futures_util::stream;
|
use futures_util::stream;
|
||||||
use hemx::IntoEffect;
|
use hemx::IntoEffect;
|
||||||
@@ -9,26 +10,30 @@ use hemx_axum::{runtime_js, runtime_js_path, sse, EffectResponse, InteractionReq
|
|||||||
use hemx_saas_example::{home_page, live_status, registry, settings_page, ui, AppContext};
|
use hemx_saas_example::{home_page, live_status, registry, settings_page, ui, AppContext};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let app = app(AppContext::demo());
|
let address = std::env::var("HEMX_SAAS_ADDR").unwrap_or_else(|_| "127.0.0.1:3003".to_owned());
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:3003")
|
let store = std::env::var_os("HEMX_SAAS_STORE")
|
||||||
.await
|
.map(PathBuf::from)
|
||||||
.expect("bind saas tutorial example");
|
.unwrap_or_else(|| std::env::temp_dir().join("hemx-saas-projects.tsv"));
|
||||||
axum::serve(listener, app)
|
let app = app(AppContext::durable(store, format!("http://{address}"))?);
|
||||||
.await
|
let listener = tokio::net::TcpListener::bind(&address).await?;
|
||||||
.expect("serve saas tutorial example");
|
axum::serve(listener, app).await?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn app(ctx: AppContext) -> Router {
|
fn app(ctx: AppContext) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(home).post(interact))
|
.route("/", get(home).post(interact))
|
||||||
.route("/settings", get(settings))
|
.route("/settings", get(settings))
|
||||||
|
.route("/projects", post(create_project))
|
||||||
.route("/events", get(events))
|
.route("/events", get(events))
|
||||||
.route(runtime_js_path(), get(runtime))
|
.route(runtime_js_path(), get(runtime))
|
||||||
.route("/app.css", get(css))
|
.route("/app.css", get(css))
|
||||||
.route("/metrics.js", get(metrics_js))
|
.route("/metrics.js", get(metrics_js))
|
||||||
|
.layer(DefaultBodyLimit::max(8 * 1024))
|
||||||
.with_state(ctx)
|
.with_state(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +68,37 @@ async fn events(
|
|||||||
)]))
|
)]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// req: auth/001 req: auth/002 req: auth/004
|
||||||
|
// req: security/004 req: v1_release/003
|
||||||
|
async fn create_project(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Form(form): Form<BTreeMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let bearer = headers
|
||||||
|
.get("authorization")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let origin = headers
|
||||||
|
.get("origin")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let name = form.get("name").map(String::as_str).unwrap_or_default();
|
||||||
|
let csrf = form.get("csrf").map(String::as_str).unwrap_or_default();
|
||||||
|
match ctx.create_project_authorized(name, bearer, csrf, origin) {
|
||||||
|
Ok(_) => (StatusCode::SEE_OTHER, [("location", "/")], "").into_response(),
|
||||||
|
Err(
|
||||||
|
error @ (hemx_saas_example::AppError::MissingSession
|
||||||
|
| hemx_saas_example::AppError::CsrfRejected
|
||||||
|
| hemx_saas_example::AppError::OriginRejected),
|
||||||
|
) => (StatusCode::FORBIDDEN, error.to_string()).into_response(),
|
||||||
|
Err(error @ hemx_saas_example::AppError::Validation(_)) => {
|
||||||
|
(StatusCode::BAD_REQUEST, error.to_string()).into_response()
|
||||||
|
}
|
||||||
|
Err(error) => (StatusCode::SERVICE_UNAVAILABLE, error.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn runtime() -> impl IntoResponse {
|
async fn runtime() -> impl IntoResponse {
|
||||||
runtime_js()
|
runtime_js()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
use hemx_test::TestProcess;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::{TcpListener, TcpStream};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
const STARTUP_TIMEOUT: Duration = Duration::from_secs(12);
|
||||||
|
|
||||||
|
fn available_address() -> String {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").expect("reserve test port");
|
||||||
|
let address = listener.local_addr().expect("test address");
|
||||||
|
drop(listener);
|
||||||
|
address.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_path(label: &str) -> PathBuf {
|
||||||
|
let nonce = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("system clock")
|
||||||
|
.as_nanos();
|
||||||
|
std::env::temp_dir().join(format!("hemx-saas-{label}-{}-{nonce}", std::process::id()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(address: &str, store: &Path) -> TestProcess {
|
||||||
|
let mut command = Command::new(env!("CARGO_BIN_EXE_hemx-saas-example"));
|
||||||
|
command
|
||||||
|
.env("HEMX_SAAS_ADDR", address)
|
||||||
|
.env("HEMX_SAAS_STORE", store);
|
||||||
|
TestProcess::start(command, "hemx-saas", address, STARTUP_TIMEOUT).expect("start SaaS app")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(
|
||||||
|
address: &str,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
headers: &[(&str, &str)],
|
||||||
|
body: &str,
|
||||||
|
) -> String {
|
||||||
|
let mut stream = TcpStream::connect(address).expect("connect to SaaS app");
|
||||||
|
write!(
|
||||||
|
stream,
|
||||||
|
"{method} {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\nContent-Length: {}\r\n",
|
||||||
|
body.len()
|
||||||
|
)
|
||||||
|
.expect("write request line");
|
||||||
|
for (name, value) in headers {
|
||||||
|
write!(stream, "{name}: {value}\r\n").expect("write request header");
|
||||||
|
}
|
||||||
|
write!(stream, "\r\n{body}").expect("finish request");
|
||||||
|
let mut response = String::new();
|
||||||
|
stream.read_to_string(&mut response).expect("read response");
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create(address: &str, name: &str, bearer: &str, csrf: &str, origin: &str) -> String {
|
||||||
|
request(
|
||||||
|
address,
|
||||||
|
"POST",
|
||||||
|
"/projects",
|
||||||
|
&[
|
||||||
|
("Authorization", bearer),
|
||||||
|
("Origin", origin),
|
||||||
|
("Content-Type", "application/x-www-form-urlencoded"),
|
||||||
|
],
|
||||||
|
&format!("name={name}&csrf={csrf}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authenticated_project_mutation_is_atomic_and_survives_restart() {
|
||||||
|
// test req: auth/001 req: auth/002 req: auth/004 req: security/004 req: v1_release/003
|
||||||
|
let address = available_address();
|
||||||
|
let origin = format!("http://{address}");
|
||||||
|
let store = test_path("durable");
|
||||||
|
|
||||||
|
{
|
||||||
|
let _app = start(&address, &store);
|
||||||
|
for denied in [
|
||||||
|
create(&address, "DeniedAuth", "Bearer wrong", "demo-csrf", &origin),
|
||||||
|
create(
|
||||||
|
&address,
|
||||||
|
"DeniedCsrf",
|
||||||
|
"Bearer demo-session",
|
||||||
|
"stale",
|
||||||
|
&origin,
|
||||||
|
),
|
||||||
|
create(
|
||||||
|
&address,
|
||||||
|
"DeniedOrigin",
|
||||||
|
"Bearer demo-session",
|
||||||
|
"demo-csrf",
|
||||||
|
"https://attacker.invalid",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert!(denied.starts_with("HTTP/1.1 403"), "{denied}");
|
||||||
|
}
|
||||||
|
let wrong_content_type = request(
|
||||||
|
&address,
|
||||||
|
"POST",
|
||||||
|
"/projects",
|
||||||
|
&[
|
||||||
|
("Authorization", "Bearer demo-session"),
|
||||||
|
("Origin", origin.as_str()),
|
||||||
|
("Content-Type", "text/plain"),
|
||||||
|
],
|
||||||
|
"name=WrongType&csrf=demo-csrf",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
wrong_content_type.starts_with("HTTP/1.1 415"),
|
||||||
|
"{wrong_content_type}"
|
||||||
|
);
|
||||||
|
let oversized = request(
|
||||||
|
&address,
|
||||||
|
"POST",
|
||||||
|
"/projects",
|
||||||
|
&[
|
||||||
|
("Authorization", "Bearer demo-session"),
|
||||||
|
("Origin", origin.as_str()),
|
||||||
|
("Content-Type", "application/x-www-form-urlencoded"),
|
||||||
|
],
|
||||||
|
&format!("name={}&csrf=demo-csrf", "x".repeat(9 * 1024)),
|
||||||
|
);
|
||||||
|
assert!(oversized.starts_with("HTTP/1.1 413"), "{oversized}");
|
||||||
|
let before = request(&address, "GET", "/", &[], "");
|
||||||
|
assert!(!before.contains("DeniedAuth"));
|
||||||
|
assert!(!before.contains("DeniedCsrf"));
|
||||||
|
assert!(!before.contains("DeniedOrigin"));
|
||||||
|
assert!(!before.contains("WrongType"));
|
||||||
|
|
||||||
|
let allowed = create(
|
||||||
|
&address,
|
||||||
|
"Durable%20Project",
|
||||||
|
"Bearer demo-session",
|
||||||
|
"demo-csrf",
|
||||||
|
&origin,
|
||||||
|
);
|
||||||
|
assert!(allowed.starts_with("HTTP/1.1 303"), "{allowed}");
|
||||||
|
assert!(request(&address, "GET", "/", &[], "").contains("Durable Project"));
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let _restarted = start(&address, &store);
|
||||||
|
let restored = request(&address, "GET", "/", &[], "");
|
||||||
|
assert!(restored.contains("Durable Project"), "{restored}");
|
||||||
|
assert!(restored.contains("1 project"), "{restored}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = fs::remove_file(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_durable_commit_rolls_back_visible_state() {
|
||||||
|
// test req: failure/004 req: operations/002 req: v1_release/003
|
||||||
|
let address = available_address();
|
||||||
|
let origin = format!("http://{address}");
|
||||||
|
let store = test_path("rollback");
|
||||||
|
let _app = start(&address, &store);
|
||||||
|
fs::create_dir(&store).expect("block atomic rename destination");
|
||||||
|
|
||||||
|
let rejected = create(
|
||||||
|
&address,
|
||||||
|
"Must%20Rollback",
|
||||||
|
"Bearer demo-session",
|
||||||
|
"demo-csrf",
|
||||||
|
&origin,
|
||||||
|
);
|
||||||
|
assert!(rejected.starts_with("HTTP/1.1 503"), "{rejected}");
|
||||||
|
assert!(!request(&address, "GET", "/", &[], "").contains("Must Rollback"));
|
||||||
|
assert!(!store.with_extension("tmp").exists());
|
||||||
|
|
||||||
|
let _ = fs::remove_dir(store);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user