Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ All notable changes to this project will be documented in this file.

### Breaking

- CLI
- `doublezero feed update` (with `--group`) and `doublezero feed delete` now fail closed if the change would leave an EdgeSeat user holding a multicast group role that no consumed feed seat on their access pass covers and the pass's own allowlists don't authorize, printing the affected users, groups, and roles and submitting nothing. Pass `--force-unsubscribe` to remove those roles first and then apply the change; a group where one role must go while the allowlist still authorizes the other fails closed even with the flag and needs manual cleanup. A rename or an update without `--group` is unaffected; an additive `--group` set scans but finds nothing to do. The removals need `USER_ADMIN` (or foundation membership) on the payer in addition to `FEED_AUTHORITY`. (malbeclabs/infra#2113, #4119)

### Changes

- Serviceability
Expand Down
123 changes: 118 additions & 5 deletions smartcontract/cli/src/feed/delete.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::{
doublezerocommand::CliCommand, helpers::parse_or_resolve_exchange,
validators::validate_pubkey_or_code,
doublezerocommand::CliCommand, feed::guard::unsubscribe_orphans,
helpers::parse_or_resolve_exchange, validators::validate_pubkey_or_code,
};
use clap::Args;
use doublezero_cli_core::{print_signature, require, CliContext, RequirementCheck};
Expand All @@ -15,6 +15,11 @@ pub struct DeleteFeedCliCommand {
/// Metro (exchange) pubkey or code to disambiguate a code that exists in multiple metros
#[arg(long, value_parser = validate_pubkey_or_code)]
pub exchange: Option<String>,
/// Unsubscribe EdgeSeat users from the deleted feed's groups, instead of refusing the delete.
/// Without it, a delete that would leave a user subscribed to a group outside their access
/// pass's feeds fails and changes nothing.
#[arg(long, default_value_t = false)]
pub force_unsubscribe: bool,
}

impl DeleteFeedCliCommand {
Expand All @@ -34,29 +39,123 @@ impl DeleteFeedCliCommand {
.as_deref()
.map(|e| parse_or_resolve_exchange(client, e))
.transpose()?;
let (pubkey, _feed) = client.get_feed(GetFeedCommand {
let (pubkey, feed) = client.get_feed(GetFeedCommand {
pubkey_or_code: self.pubkey,
exchange,
})?;

// Deleting the feed drops every group it carried, so the post-change set is empty. The
// guard always runs — it re-derives the dropped set from its own fresh scan, so a group
// added after the `get_feed` read above is still caught.
unsubscribe_orphans(
client,
out,
&pubkey,
&feed.code,
&[],
self.force_unsubscribe,
)?;

let signature = client.delete_feed(DeleteFeedCommand { pubkey })?;
print_signature(out, &signature)
}
}

#[cfg(test)]
mod tests {
use crate::{feed::delete::DeleteFeedCliCommand, tests::utils::create_test_client};
use crate::{
feed::{delete::DeleteFeedCliCommand, guard::fixtures::GuardFixture},
tests::utils::create_test_client,
};
use doublezero_cli_core::testing::{block_on, cli_context_default_for_tests};
use doublezero_sdk::{
commands::{
exchange::get::GetExchangeCommand,
feed::{delete::DeleteFeedCommand, get::GetFeedCommand},
multicastgroup::subscribe::UpdateMulticastGroupRolesCommand,
},
AccountType, Exchange, ExchangeStatus, Feed,
};
use mockall::predicate;
use solana_sdk::{pubkey::Pubkey, signature::Signature};
use std::collections::HashMap;

#[test]
fn test_cli_feed_delete_fails_closed_when_it_would_orphan_a_subscriber() {
let mut client = create_test_client();
client.expect_check_requirements().returning(|_| Ok(()));

let f = GuardFixture::new(1);
let group = f.groups[0];
f.expect_get_feed(&mut client, vec![group]);
f.expect_scan(&mut client, vec![group]);
client.expect_delete_feed().times(0);
client.expect_update_multicastgroup_roles().times(0);

let ctx = cli_context_default_for_tests();
let mut output = Vec::new();
let res = block_on(
DeleteFeedCliCommand {
pubkey: f.feed_pk.to_string(),
exchange: None,
force_unsubscribe: false,
}
.execute(&ctx, &client, &mut output),
);
let err = res.unwrap_err();
assert!(
err.to_string().contains("--force-unsubscribe"),
"unexpected error: {err}"
);
}

#[test]
fn test_cli_feed_delete_force_unsubscribes_then_deletes() {
let mut client = create_test_client();
client.expect_check_requirements().returning(|_| Ok(()));

let f = GuardFixture::new(1);
let group = f.groups[0];
let signature = Signature::new_unique();
f.expect_get_feed(&mut client, vec![group]);
f.expect_scan(&mut client, vec![group]);
// The mock does not mutate state, so the post-unsubscribe re-scan needs its own snapshot
// with the membership gone.
f.expect_scan(&mut client, vec![]);
client
.expect_update_multicastgroup_roles()
.with(predicate::eq(UpdateMulticastGroupRolesCommand {
user_pk: f.user_pk,
group_pk: group,
client_ip: f.client_ip,
publisher: false,
subscriber: false,
device_pk: None,
feed_pk: None,
}))
.times(1)
.returning(|_| Ok(Signature::new_unique()));
client
.expect_delete_feed()
.with(predicate::eq(DeleteFeedCommand { pubkey: f.feed_pk }))
.times(1)
.returning(move |_| Ok(signature));

let ctx = cli_context_default_for_tests();
let mut output = Vec::new();
let res = block_on(
DeleteFeedCliCommand {
pubkey: f.feed_pk.to_string(),
exchange: None,
force_unsubscribe: true,
}
.execute(&ctx, &client, &mut output),
);
assert!(res.is_ok(), "{res:?}");
assert!(String::from_utf8(output)
.unwrap()
.contains(&format!("Signature: {signature}")));
}

#[test]
fn test_cli_feed_delete_with_exchange_code() {
Expand Down Expand Up @@ -100,14 +199,27 @@ mod tests {
exchange: exchange_pk,
groups: vec![],
};
let feed_for_get = feed.clone();
client
.expect_get_feed()
.with(predicate::eq(GetFeedCommand {
pubkey_or_code: "feed01".to_string(),
exchange: Some(exchange_pk),
}))
.times(1)
.returning(move |_| Ok((feed_pk, feed.clone())));
.returning(move |_| Ok((feed_pk, feed_for_get.clone())));

// Delete always runs the guard; with no users the scan finds nothing to drop.
client.expect_list_user().returning(|_| Ok(HashMap::new()));
client
.expect_list_accesspass()
.returning(|_| Ok(HashMap::new()));
client
.expect_list_device()
.returning(|_| Ok(HashMap::new()));
client
.expect_list_feed()
.returning(move |_| Ok(HashMap::from([(feed_pk, feed.clone())])));

client
.expect_delete_feed()
Expand All @@ -121,6 +233,7 @@ mod tests {
DeleteFeedCliCommand {
pubkey: "feed01".to_string(),
exchange: Some("xchi".to_string()),
force_unsubscribe: false,
}
.execute(&ctx, &client, &mut output),
);
Expand Down
Loading
Loading