Render an Apple configuration profile from the server's PACC document
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
This commit is contained in:
co-authored by
Claude Opus 5
parent
43a2f3a069
commit
44c7ee6870
+223
-45
@@ -34,27 +34,51 @@ async fn a_submission_is_created_moved_refused_and_read_back_through_the_real_en
|
||||
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");
|
||||
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();
|
||||
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");
|
||||
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:?}");
|
||||
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:?}");
|
||||
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.
|
||||
@@ -67,7 +91,8 @@ async fn a_submission_is_created_moved_refused_and_read_back_through_the_real_en
|
||||
// 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 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,
|
||||
@@ -81,13 +106,20 @@ async fn a_submission_is_created_moved_refused_and_read_back_through_the_real_en
|
||||
};
|
||||
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");
|
||||
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();
|
||||
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");
|
||||
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.
|
||||
@@ -122,17 +154,39 @@ async fn a_session_survives_the_process_that_made_it() {
|
||||
|
||||
// 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");
|
||||
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");
|
||||
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"),
|
||||
next_boot
|
||||
.load(&record.id)
|
||||
.await
|
||||
.expect("load")
|
||||
.expect("original intact")
|
||||
.data
|
||||
.get("who"),
|
||||
Some(&json!("ingrid")),
|
||||
"the original session must be untouched"
|
||||
);
|
||||
@@ -146,11 +200,19 @@ async fn a_session_survives_the_process_that_made_it() {
|
||||
- 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());
|
||||
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());
|
||||
assert!(next_boot
|
||||
.load(&record.id)
|
||||
.await
|
||||
.expect("load after delete")
|
||||
.is_none());
|
||||
next_boot.delete(&collide.id).await.ok();
|
||||
}
|
||||
|
||||
@@ -175,12 +237,20 @@ async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back
|
||||
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(),
|
||||
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()))),
|
||||
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(),
|
||||
@@ -198,17 +268,37 @@ async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back
|
||||
|
||||
// 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");
|
||||
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 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");
|
||||
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 {
|
||||
@@ -233,18 +323,47 @@ async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back
|
||||
}
|
||||
|
||||
// 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");
|
||||
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();
|
||||
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]);
|
||||
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");
|
||||
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);
|
||||
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()));
|
||||
}
|
||||
@@ -265,15 +384,74 @@ async fn a_card_of_ones_own_is_replaced_not_added_to_even_on_a_double_click() {
|
||||
|
||||
// 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();
|
||||
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) = 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");
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user