iOS is the one client none of the mail autoconfiguration reaches: Apple Mail reads neither Mozilla autoconfig nor Autodiscover for IMAP, so adding an account means the device probing for ports - and a probe that meets a filtered port waits out a TCP timeout and reports the account as unverifiable, which the person reads as a wrong password. A profile states the hosts and the ports instead, and carries mail, calendars and contacts in one install. It invents none of them: site.yaml names a PACC document - the standard defined to be fetched from the mail domain itself - and every host, port and collection path comes from there, so the server is described in one place and the profile follows it. The document holds no address, username or password. iOS asks for those at install, which means one profile serves everyone with an account and it can be fetched by anyone: it says no more than PACC already says in public. Identifiers are derived from the site and the payload rather than drawn at random, because iOS replaces a profile whose ids it knows and stacks a duplicate when they differ. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
458 lines
16 KiB
Rust
458 lines
16 KiB
Rust
//! The one test that meets a real JetStream: a submission becomes an
|
|
//! aggregate, a desk move is accepted, an undeclared move is refused,
|
|
//! and the record reads back from its own event log. Everything else
|
|
//! in the suite replays in memory; this is what proves the store, the
|
|
//! event log and the projection agree with each other.
|
|
//!
|
|
//! Runs only when `PORTAL_TEST_NATS_URL` points at a JetStream (CI
|
|
//! starts a throwaway one, see `.gitea/workflows/test.yml`); without
|
|
//! it the test passes as skipped, so `cargo test` stays green offline.
|
|
#![cfg(feature = "ssr")]
|
|
|
|
use portal::aggregates::{self, parse_aggregates_yaml, TransitionError};
|
|
use portal::answers::{store_answer, Answer};
|
|
use portal::events::store::{ensure_stream, load_events};
|
|
use serde_json::json;
|
|
|
|
#[tokio::test]
|
|
async fn a_submission_is_created_moved_refused_and_read_back_through_the_real_engine() {
|
|
let Ok(url) = std::env::var("PORTAL_TEST_NATS_URL") else {
|
|
eprintln!("skipped: PORTAL_TEST_NATS_URL is not set");
|
|
return;
|
|
};
|
|
let js = async_nats::jetstream::new(async_nats::connect(&url).await.expect("connect"));
|
|
ensure_stream(&js).await.expect("event stream");
|
|
|
|
// A bucket of its own per run, so reruns and parallel runs never
|
|
// read each other's records.
|
|
let bucket = format!("it_{}", chrono::Utc::now().timestamp_millis());
|
|
let schemas = parse_aggregates_yaml(&format!(
|
|
"aggregates:\n - bucket: {bucket}\n initial: open\n states:\n open: {{ event: received }}\n quoted: {{ event: quoted }}\n won: {{ event: won }}\n lost: {{ event: lost }}\n transitions:\n open: [quoted, lost]\n quoted: [won, lost]\n"
|
|
))
|
|
.expect("schema");
|
|
let schema = &schemas[&bucket];
|
|
let now = chrono::Utc::now().timestamp_millis();
|
|
|
|
// The submission: a KV record in the initial state, plus the first event.
|
|
store_answer(
|
|
&js,
|
|
&schemas,
|
|
&bucket,
|
|
"rec-1".into(),
|
|
"/",
|
|
"Hire us",
|
|
&json!({"email": "a@b.example"}),
|
|
now,
|
|
0,
|
|
)
|
|
.await
|
|
.expect("store");
|
|
let store = js.get_key_value(&bucket).await.expect("bucket exists");
|
|
let stored: Answer =
|
|
serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap();
|
|
assert_eq!(stored.state, "open");
|
|
assert_eq!(stored.responses["email"], "a@b.example");
|
|
|
|
// A desk move the machine allows.
|
|
let moved = aggregates::transition(
|
|
&js,
|
|
schema,
|
|
"rec-1",
|
|
"quoted",
|
|
json!({"by": "test"}),
|
|
now + 1,
|
|
)
|
|
.await
|
|
.expect("open -> quoted is declared");
|
|
assert_eq!(moved.state, "quoted");
|
|
|
|
// Moves the machine does not allow are refused, whoever asks.
|
|
let back = aggregates::transition(&js, schema, "rec-1", "open", json!({}), now + 2).await;
|
|
assert!(
|
|
matches!(back, Err(TransitionError::DisallowedTransition { .. })),
|
|
"{back:?}"
|
|
);
|
|
let skip = aggregates::transition(&js, schema, "rec-1", "won", json!({}), now + 2).await;
|
|
assert!(skip.is_ok(), "quoted -> won is declared: {skip:?}");
|
|
let nobody = aggregates::transition(&js, schema, "rec-9", "quoted", json!({}), now + 3).await;
|
|
assert!(
|
|
matches!(nobody, Err(TransitionError::UnknownAggregate)),
|
|
"{nobody:?}"
|
|
);
|
|
|
|
// The record reads back from its own event log, in the state the
|
|
// accepted moves left it, with a sequence that moved forward.
|
|
let events = load_events(&js, &bucket, "rec-1").await.expect("events");
|
|
assert_eq!(events.len(), 3, "received, quoted, won");
|
|
let replayed = aggregates::replay(schema, "rec-1", &events).expect("replays");
|
|
assert_eq!(replayed.state, "won");
|
|
assert!(replayed.sequence > moved.sequence);
|
|
|
|
// A reply to the mail this record was sent becomes a note on it,
|
|
// through the same store the desks read - and moves nothing: the
|
|
// record is still where the desk left it.
|
|
let mut record: Answer =
|
|
serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap();
|
|
let state_before_the_note = record.state.clone();
|
|
let reply = portal::mail::Incoming {
|
|
v: portal::mail::CONTRACT,
|
|
id: "mail-1".into(),
|
|
bucket: bucket.clone(),
|
|
record: "rec-1".into(),
|
|
from: "A <A@B.example>".into(),
|
|
subject: "Re: your enquiry".into(),
|
|
text: "Thursday suits us.".into(),
|
|
received_ms: now + 10,
|
|
};
|
|
let note = portal::mail::note_for(&reply, &record).expect("the address matches the record");
|
|
record.notes.push(note);
|
|
store
|
|
.put("rec-1", serde_json::to_vec(&record).unwrap().into())
|
|
.await
|
|
.expect("note stored");
|
|
|
|
let read_back: Answer =
|
|
serde_json::from_slice(&store.get("rec-1").await.unwrap().expect("record")).unwrap();
|
|
assert_eq!(read_back.notes.len(), 1);
|
|
assert_eq!(read_back.notes[0].text, "Thursday suits us.");
|
|
assert_eq!(read_back.notes[0].from, "a@b.example");
|
|
assert_eq!(
|
|
read_back.state, state_before_the_note,
|
|
"a reply is a note, never a decision"
|
|
);
|
|
// The same reply read twice adds nothing.
|
|
assert!(portal::mail::note_for(&reply, &read_back).is_err());
|
|
// And the event log is untouched by it.
|
|
let after = load_events(&js, &bucket, "rec-1").await.expect("events");
|
|
assert_eq!(after.len(), 3, "a note writes no event");
|
|
}
|
|
|
|
/// A session outlives the process that made it. The `MemoryStore` this
|
|
/// replaced could not: every deploy signed out every desk, and the
|
|
/// only proof that this one does not is a store opened twice against
|
|
/// the same JetStream, as two runs of the binary would.
|
|
#[tokio::test]
|
|
async fn a_session_survives_the_process_that_made_it() {
|
|
use tower_sessions::session::{Id, Record};
|
|
use tower_sessions::SessionStore;
|
|
|
|
let Ok(url) = std::env::var("PORTAL_TEST_NATS_URL") else {
|
|
eprintln!("skipped: PORTAL_TEST_NATS_URL is not set");
|
|
return;
|
|
};
|
|
let js = async_nats::jetstream::new(async_nats::connect(&url).await.expect("connect"));
|
|
let store = portal::sessions::NatsStore::open(&js).await.expect("open");
|
|
|
|
let mut record = Record {
|
|
id: Id::default(),
|
|
data: Default::default(),
|
|
expiry_date: tower_sessions::cookie::time::OffsetDateTime::now_utc()
|
|
+ tower_sessions::cookie::time::Duration::hours(1),
|
|
};
|
|
record.data.insert("who".into(), json!("ingrid"));
|
|
store.create(&mut record).await.expect("create");
|
|
|
|
// A second store over the same JetStream is what the next boot of
|
|
// the binary has: nothing carried over in memory.
|
|
let next_boot = portal::sessions::NatsStore::open(&js)
|
|
.await
|
|
.expect("reopen");
|
|
let found = next_boot
|
|
.load(&record.id)
|
|
.await
|
|
.expect("load")
|
|
.expect("still there");
|
|
assert_eq!(found.data.get("who"), Some(&json!("ingrid")));
|
|
|
|
// `create` never writes over somebody else's session: asked to
|
|
// create one whose id is taken, it takes a new id instead.
|
|
let mut collide = Record {
|
|
id: record.id,
|
|
data: Default::default(),
|
|
expiry_date: record.expiry_date,
|
|
};
|
|
next_boot
|
|
.create(&mut collide)
|
|
.await
|
|
.expect("create beside it");
|
|
assert_ne!(
|
|
collide.id, record.id,
|
|
"an id already in use must not be reused"
|
|
);
|
|
assert_eq!(
|
|
next_boot
|
|
.load(&record.id)
|
|
.await
|
|
.expect("load")
|
|
.expect("original intact")
|
|
.data
|
|
.get("who"),
|
|
Some(&json!("ingrid")),
|
|
"the original session must be untouched"
|
|
);
|
|
|
|
// An expired record is gone to anyone asking, whatever the bucket
|
|
// still holds.
|
|
let stale = Record {
|
|
id: Id::default(),
|
|
data: Default::default(),
|
|
expiry_date: tower_sessions::cookie::time::OffsetDateTime::now_utc()
|
|
- tower_sessions::cookie::time::Duration::minutes(1),
|
|
};
|
|
next_boot.save(&stale).await.expect("save stale");
|
|
assert!(next_boot
|
|
.load(&stale.id)
|
|
.await
|
|
.expect("load stale")
|
|
.is_none());
|
|
|
|
// Signing out leaves nothing to read back.
|
|
next_boot.delete(&record.id).await.expect("delete");
|
|
assert!(next_boot
|
|
.load(&record.id)
|
|
.await
|
|
.expect("load after delete")
|
|
.is_none());
|
|
next_boot.delete(&collide.id).await.ok();
|
|
}
|
|
|
|
/// A membership is a case portal keeps itself: invited by a desk or a
|
|
/// decision, active from the person's first sign-in, removed by a desk,
|
|
/// and invited again after that. What the person belongs to is read
|
|
/// back from the same records, matched on phone number or address.
|
|
#[tokio::test]
|
|
async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back() {
|
|
use portal::memberships::{self, Identity, ACTIVE, INVITED, REMOVED};
|
|
|
|
let Ok(url) = std::env::var("PORTAL_TEST_NATS_URL") else {
|
|
eprintln!("skipped: PORTAL_TEST_NATS_URL is not set");
|
|
return;
|
|
};
|
|
let nats = async_nats::connect(&url).await.expect("connect");
|
|
let js = async_nats::jetstream::new(nats.clone());
|
|
ensure_stream(&js).await.expect("event stream");
|
|
// A clean bucket per run.
|
|
let _ = js.delete_key_value(memberships::BUCKET).await;
|
|
let aggregates = portal::content::with_builtin_aggregates(Default::default());
|
|
let questions = std::collections::HashMap::new();
|
|
let access = portal::access::Policy::from_content(&questions, &aggregates);
|
|
let state = portal::server::AppState {
|
|
leptos_options: leptos::config::LeptosOptions::builder()
|
|
.output_name("test")
|
|
.build(),
|
|
nats,
|
|
jetstream: js,
|
|
questions: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
|
|
portal::content::Storylines::one(questions),
|
|
)),
|
|
aggregates: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
|
|
portal::content::Storylines::one(aggregates),
|
|
)),
|
|
site: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
|
|
portal::content::Storylines::one(Default::default()),
|
|
)),
|
|
access: std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(access)),
|
|
gitea_base: String::new(),
|
|
content_raw_base: String::new(),
|
|
content_branch: "main".into(),
|
|
oidc: std::sync::Arc::new(portal::server::oidc::Oidc::disabled()),
|
|
garage: None,
|
|
};
|
|
|
|
// Groups of this run's own: the event log outlives the bucket, and
|
|
// a record id seen before would replay its old history.
|
|
let run = chrono::Utc::now().timestamp_millis();
|
|
let styret = format!("styret{run}");
|
|
let medlemmer = format!("medlemmer{run}");
|
|
let (styret, medlemmer) = (styret.as_str(), medlemmer.as_str());
|
|
|
|
// A desk invites Kari by phone number; a decision grants her a
|
|
// second group by address. Both are invitations until she is seen.
|
|
let by_phone = Identity {
|
|
sub: None,
|
|
phone: Some("+4791234567".into()),
|
|
email: None,
|
|
};
|
|
let by_mail = Identity {
|
|
sub: None,
|
|
phone: None,
|
|
email: Some("kari@vel.no".into()),
|
|
};
|
|
let first = memberships::grant(&state, styret, "Kari", &by_phone, "desk")
|
|
.await
|
|
.expect("grant");
|
|
assert!(first.created && first.reset_link.is_none());
|
|
let again = memberships::grant(&state, styret, "Kari", &by_phone, "desk")
|
|
.await
|
|
.expect("grant again");
|
|
assert!(
|
|
!again.created,
|
|
"a second invite into the same group finds the first"
|
|
);
|
|
memberships::grant(&state, medlemmer, "Kari", &by_mail, "husstander:abc")
|
|
.await
|
|
.expect("grant by mail");
|
|
let records = memberships::all(&state).await;
|
|
assert_eq!(records.len(), 2);
|
|
assert!(records.iter().all(|r| r.state == INVITED));
|
|
assert!(
|
|
memberships::groups_of(&records, &by_phone).is_empty(),
|
|
"an invitation is not yet a group"
|
|
);
|
|
|
|
// She signs in: the provider knows her subject, phone and address.
|
|
let kari = portal::auth::User {
|
|
sub: "vipps-abc".into(),
|
|
username: "kari".into(),
|
|
display_name: "Kari".into(),
|
|
groups: vec![],
|
|
phone: Some("912 34 567".into()),
|
|
email: Some("Kari@Vel.no".into()),
|
|
};
|
|
let mut groups = memberships::on_signed_in(&state, &kari).await;
|
|
groups.sort();
|
|
assert_eq!(groups, vec![medlemmer, styret]);
|
|
let records = memberships::all(&state).await;
|
|
assert!(records.iter().all(|r| r.state == ACTIVE));
|
|
// Both records now know her by everything the provider verified.
|
|
for r in &records {
|
|
let held = memberships::identity_of(r);
|
|
assert_eq!(held.sub.as_deref(), Some("vipps-abc"));
|
|
assert_eq!(held.phone.as_deref(), Some("+4791234567"));
|
|
assert_eq!(held.email.as_deref(), Some("kari@vel.no"));
|
|
}
|
|
|
|
// A desk takes her out of styret; the record stays, as removed.
|
|
assert!(memberships::revoke(&state, styret, &by_phone, "ola")
|
|
.await
|
|
.expect("revoke"));
|
|
assert!(
|
|
!memberships::revoke(&state, styret, &by_phone, "ola")
|
|
.await
|
|
.expect("revoke twice"),
|
|
"already out"
|
|
);
|
|
let records = memberships::all(&state).await;
|
|
let styret_record = records
|
|
.iter()
|
|
.find(|r| memberships::group_of(r) == styret)
|
|
.unwrap();
|
|
assert_eq!(styret_record.state, REMOVED);
|
|
assert_eq!(
|
|
memberships::groups_of(
|
|
&records,
|
|
&Identity {
|
|
sub: Some("vipps-abc".into()),
|
|
phone: None,
|
|
email: None
|
|
}
|
|
),
|
|
vec![medlemmer]
|
|
);
|
|
|
|
// Invited again: the same record, invited, and hers at the next sign-in.
|
|
let back = memberships::grant(&state, styret, "Kari", &by_mail, "desk")
|
|
.await
|
|
.expect("re-grant");
|
|
assert!(back.created);
|
|
let records = memberships::all(&state).await;
|
|
assert_eq!(
|
|
records
|
|
.iter()
|
|
.find(|r| memberships::group_of(r) == styret)
|
|
.unwrap()
|
|
.state,
|
|
INVITED
|
|
);
|
|
let groups = memberships::on_signed_in(&state, &kari).await;
|
|
assert!(groups.contains(&styret.to_string()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_card_of_ones_own_is_replaced_not_added_to_even_on_a_double_click() {
|
|
use portal::answers::{keep_newest_own, own_records, OWNER_FIELD};
|
|
let Ok(url) = std::env::var("PORTAL_TEST_NATS_URL") else {
|
|
eprintln!("skipped: PORTAL_TEST_NATS_URL is not set");
|
|
return;
|
|
};
|
|
let js = async_nats::jetstream::new(async_nats::connect(&url).await.expect("connect"));
|
|
ensure_stream(&js).await.expect("event stream");
|
|
let bucket = format!("own_{}", chrono::Utc::now().timestamp_millis());
|
|
let schemas = std::collections::HashMap::new();
|
|
let now = chrono::Utc::now().timestamp_millis();
|
|
let card = |name: &str, by: &str| json!({"name": name, OWNER_FIELD: by});
|
|
|
|
// Kari's first card, then two sends of her second pressed at once,
|
|
// and Ola's card beside them.
|
|
store_answer(
|
|
&js,
|
|
&schemas,
|
|
&bucket,
|
|
"k1".into(),
|
|
"/kontor/ansatt",
|
|
"a",
|
|
&card("Kari", "kari"),
|
|
now,
|
|
0,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
store_answer(
|
|
&js,
|
|
&schemas,
|
|
&bucket,
|
|
"o1".into(),
|
|
"/kontor/ansatt",
|
|
"a",
|
|
&card("Ola", "ola"),
|
|
now + 1,
|
|
0,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
store_answer(
|
|
&js,
|
|
&schemas,
|
|
&bucket,
|
|
"k2".into(),
|
|
"/kontor/ansatt",
|
|
"a",
|
|
&card("Kari N.", "kari"),
|
|
now + 2,
|
|
0,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
store_answer(
|
|
&js,
|
|
&schemas,
|
|
&bucket,
|
|
"k3".into(),
|
|
"/kontor/ansatt",
|
|
"a",
|
|
&card("Kari N.", "kari"),
|
|
now + 2,
|
|
0,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
// Both racing sends settle after storing; both must keep the same one.
|
|
let (a, b) = tokio::join!(
|
|
keep_newest_own(&js, &bucket, "open", "kari"),
|
|
keep_newest_own(&js, &bucket, "open", "kari")
|
|
);
|
|
let _ = (a, b);
|
|
|
|
let kari = own_records(&js, &bucket, "kari").await;
|
|
assert_eq!(
|
|
kari.iter().map(|a| a.id.as_str()).collect::<Vec<_>>(),
|
|
vec!["k3"],
|
|
"one card, the newest"
|
|
);
|
|
assert_eq!(
|
|
own_records(&js, &bucket, "ola").await.len(),
|
|
1,
|
|
"nobody else's card is touched"
|
|
);
|
|
}
|