#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
use std::future::Future;
use std::pin::Pin;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use serde::{Deserialize, Serialize};
use super::PushError;
use super::vapid::decode_base64url;
pub const PUSH_SUBSCRIPTIONS_TABLE: &str = "push_subscriptions";
const AUTH_SECRET_LEN: usize = 16;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct PushPrincipal(String);
impl PushPrincipal {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<i64> for PushPrincipal {
fn from(value: i64) -> Self {
Self(value.to_string())
}
}
impl From<&str> for PushPrincipal {
fn from(value: &str) -> Self {
Self(value.to_owned())
}
}
impl From<String> for PushPrincipal {
fn from(value: String) -> Self {
Self(value)
}
}
impl std::fmt::Display for PushPrincipal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BrowserSubscription {
pub endpoint: String,
pub keys: SubscriptionKeys,
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubscriptionKeys {
pub p256dh: String,
pub auth: String,
}
#[derive(Clone, PartialEq, Eq)]
pub struct StoredSubscription {
principal_id: String,
endpoint: String,
p256dh: Vec<u8>,
auth: Vec<u8>,
}
impl StoredSubscription {
#[must_use]
pub fn principal_id(&self) -> &str {
&self.principal_id
}
#[must_use]
pub fn endpoint(&self) -> &str {
&self.endpoint
}
#[must_use]
pub fn p256dh(&self) -> &[u8] {
&self.p256dh
}
#[must_use]
pub fn auth(&self) -> &[u8] {
&self.auth
}
#[must_use]
pub fn p256dh_base64url(&self) -> String {
URL_SAFE_NO_PAD.encode(&self.p256dh)
}
#[must_use]
pub fn auth_base64url(&self) -> String {
URL_SAFE_NO_PAD.encode(&self.auth)
}
}
impl std::fmt::Debug for BrowserSubscription {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BrowserSubscription")
.field("endpoint.origin", &endpoint_origin(&self.endpoint))
.field("keys", &self.keys)
.finish_non_exhaustive()
}
}
impl std::fmt::Debug for SubscriptionKeys {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SubscriptionKeys")
.field("p256dh", &"<redacted>")
.field("auth", &"<redacted>")
.finish()
}
}
impl std::fmt::Debug for StoredSubscription {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StoredSubscription")
.field("principal_id", &self.principal_id)
.field("endpoint.origin", &endpoint_origin(&self.endpoint))
.field("p256dh", &"<redacted>")
.field("auth", &"<redacted>")
.finish_non_exhaustive()
}
}
impl BrowserSubscription {
pub fn decode(&self, principal: &PushPrincipal) -> Result<StoredSubscription, PushError> {
let endpoint = validate_endpoint(&self.endpoint)?;
let p256dh = decode_base64url(self.keys.p256dh.trim()).ok_or_else(|| {
PushError::InvalidSubscriptionKey("`p256dh` is not valid base64url".to_owned())
})?;
let auth = decode_base64url(self.keys.auth.trim()).ok_or_else(|| {
PushError::InvalidSubscriptionKey("`auth` is not valid base64url".to_owned())
})?;
check_key_material(&p256dh, &auth)?;
Ok(StoredSubscription {
principal_id: principal.0.clone(),
endpoint,
p256dh,
auth,
})
}
}
pub(crate) fn check_key_material(p256dh: &[u8], auth: &[u8]) -> Result<(), PushError> {
p256::PublicKey::from_sec1_bytes(p256dh).map_err(|_| {
PushError::InvalidSubscriptionKey(
"`p256dh` is not an uncompressed P-256 public key on the curve".to_owned(),
)
})?;
if auth.len() != AUTH_SECRET_LEN {
return Err(PushError::InvalidSubscriptionKey(format!(
"`auth` must be exactly {AUTH_SECRET_LEN} bytes, got {}",
auth.len()
)));
}
Ok(())
}
pub(crate) fn endpoint_origin(endpoint: &str) -> String {
url::Url::parse(endpoint).map_or_else(
|_| "<unparseable>".to_owned(),
|parsed| parsed.origin().ascii_serialization(),
)
}
pub(crate) const MAX_ENDPOINT_LEN: usize = 2048;
fn validate_endpoint(endpoint: &str) -> Result<String, PushError> {
let endpoint = endpoint.trim();
if endpoint.len() > MAX_ENDPOINT_LEN {
return Err(PushError::InvalidEndpoint(format!(
"push endpoints must be at most {MAX_ENDPOINT_LEN} bytes, got {}",
endpoint.len()
)));
}
let mut parsed = url::Url::parse(endpoint)
.map_err(|e| PushError::InvalidEndpoint(format!("not a valid URL: {e}")))?;
if parsed.scheme() != "https" {
return Err(PushError::InvalidEndpoint(format!(
"push endpoints must use https, got `{}`",
parsed.scheme()
)));
}
match parsed.host() {
Some(url::Host::Domain(host)) => {
let host = host.trim_end_matches('.').to_ascii_lowercase();
if host.is_empty() || host == "localhost" || host.ends_with(".localhost") {
return Err(PushError::InvalidEndpoint(
"refusing a loopback push endpoint".to_owned(),
));
}
}
Some(url::Host::Ipv4(_) | url::Host::Ipv6(_)) => {
return Err(PushError::InvalidEndpoint(
"push endpoints must name a host, not an IP literal".to_owned(),
));
}
None => {
return Err(PushError::InvalidEndpoint("no host".to_owned()));
}
}
let _ = parsed.set_username("");
let _ = parsed.set_password(None);
parsed.set_fragment(None);
Ok(parsed.to_string())
}
pub const MAX_SUBSCRIPTIONS_PER_PRINCIPAL: usize = 20;
pub trait PushSubscriptionStore: Send + Sync + 'static {
fn save(
&self,
subscription: StoredSubscription,
) -> impl Future<Output = Result<(), PushError>> + Send;
fn list_for(
&self,
principal_id: &str,
) -> impl Future<Output = Result<Vec<StoredSubscription>, PushError>> + Send;
fn remove(
&self,
endpoint: &str,
principal_id: Option<&str>,
) -> impl Future<Output = Result<u64, PushError>> + Send;
}
type BoxedFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, PushError>> + Send + 'a>>;
pub(crate) trait BoxedPushSubscriptionStore: Send + Sync + 'static {
fn boxed_save(&self, subscription: StoredSubscription) -> BoxedFuture<'_, ()>;
fn boxed_list_for(&self, principal_id: String) -> BoxedFuture<'_, Vec<StoredSubscription>>;
fn boxed_remove(&self, endpoint: String, principal_id: Option<String>) -> BoxedFuture<'_, u64>;
}
impl<S: PushSubscriptionStore> BoxedPushSubscriptionStore for S {
fn boxed_save(&self, subscription: StoredSubscription) -> BoxedFuture<'_, ()> {
Box::pin(PushSubscriptionStore::save(self, subscription))
}
fn boxed_list_for(&self, principal_id: String) -> BoxedFuture<'_, Vec<StoredSubscription>> {
Box::pin(async move { PushSubscriptionStore::list_for(self, &principal_id).await })
}
fn boxed_remove(&self, endpoint: String, principal_id: Option<String>) -> BoxedFuture<'_, u64> {
Box::pin(async move {
PushSubscriptionStore::remove(self, &endpoint, principal_id.as_deref()).await
})
}
}
#[derive(Debug, Default)]
pub struct MemoryPushSubscriptionStore {
rows: std::sync::Mutex<Vec<(String, StoredSubscription)>>,
}
impl MemoryPushSubscriptionStore {
#[must_use]
pub fn new() -> Self {
Self::default()
}
fn lock(
&self,
) -> Result<std::sync::MutexGuard<'_, Vec<(String, StoredSubscription)>>, PushError> {
self.rows
.lock()
.map_err(|_| PushError::Store("memory store mutex poisoned".to_owned()))
}
}
impl PushSubscriptionStore for MemoryPushSubscriptionStore {
async fn save(&self, subscription: StoredSubscription) -> Result<(), PushError> {
let tenant = crate::tenancy::current_tenant_id();
let mut rows = self.lock()?;
if let Some((existing_tenant, existing)) = rows
.iter()
.find(|(_, row)| row.endpoint == subscription.endpoint)
&& (*existing_tenant != tenant || existing.principal_id != subscription.principal_id)
&& (existing.p256dh != subscription.p256dh || existing.auth != subscription.auth)
{
return Err(PushError::EndpointClaimed);
}
let is_new = !rows.iter().any(|(t, row)| {
row.endpoint == subscription.endpoint
&& *t == tenant
&& row.principal_id == subscription.principal_id
});
if is_new
&& rows
.iter()
.filter(|(t, row)| *t == tenant && row.principal_id == subscription.principal_id)
.count()
>= MAX_SUBSCRIPTIONS_PER_PRINCIPAL
{
return Err(PushError::TooManySubscriptions {
max: MAX_SUBSCRIPTIONS_PER_PRINCIPAL,
});
}
rows.retain(|(_, row)| row.endpoint != subscription.endpoint);
rows.push((tenant, subscription));
drop(rows);
Ok(())
}
async fn list_for(&self, principal_id: &str) -> Result<Vec<StoredSubscription>, PushError> {
let tenant = crate::tenancy::current_tenant_id();
let rows = self.lock()?;
Ok(rows
.iter()
.filter(|(t, row)| *t == tenant && row.principal_id == principal_id)
.take(MAX_SUBSCRIPTIONS_PER_PRINCIPAL)
.map(|(_, row)| row.clone())
.collect())
}
async fn remove(&self, endpoint: &str, principal_id: Option<&str>) -> Result<u64, PushError> {
let tenant = crate::tenancy::current_tenant_id();
let mut rows = self.lock()?;
let before = rows.len();
rows.retain(|(t, row)| {
row.endpoint != endpoint
|| principal_id.is_some_and(|id| *t != tenant || row.principal_id != id)
});
Ok((before - rows.len()) as u64)
}
}
#[cfg(feature = "db")]
pub use self::db_store::DbPushSubscriptionStore;
#[cfg(feature = "db")]
mod db_store {
use super::{PUSH_SUBSCRIPTIONS_TABLE, PushError, PushSubscriptionStore, StoredSubscription};
use crate::db::RuntimeConnection;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::{DateTime, Utc};
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
use diesel_async::pooled_connection::deadpool::Pool;
#[cfg(not(feature = "sqlite"))]
mod schema {
diesel::table! {
push_subscriptions (id) {
id -> BigInt,
tenant_id -> Text,
principal_id -> Text,
endpoint -> Text,
p256dh -> Text,
auth -> Text,
created_at -> Timestamptz,
}
}
}
#[cfg(feature = "sqlite")]
mod schema {
diesel::table! {
push_subscriptions (id) {
id -> BigInt,
tenant_id -> Text,
principal_id -> Text,
endpoint -> Text,
p256dh -> Text,
auth -> Text,
created_at -> TimestamptzSqlite,
}
}
}
use schema::push_subscriptions;
#[derive(Queryable, Selectable)]
#[diesel(table_name = push_subscriptions)]
struct SubscriptionRow {
principal_id: String,
endpoint: String,
p256dh: String,
auth: String,
}
#[derive(Insertable)]
#[diesel(table_name = push_subscriptions)]
struct NewSubscriptionRow {
tenant_id: String,
principal_id: String,
endpoint: String,
p256dh: String,
auth: String,
created_at: DateTime<Utc>,
}
impl TryFrom<SubscriptionRow> for StoredSubscription {
type Error = PushError;
fn try_from(row: SubscriptionRow) -> Result<Self, PushError> {
let decode = |label: &str, value: &str| {
URL_SAFE_NO_PAD.decode(value).map_err(|e| {
PushError::Store(format!(
"stored `{label}` for {} is not base64url: {e}",
row.endpoint
))
})
};
let p256dh = decode("p256dh", &row.p256dh)?;
let auth = decode("auth", &row.auth)?;
super::check_key_material(&p256dh, &auth).map_err(|e| {
PushError::Store(format!(
"stored keys for {} are unusable: {e}",
row.endpoint
))
})?;
Ok(Self {
p256dh,
auth,
principal_id: row.principal_id,
endpoint: row.endpoint,
})
}
}
fn usable_subscriptions(
principal_id: &str,
rows: Vec<SubscriptionRow>,
) -> Vec<StoredSubscription> {
let total = rows.len();
let usable: Vec<StoredSubscription> = rows
.into_iter()
.filter_map(|row| StoredSubscription::try_from(row).ok())
.collect();
if usable.len() < total {
tracing::warn!(
principal_id = %principal_id,
count = total - usable.len(),
"skipping unusable push subscription rows; key material did not decode",
);
}
usable
}
const MAX_LIST_PAGES: i64 = 5;
fn absorb_page(
principal_id: &str,
usable: &mut Vec<StoredSubscription>,
page: Vec<SubscriptionRow>,
) -> bool {
let full = page.len() >= super::MAX_SUBSCRIPTIONS_PER_PRINCIPAL;
usable.extend(usable_subscriptions(principal_id, page));
usable.truncate(super::MAX_SUBSCRIPTIONS_PER_PRINCIPAL);
full && usable.len() < super::MAX_SUBSCRIPTIONS_PER_PRINCIPAL
}
#[derive(Clone)]
pub struct DbPushSubscriptionStore {
pool: Pool<RuntimeConnection>,
}
impl DbPushSubscriptionStore {
#[must_use]
pub const fn new(pool: Pool<RuntimeConnection>) -> Self {
Self { pool }
}
async fn conn(
&self,
) -> Result<diesel_async::pooled_connection::deadpool::Object<RuntimeConnection>, PushError>
{
self.pool
.get()
.await
.map_err(|e| PushError::Store(format!("checkout failed: {e}")))
}
}
fn store_err(e: &diesel::result::Error) -> PushError {
let message = e.to_string();
if message.contains("tenant_id") && message.contains("column") {
return PushError::Store(format!(
"query failed: {e}. The `{PUSH_SUBSCRIPTIONS_TABLE}` table has no `tenant_id` \
column — see docs/migrations/0.9.0.md"
));
}
if message.contains("does not exist") || message.contains("no such table") {
return PushError::Store(format!(
"query failed: {e}. The `{PUSH_SUBSCRIPTIONS_TABLE}` table is missing — \
scaffold it with `autumn generate pwa`, then apply it with `autumn migrate`"
));
}
PushError::Store(format!("query failed: {e}"))
}
impl PushSubscriptionStore for DbPushSubscriptionStore {
async fn save(&self, subscription: StoredSubscription) -> Result<(), PushError> {
use diesel::query_dsl::methods::FilterDsl as _;
use push_subscriptions::dsl;
let mut conn = self.conn().await?;
let tenant_id = crate::tenancy::current_tenant_id();
let principal_id = subscription.principal_id.clone();
let endpoint = subscription.endpoint.clone();
let p256dh = subscription.p256dh_base64url();
let auth = subscription.auth_base64url();
let counted = diesel::QueryDsl::filter(
diesel::QueryDsl::filter(
diesel::QueryDsl::filter(
dsl::push_subscriptions,
dsl::tenant_id.eq(&tenant_id),
),
dsl::principal_id.eq(&principal_id),
),
dsl::endpoint.ne(&endpoint),
);
let existing: i64 = counted
.count()
.get_result(&mut conn)
.await
.map_err(|e| store_err(&e))?;
if usize::try_from(existing).unwrap_or(usize::MAX)
>= super::MAX_SUBSCRIPTIONS_PER_PRINCIPAL
{
return Err(PushError::TooManySubscriptions {
max: super::MAX_SUBSCRIPTIONS_PER_PRINCIPAL,
});
}
let affected = diesel::insert_into(push_subscriptions::table)
.values(NewSubscriptionRow {
tenant_id: tenant_id.clone(),
principal_id: principal_id.clone(),
endpoint: endpoint.clone(),
p256dh: p256dh.clone(),
auth: auth.clone(),
created_at: crate::time::ambient_now(),
})
.on_conflict(dsl::endpoint)
.do_update()
.set((
dsl::tenant_id.eq(&tenant_id),
dsl::principal_id.eq(&principal_id),
dsl::p256dh.eq(&p256dh),
dsl::auth.eq(&auth),
))
.filter(
dsl::tenant_id
.eq(tenant_id.clone())
.and(dsl::principal_id.eq(principal_id.clone()))
.or(dsl::p256dh
.eq(p256dh.clone())
.and(dsl::auth.eq(auth.clone()))),
)
.execute(&mut conn)
.await
.map_err(|e| store_err(&e))?;
if affected == 0 {
return Err(PushError::EndpointClaimed);
}
Ok(())
}
async fn list_for(&self, principal_id: &str) -> Result<Vec<StoredSubscription>, PushError> {
use push_subscriptions::dsl;
let mut conn = self.conn().await?;
let page_size =
i64::try_from(super::MAX_SUBSCRIPTIONS_PER_PRINCIPAL).unwrap_or(i64::MAX);
let mut usable = Vec::new();
for page_no in 0..MAX_LIST_PAGES {
let page: Vec<SubscriptionRow> = dsl::push_subscriptions
.filter(dsl::tenant_id.eq(crate::tenancy::current_tenant_id()))
.filter(dsl::principal_id.eq(principal_id))
.order(dsl::id.asc())
.limit(page_size)
.offset(page_no * page_size)
.select(SubscriptionRow::as_select())
.load(&mut conn)
.await
.map_err(|e| store_err(&e))?;
if !absorb_page(principal_id, &mut usable, page) {
break;
}
}
Ok(usable)
}
async fn remove(
&self,
endpoint: &str,
principal_id: Option<&str>,
) -> Result<u64, PushError> {
use push_subscriptions::dsl;
let mut conn = self.conn().await?;
let affected = match principal_id {
Some(id) => {
diesel::delete(
dsl::push_subscriptions
.filter(dsl::endpoint.eq(endpoint))
.filter(dsl::tenant_id.eq(crate::tenancy::current_tenant_id()))
.filter(dsl::principal_id.eq(id)),
)
.execute(&mut conn)
.await
}
None => {
diesel::delete(dsl::push_subscriptions.filter(dsl::endpoint.eq(endpoint)))
.execute(&mut conn)
.await
}
}
.map_err(|e| store_err(&e))?;
Ok(affected as u64)
}
}
#[cfg(test)]
mod tests {
use super::super::MAX_SUBSCRIPTIONS_PER_PRINCIPAL;
use super::*;
const P256DH: &str = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4";
const AUTH: &str = "BTBZMqHH6r4Tts7J_aSIgg";
fn row_with(endpoint: &str, p256dh: &str, auth: &str) -> SubscriptionRow {
SubscriptionRow {
principal_id: "1".to_owned(),
endpoint: endpoint.to_owned(),
p256dh: p256dh.to_owned(),
auth: auth.to_owned(),
}
}
fn good(endpoint: &str) -> SubscriptionRow {
row_with(endpoint, P256DH, AUTH)
}
fn bad(endpoint: &str) -> SubscriptionRow {
row_with(endpoint, "not base64!", "not base64!")
}
#[test]
fn corrupt_rows_in_the_first_page_do_not_starve_healthy_rows_after_it() {
let mut usable = Vec::new();
let first: Vec<SubscriptionRow> = (0..MAX_SUBSCRIPTIONS_PER_PRINCIPAL)
.map(|i| bad(&format!("https://push.example.com/bad{i}")))
.collect();
assert!(
absorb_page("1", &mut usable, first),
"a full page with no usable row needs the next page"
);
let last = vec![good("https://push.example.com/good")];
assert!(
!absorb_page("1", &mut usable, last),
"a short page is the last"
);
assert_eq!(usable.len(), 1);
}
#[test]
fn paging_stops_once_the_cap_of_usable_rows_is_collected() {
let mut usable = Vec::new();
let page: Vec<SubscriptionRow> = (0..MAX_SUBSCRIPTIONS_PER_PRINCIPAL)
.map(|i| good(&format!("https://push.example.com/ok{i}")))
.collect();
assert!(!absorb_page("1", &mut usable, page));
assert_eq!(usable.len(), MAX_SUBSCRIPTIONS_PER_PRINCIPAL);
}
#[test]
fn decodable_but_invalid_key_material_is_not_usable() {
let rows = vec![
row_with("https://push.example.com/short-key", "AAAA", AUTH),
row_with("https://push.example.com/short-auth", P256DH, "AAAA"),
good("https://push.example.com/ok"),
];
let usable = usable_subscriptions("1", rows);
assert_eq!(usable.len(), 1);
assert_eq!(usable[0].endpoint(), "https://push.example.com/ok");
}
fn store_message(sql_error: &str) -> String {
let error = diesel::result::Error::QueryBuilderError(sql_error.into());
match store_err(&error) {
PushError::Store(message) => message,
other => panic!("expected PushError::Store, got {other:?}"),
}
}
#[test]
fn a_table_without_tenant_id_points_at_the_migration_guide() {
let message = store_message(r#"column "tenant_id" does not exist"#);
assert!(message.contains("has no `tenant_id` column"), "{message}");
assert!(message.contains("docs/migrations/0.9.0.md"), "{message}");
assert!(
!message.contains("autumn generate pwa"),
"the table exists, so scaffolding it again is the wrong advice: {message}"
);
}
#[test]
fn a_missing_table_points_at_the_scaffold() {
let message = store_message(r#"relation "push_subscriptions" does not exist"#);
assert!(message.contains("table is missing"), "{message}");
assert!(message.contains("autumn generate pwa"), "{message}");
}
#[test]
fn any_other_error_is_passed_through() {
assert_eq!(
store_message("connection reset"),
"query failed: connection reset"
);
}
#[test]
fn a_corrupt_row_does_not_hide_the_healthy_ones() {
let rows = vec![
good("https://push.example.com/a"),
bad("https://push.example.com/bad"),
good("https://push.example.com/b"),
];
let usable = usable_subscriptions("1", rows);
let endpoints: Vec<&str> = usable.iter().map(StoredSubscription::endpoint).collect();
assert_eq!(
endpoints,
["https://push.example.com/a", "https://push.example.com/b"]
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
const P256DH: &str =
"BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4";
const AUTH: &str = "BTBZMqHH6r4Tts7J_aSIgg";
const OTHER_P256DH: &str =
"BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8";
fn browser_subscription(endpoint: &str) -> BrowserSubscription {
BrowserSubscription {
endpoint: endpoint.to_owned(),
keys: SubscriptionKeys {
p256dh: P256DH.to_owned(),
auth: AUTH.to_owned(),
},
}
}
fn stuff_past_the_cap(store: &MemoryPushSubscriptionStore) {
let extra: Vec<(String, StoredSubscription)> = (0..(MAX_SUBSCRIPTIONS_PER_PRINCIPAL * 3))
.map(|i| {
let row = stored(1_i64, &format!("https://push.example.com/raw{i}"));
(String::new(), row)
})
.collect();
let mut rows = store.rows.lock().expect("memory store lock");
rows.extend(extra);
drop(rows);
}
fn stored(principal: impl Into<PushPrincipal>, endpoint: &str) -> StoredSubscription {
browser_subscription(endpoint)
.decode(&principal.into())
.expect("valid subscription")
}
#[test]
fn principal_accepts_an_integer_user_id() {
assert_eq!(PushPrincipal::from(42_i64).as_str(), "42");
}
#[test]
fn principal_accepts_string_shaped_ids() {
assert_eq!(PushPrincipal::from("user:42").as_str(), "user:42");
assert_eq!(
PushPrincipal::from("service:ci".to_owned()).as_str(),
"service:ci"
);
}
#[test]
fn principal_for_the_same_user_id_is_stable_across_both_forms() {
assert_eq!(PushPrincipal::from(7_i64), PushPrincipal::from("7"));
}
#[test]
fn decode_turns_base64url_keys_into_raw_bytes() {
let subscription = stored(1_i64, "https://push.example.com/a");
assert_eq!(subscription.principal_id(), "1");
assert_eq!(subscription.endpoint(), "https://push.example.com/a");
assert_eq!(
subscription.p256dh(),
URL_SAFE_NO_PAD.decode(P256DH).expect("decode")
);
assert_eq!(
subscription.auth(),
URL_SAFE_NO_PAD.decode(AUTH).expect("decode")
);
assert_eq!(subscription.p256dh().len(), 65);
assert_eq!(subscription.auth().len(), 16);
}
#[test]
fn decode_rejects_a_p256dh_that_is_not_a_curve_point() {
let mut sub = browser_subscription("https://push.example.com/a");
let mut bytes = URL_SAFE_NO_PAD.decode(P256DH).expect("decode");
bytes[64] ^= 0x01;
sub.keys.p256dh = URL_SAFE_NO_PAD.encode(bytes);
let err = sub
.decode(&1_i64.into())
.expect_err("an off-curve p256dh is rejected");
assert!(
matches!(err, PushError::InvalidSubscriptionKey(_)),
"{err:?}"
);
}
#[test]
fn decode_rejects_a_wrong_length_auth_secret() {
let mut sub = browser_subscription("https://push.example.com/a");
sub.keys.auth = URL_SAFE_NO_PAD.encode([0_u8; 8]);
let err = sub.decode(&1_i64.into()).expect_err("auth is 16 bytes");
assert!(
matches!(err, PushError::InvalidSubscriptionKey(_)),
"{err:?}"
);
}
#[test]
fn decode_rejects_non_base64url_keys() {
let mut sub = browser_subscription("https://push.example.com/a");
sub.keys.p256dh = "not base64!!!".to_owned();
let err = sub.decode(&1_i64.into()).expect_err("garbage is rejected");
assert!(
matches!(err, PushError::InvalidSubscriptionKey(_)),
"{err:?}"
);
}
#[test]
fn decode_rejects_a_non_https_endpoint() {
let err = browser_subscription("http://push.example.com/a")
.decode(&1_i64.into())
.expect_err("http:// endpoints are refused");
assert!(matches!(err, PushError::InvalidEndpoint(_)), "{err:?}");
}
#[test]
fn decode_rejects_an_endpoint_pointing_at_a_private_address() {
for endpoint in [
"https://127.0.0.1/push",
"https://localhost/push",
"https://10.0.0.1/push",
"https://169.254.169.254/latest/meta-data",
"https://[::1]/push",
] {
let err = browser_subscription(endpoint)
.decode(&1_i64.into())
.expect_err(&format!("{endpoint} must be refused"));
assert!(
matches!(err, PushError::InvalidEndpoint(_)),
"{endpoint}: expected InvalidEndpoint, got {err:?}"
);
}
}
#[test]
fn decode_accepts_a_real_push_service_endpoint() {
for endpoint in [
"https://fcm.googleapis.com/fcm/send/abc123",
"https://updates.push.services.mozilla.com/wpush/v2/gAAAA",
"https://wns2-par02p.notify.windows.com/w/?token=xyz",
] {
browser_subscription(endpoint)
.decode(&1_i64.into())
.unwrap_or_else(|e| panic!("{endpoint} must be accepted, got {e}"));
}
}
#[tokio::test]
async fn save_then_list_round_trips() {
let store = MemoryPushSubscriptionStore::new();
let subscription = stored(1_i64, "https://push.example.com/a");
store.save(subscription.clone()).await.expect("save");
assert_eq!(store.list_for("1").await.expect("list"), vec![subscription]);
}
#[tokio::test]
async fn saving_the_same_endpoint_twice_upserts_rather_than_duplicates() {
let store = MemoryPushSubscriptionStore::new();
store
.save(stored(1_i64, "https://push.example.com/a"))
.await
.expect("save");
store
.save(stored(1_i64, "https://push.example.com/a"))
.await
.expect("re-save");
assert_eq!(
store.list_for("1").await.expect("list").len(),
1,
"re-subscribing the same endpoint must update, not duplicate"
);
}
#[tokio::test]
async fn re_saving_an_endpoint_under_a_new_principal_moves_it() {
let store = MemoryPushSubscriptionStore::new();
store
.save(stored(1_i64, "https://push.example.com/a"))
.await
.expect("save");
store
.save(stored(2_i64, "https://push.example.com/a"))
.await
.expect("re-save under a new principal");
assert!(
store.list_for("1").await.expect("list").is_empty(),
"the previous owner must no longer receive on this endpoint"
);
assert_eq!(store.list_for("2").await.expect("list").len(), 1);
}
#[tokio::test]
async fn list_is_scoped_to_one_principal() {
let store = MemoryPushSubscriptionStore::new();
store
.save(stored(1_i64, "https://push.example.com/a"))
.await
.expect("save");
store
.save(stored(2_i64, "https://push.example.com/b"))
.await
.expect("save");
let rows = store.list_for("1").await.expect("list");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].endpoint(), "https://push.example.com/a");
}
#[tokio::test]
async fn list_for_an_unknown_principal_is_empty_not_an_error() {
let store = MemoryPushSubscriptionStore::new();
assert!(store.list_for("nobody").await.expect("list").is_empty());
}
#[tokio::test]
async fn one_principal_may_hold_several_devices() {
let store = MemoryPushSubscriptionStore::new();
store
.save(stored(1_i64, "https://push.example.com/laptop"))
.await
.expect("save");
store
.save(stored(1_i64, "https://push.example.com/phone"))
.await
.expect("save");
assert_eq!(store.list_for("1").await.expect("list").len(), 2);
}
#[tokio::test]
async fn unscoped_remove_prunes_by_endpoint() {
let store = MemoryPushSubscriptionStore::new();
store
.save(stored(1_i64, "https://push.example.com/a"))
.await
.expect("save");
assert_eq!(
store
.remove("https://push.example.com/a", None)
.await
.expect("remove"),
1
);
assert!(store.list_for("1").await.expect("list").is_empty());
}
#[tokio::test]
async fn scoped_remove_refuses_another_principals_endpoint() {
let store = MemoryPushSubscriptionStore::new();
store
.save(stored(1_i64, "https://push.example.com/a"))
.await
.expect("save");
assert_eq!(
store
.remove("https://push.example.com/a", Some("2"))
.await
.expect("remove"),
0,
"a different principal must not be able to delete this row"
);
assert_eq!(store.list_for("1").await.expect("list").len(), 1);
}
#[tokio::test]
async fn removing_a_missing_endpoint_is_a_no_op_not_an_error() {
let store = MemoryPushSubscriptionStore::new();
assert_eq!(
store
.remove("https://push.example.com/gone", None)
.await
.expect("remove"),
0
);
}
#[test]
fn debug_never_prints_a_working_send_capability() {
let secret_path = "SECRET-DEVICE-TOKEN";
let browser = browser_subscription(&format!("https://push.example.com/{secret_path}"));
let stored = browser.decode(&7_i64.into()).expect("valid");
for rendered in [format!("{browser:?}"), format!("{stored:?}")] {
assert!(
!rendered.contains(secret_path),
"the endpoint path is a capability and must not be printed: {rendered}"
);
assert!(
!rendered.contains(AUTH),
"the auth secret must not be printed: {rendered}"
);
assert!(
!rendered.contains(P256DH),
"the subscription key must not be printed: {rendered}"
);
assert!(
rendered.contains("push.example.com"),
"…but the origin stays, so a delivery problem is still diagnosable: {rendered}"
);
}
let keys = format!("{:?}", browser.keys);
assert!(!keys.contains(AUTH) && !keys.contains(P256DH), "{keys}");
}
#[test]
fn decode_rejects_a_trailing_dot_loopback_endpoint() {
for endpoint in [
"https://localhost./push",
"https://LOCALHOST./push",
"https://foo.localhost./push",
] {
let err = browser_subscription(endpoint)
.decode(&1_i64.into())
.expect_err(&format!("{endpoint} must be refused"));
assert!(
matches!(err, PushError::InvalidEndpoint(_)),
"{endpoint}: {err:?}"
);
}
}
#[test]
fn decode_normalizes_the_endpoint_so_variants_are_one_row() {
let canonical = stored(1_i64, "https://push.example.com/abc")
.endpoint()
.to_owned();
for variant in [
"https://push.example.com:443/abc",
"https://PUSH.example.com/abc",
"https://user:secret@push.example.com/abc",
"https://push.example.com/abc#fragment",
" https://push.example.com/abc ",
] {
assert_eq!(
stored(1_i64, variant).endpoint(),
canonical,
"{variant} must normalize to the same endpoint"
);
}
}
#[test]
fn decode_does_not_echo_the_submitted_url_in_its_error() {
let secret = "https://push.example.com/SECRET-DEVICE-TOKEN";
let mut sub = browser_subscription(secret);
sub.endpoint = secret.replace("https", "http");
let err = sub.decode(&1_i64.into()).expect_err("http is refused");
assert!(
!err.to_string().contains("SECRET-DEVICE-TOKEN"),
"the error must not reflect the endpoint back: {err}"
);
}
#[test]
fn decode_rejects_an_absurdly_long_endpoint() {
let long = format!("https://push.example.com/{}", "x".repeat(MAX_ENDPOINT_LEN));
let err = browser_subscription(&long)
.decode(&1_i64.into())
.expect_err("oversize endpoints are refused");
assert!(matches!(err, PushError::InvalidEndpoint(_)), "{err:?}");
}
#[tokio::test]
async fn a_different_principal_cannot_claim_an_endpoint_with_its_own_keys() {
let store = MemoryPushSubscriptionStore::new();
store
.save(stored(1_i64, "https://push.example.com/a"))
.await
.expect("save");
let mut hostile = browser_subscription("https://push.example.com/a");
hostile.keys.p256dh = OTHER_P256DH.to_owned();
let hostile = hostile.decode(&2_i64.into()).expect("valid shape");
assert!(
matches!(store.save(hostile).await, Err(PushError::EndpointClaimed)),
"an endpoint URL alone must not transfer a subscription"
);
assert_eq!(
store.list_for("1").await.expect("list").len(),
1,
"the original owner keeps receiving"
);
assert!(store.list_for("2").await.expect("list").is_empty());
}
#[tokio::test]
async fn a_matching_p256dh_alone_does_not_transfer_an_endpoint() {
let store = MemoryPushSubscriptionStore::new();
store
.save(stored(1_i64, "https://push.example.com/a"))
.await
.expect("save");
let mut hostile = browser_subscription("https://push.example.com/a");
hostile.keys.auth = URL_SAFE_NO_PAD.encode([7_u8; 16]);
let hostile = hostile.decode(&2_i64.into()).expect("valid shape");
assert!(
matches!(store.save(hostile).await, Err(PushError::EndpointClaimed)),
"both keys must match for a cross-principal move"
);
let rows = store.list_for("1").await.expect("list");
assert_eq!(rows.len(), 1, "the original owner keeps the subscription");
assert_eq!(
rows[0].auth(),
URL_SAFE_NO_PAD.decode(AUTH).expect("decode").as_slice(),
"and its auth secret is untouched, so it can still decrypt"
);
}
#[tokio::test]
async fn the_same_browser_re_subscribing_under_a_new_user_still_moves() {
let store = MemoryPushSubscriptionStore::new();
store
.save(stored(1_i64, "https://push.example.com/a"))
.await
.expect("save");
store
.save(stored(2_i64, "https://push.example.com/a"))
.await
.expect("a genuine re-subscribe is honored");
assert!(store.list_for("1").await.expect("list").is_empty());
assert_eq!(store.list_for("2").await.expect("list").len(), 1);
}
#[tokio::test]
async fn one_principal_may_rotate_its_own_keys_freely() {
let store = MemoryPushSubscriptionStore::new();
store
.save(stored(1_i64, "https://push.example.com/a"))
.await
.expect("save");
let mut rotated = browser_subscription("https://push.example.com/a");
rotated.keys.p256dh = OTHER_P256DH.to_owned();
store
.save(rotated.decode(&1_i64.into()).expect("valid"))
.await
.expect("the owner may rotate its own keys");
assert_eq!(store.list_for("1").await.expect("list").len(), 1);
}
#[tokio::test]
async fn a_principal_cannot_register_unbounded_subscriptions() {
let store = MemoryPushSubscriptionStore::new();
for i in 0..MAX_SUBSCRIPTIONS_PER_PRINCIPAL {
store
.save(stored(1_i64, &format!("https://push.example.com/d{i}")))
.await
.expect("save up to the cap");
}
assert!(
matches!(
store
.save(stored(1_i64, "https://push.example.com/one-too-many"))
.await,
Err(PushError::TooManySubscriptions { .. })
),
"the cap must be enforced"
);
store
.save(stored(1_i64, "https://push.example.com/d0"))
.await
.expect("re-saving an existing endpoint is not capped");
assert_eq!(
store.list_for("1").await.expect("list").len(),
MAX_SUBSCRIPTIONS_PER_PRINCIPAL
);
}
#[tokio::test]
async fn list_for_is_bounded_even_when_the_table_holds_more() {
let store = MemoryPushSubscriptionStore::new();
stuff_past_the_cap(&store);
assert_eq!(
store.list_for("1").await.expect("list").len(),
MAX_SUBSCRIPTIONS_PER_PRINCIPAL,
"one notification must never fan out past the cap"
);
}
#[tokio::test]
async fn a_move_into_a_full_principal_is_refused() {
let store = MemoryPushSubscriptionStore::new();
store
.save(stored(1_i64, "https://push.example.com/shared"))
.await
.expect("owner A saves");
for i in 0..MAX_SUBSCRIPTIONS_PER_PRINCIPAL {
store
.save(stored(2_i64, &format!("https://push.example.com/d{i}")))
.await
.expect("fill B to the cap");
}
assert!(
matches!(
store
.save(stored(2_i64, "https://push.example.com/shared"))
.await,
Err(PushError::TooManySubscriptions { .. })
),
"a move into a full principal must not exceed the cap"
);
assert_eq!(store.list_for("1").await.expect("list").len(), 1);
}
#[tokio::test]
async fn the_cap_is_per_principal_not_global() {
let store = MemoryPushSubscriptionStore::new();
for i in 0..MAX_SUBSCRIPTIONS_PER_PRINCIPAL {
store
.save(stored(1_i64, &format!("https://push.example.com/d{i}")))
.await
.expect("save");
}
store
.save(stored(2_i64, "https://push.example.com/other-user"))
.await
.expect("one user at their cap must not block everyone else");
}
use crate::tenancy::with_tenant;
fn in_tenant<F: std::future::Future>(
tenant: &str,
fut: F,
) -> impl std::future::Future<Output = F::Output> {
with_tenant(tenant.to_owned(), fut)
}
#[tokio::test]
async fn the_same_principal_in_two_tenants_has_separate_subscriptions() {
let store = MemoryPushSubscriptionStore::new();
in_tenant(
"a",
store.save(stored(42_i64, "https://push.example.com/a")),
)
.await
.expect("save");
in_tenant(
"b",
store.save(stored(42_i64, "https://push.example.com/b")),
)
.await
.expect("save");
let a = in_tenant("a", store.list_for("42")).await.expect("list");
let b = in_tenant("b", store.list_for("42")).await.expect("list");
assert_eq!(a.len(), 1);
assert_eq!(a[0].endpoint(), "https://push.example.com/a");
assert_eq!(b.len(), 1);
assert_eq!(b[0].endpoint(), "https://push.example.com/b");
assert!(store.list_for("42").await.expect("list").is_empty());
}
#[tokio::test]
async fn another_tenant_cannot_take_an_endpoint_with_one_key() {
let store = MemoryPushSubscriptionStore::new();
let url = "https://push.example.com/shared";
in_tenant("a", store.save(stored(42_i64, url)))
.await
.expect("save");
let mut hostile = browser_subscription(url);
hostile.keys.p256dh = OTHER_P256DH.to_owned();
let hostile = hostile.decode(&42_i64.into()).expect("valid");
let err = in_tenant("b", store.save(hostile))
.await
.expect_err("same principal id, other tenant: still a cross-owner move");
assert!(matches!(err, PushError::EndpointClaimed), "{err:?}");
}
#[tokio::test]
async fn another_tenant_can_move_an_endpoint_with_both_keys() {
let store = MemoryPushSubscriptionStore::new();
let url = "https://push.example.com/shared";
in_tenant("a", store.save(stored(42_i64, url)))
.await
.expect("save");
in_tenant("b", store.save(stored(42_i64, url)))
.await
.expect("same device, same keys: the row moves");
assert!(
in_tenant("a", store.list_for("42"))
.await
.expect("list")
.is_empty()
);
assert_eq!(
in_tenant("b", store.list_for("42"))
.await
.expect("list")
.len(),
1
);
}
#[tokio::test]
async fn remove_is_scoped_to_the_tenant() {
let store = MemoryPushSubscriptionStore::new();
let url = "https://push.example.com/a";
in_tenant("a", store.save(stored(42_i64, url)))
.await
.expect("save");
let removed = in_tenant("b", store.remove(url, Some("42")))
.await
.expect("remove");
assert_eq!(removed, 0);
assert_eq!(
in_tenant("a", store.list_for("42"))
.await
.expect("list")
.len(),
1
);
}
#[tokio::test]
async fn the_cap_counts_per_tenant() {
let store = MemoryPushSubscriptionStore::new();
for i in 0..MAX_SUBSCRIPTIONS_PER_PRINCIPAL {
in_tenant(
"a",
store.save(stored(42_i64, &format!("https://push.example.com/a{i}"))),
)
.await
.expect("under the cap");
}
in_tenant(
"b",
store.save(stored(42_i64, "https://push.example.com/b")),
)
.await
.expect("tenant b has its own count");
}
}