mirror of
https://github.com/block/buzz.git
synced 2026-11-15 13:51:12 +00:00
🤖 ## Summary Sharing a Solo chat with a large channel today means adding every person as a member, one at a time, and the app blocks the share past 100 people. The plan is to share it the way Google Docs does with "anyone at the company with the link". Buzz's `open` visibility already gives that kind of access: anyone in the community can read and join an open channel. The problem is that an open channel also shows up in everyone's channel list and search, and a private chat shouldn't appear there just because its link was posted somewhere. This PR adds a channel setting, `unlisted`. An **unlisted open channel** can be read and joined by anyone who names it by id (from a shared link). For non-members, it is left out of every read that does not name it. Members see it as before. This is the relay side only; the buzz-app change comes later. #8575 (batch adds) remains the path for small audiences. Unlisted is not an access control. Anyone who has the id can read the channel, and that is intended. ## Solo flows this supports Each user action is one event. The buzz-app side comes later; the relay supports all of these now. | Flow | Event | Result | | --- | --- | --- | | New Solo chat | kind:9007 `visibility private` + `["unlisted", "true"]` | The chat starts unlisted, so it stays unlisted when any client later opens it. | | Share | One kind:9002: `visibility open` + `unlisted true` + `join_role guest\|member`. Then post the link. | Anyone with the link can read the chat by id, both history and live. Nobody is added as a member, so the chat isn't in anyone's channel list. A non-member can't post; `join_role` decides whether someone who joins can. | | View | A REQ that names the chat by `#h`, or by `#d` for its metadata | A non-member can read it. It is never listed for them. | | Reply | kind:9021 join (the app sends it before a viewer's first reply) | Joining makes them a member with the role from `join_role`, so with `member` they can post. A kind:9022 leave makes them a viewer again; the chat stays readable by id. | | Unshare | kind:9002 `visibility private` | The existing open→private eviction closes non-members' live subscriptions. New reads by non-members are refused. | | Convert to a channel | One kind:9002: `["t", ""]` + `unlisted false`, plus `visibility` | Clears the Solo label. Non-members can now find it in discovery. | **Sidebar pins are not relay state.** Pin and unpin live in the app's existing kind:30078 sidebar preferences, which sync across devices and don't touch membership. Don't map them to join/leave: someone a chat was shared with by member add is already a member, so a leave would remove their access. Your own Solo chats keep showing up, because you're their owner member, and members see unlisted channels everywhere. Existing Solo chats aren't backfilled. They get the flag when they're shared, because the share event sets it. **Open question, not built here:** someone who only views a shared chat isn't a member. A mention of them doesn't reach their inbox (`#p:[me]` without `#h`) and sends them no push. ## Behavior | Reader | Reads that don't name the channel (kind:39000 without `#d`, `#P` creator queries, messages, COUNT, NIP-50 search, artifact queries, channel list) | Reads that name it (`#h`, or `#d` on 39000–39003) | kind:9021 join | | --- | --- | --- | --- | | Member | Listed | Readable | n/a | | Non-member, unlisted open channel | **Not listed** | Readable | Works; `join_role` still decides the role | | Non-member, private channel (unlisted or not) | Not listed | Not readable | Refused | - **Setting it:** kind:9002 `["unlisted", "true"]` / `["unlisted", "false"]`. Owner or admin only, like `visibility`. A missing tag means no change. It can be sent in the same event as `["visibility", "open"]`, so a private chat becomes open and unlisted in one step. Any other value is refused before the event is stored. The relay posts an `unlisted_changed` system message and clears the accessible-channels cache across nodes. Nobody loses access when a channel is unlisted, so no subscriptions are evicted. - **At creation:** kind:9007 also takes `["unlisted", "true"|"false"]`. A missing tag means listed. Any other value is refused before the event is stored, as on kind:9002. It is stored with the channel row in the create's transaction, on both the client-UUID path and the relay-chosen-UUID path. - **kind:39000** carries a bare `["unlisted"]` flag while the setting is on. It does not add NIP-29 `hidden`, so the metadata stays readable by id. - **NIP-11** advertises `buzz-unlisted-channels` in `supported_extensions`. A relay without this PR would apply `["visibility", "open"]` and ignore `unlisted`, which would list the chat publicly. So buzz-app must check for the extension before it sends that pair. - **CLI:** `buzz channels update --channel <id> --unlisted true|false` and `buzz channels create ... --unlisted`. `buzz_sdk::build_update_channel` takes a new `unlisted: Option<bool>` argument, and `buzz_sdk::build_create_channel` takes `unlisted: bool`, which emits the tag only when true. ### Open question: does `unlisted` only mean something when the channel is open? **Decision: store it on its own; it only has an effect while the channel is open.** Private channels are already invisible to non-members, so on a private channel the flag does nothing. It is still stored and published, for three reasons: - a client can mark a Solo chat unlisted before opening it, or in the same event that opens it, so the chat is never listed even for a moment; - switching private → open → private → open keeps the setting; - there is no cross-field rule in kind:9002 to validate or keep in sync. kind:39000 publishes the flag as stored, whatever the visibility, so clients can show the setting. ## How The relay already has a "readable only when named" path for system channels (NIP-CL). Unlisted open channels reuse it: - **`get_accessible_channel_ids`**: the open half (the implicit scope of a REQ that doesn't name a channel) adds `AND NOT unlisted`. The member half is unchanged, so members still have the channel. - **`get_readable_named_only_channels`**: also returns unlisted open channels of the legacy types. Every reader of that set (REQ, COUNT, NIP-50 search, HTTP `/query` and `/count`, thread windows, workflow run reads) adds a named-only channel only to a filter that names it, through `filter_channel_scope`. An unlisted channel's type is `stream`/`forum`, so the `#t:["system"]`/`#P` system opt-in never adds it. - **`get_accessible_channels`** (the channel list): open channels are listed to non-members only when not unlisted. - **`query_artifacts`**: an artifact in an unlisted open channel reaches a non-member only when the query's `#h` names that channel. - **Live fan-out:** no change was needed. A global subscription never receives channel-scoped events, kind:39000 included (`fan_out_scoped`). A channel-scoped subscription has to name the channel, and that goes through the same named-only authorization. **Left alone on purpose:** access checks that read a named channel or event keep treating an unlisted channel as open. These are `handle_join_request` (kind:9021), the ingest write gate, operator-listener mention delivery (`load_deliverable_event`), personal read-state writes, and push matching. Push matching still requires membership, so non-members get no pushes from an unlisted channel. **Named reads checked:** these callers use the implicit channel set (`get_accessible_channel_ids`, its cache, `get_accessible_channels`). Every one that reads a channel the request names also adds the named-only set, so a non-member can still read an unlisted open chat by id. That covers WS REQ (history, live registration, NIP-50 search), WS COUNT, HTTP `/query` and `/count`, thread windows and workflow run reads. Other named reads don't use the implicit set: they check visibility or membership, so an unlisted open chat counts as open. Those are live fan-out, the ingest write gate, typing, kind:9021 join, huddle audio join, operator-listener delivery, the workflow sink and read-state writes. Feed queries (mentions, needs-action, activity) and the `/buzz/v1/me/sidebar` accessory API are lists, so leaving out chats you only view is intended. Media reads don't check channels. `buzz-auth`'s `ChannelAccessChecker` has no production implementation. ## Database (docs/database.md checklist) - **Phase:** expand only. The migration adds a column with a constant default, and the same release ships the code that reads and writes it. No backfill or contract step follows. - No trigger, function, procedure, rule or foreign key is added. - No migration changes existing rows. `ADD COLUMN ... BOOLEAN NOT NULL DEFAULT FALSE` stores the constant default in the catalog, so the table is not rewritten. - Every location in Rule 4 is updated: `migrations/0069_channel_unlisted.sql`, `schema/tables/public/channels.sql`, and the migration count. No seeds, types or reconcile changes are needed. `admin_schema_parity_between_desired_state_and_migrations` now compares `channels` columns between the pgschema desired state and the migrated database. I failed it once by removing the column from `channels.sql`. Only columns are compared for `channels`: on current `main`, pgschema already re-emits `idx_channels_id_live` without its `INCLUDE (community_id)`, an index drift that predates this PR and is noted in the test. - **Currently deployed relay against the new schema:** it never selects or writes `unlisted`, and every insert gets the default. All channel-record queries name their columns. Nobody can set the flag until this relay ships, because the old relay refuses a kind:9002 whose only tag is `unlisted`. - The migration starts with `SET LOCAL lock_timeout = '5s'`. No index is added: the new predicates ride the existing `(community_id, visibility)` index. ## Rollout 1. Deploy. Every channel stays listed, so nothing changes. 2. **Don't unlist any channel while an old relay is still running.** An old relay ignores the column and lists the channel to everyone. The NIP-11 extension lets clients gate on this. Unlisted isn't an access boundary, so a mixed fleet only exposes the channel in listings for a while. 3. **Rollback:** an older relay ignores the column, so unlisted channels show up as ordinary open channels again. Nothing breaks. ## Tests Postgres (`buzz-db`, discovered by the Postgres lane): - `unlisted_open_channel_is_named_only_for_non_members`: sets the flag through `update_channel`. Then checks that a non-member doesn't get it from `get_accessible_channel_ids` or `get_accessible_channels`, and does get it from `get_readable_named_only_channels`; that a member still lists it; that `put_member` (the kind:9021 path) lets the non-member join, after which they list it; and that listing it again shows it to a new reader. - `unlisted_private_channel_stays_private_and_opens_unlisted`: a private unlisted channel is not readable by name, and opening it keeps the flag. - `group_metadata_publishes_the_stored_unlisted_flag`, plus the unit test `publishes_the_unlisted_flag_as_stored`. - `unlisted_channel_artifacts_are_read_by_name_for_non_members`. - `channel_created_unlisted_is_never_listed`: creating through `create_channel_with_event` (the kind:9007 path) and `create_labeled_channel` stores the flag, and the channel is never listed to a non-member. - Mutation-checked: each of `NOT unlisted` in the implicit scope, `NOT c.unlisted` in the channel list, the unlisted branch of the named-only query, its `visibility = 'open'` guard, and the artifact condition was removed one at a time, and each removal failed at least one of these tests. Relay E2E (`test_nip29_unlisted_channel_wire`, added to the CI relay E2E step), against a local relay built from this branch: - A non-member or an unknown value is refused. - kind:39000 by `#d` carries `unlisted` and `public`, and not `hidden`. - kind:39000 discovery without `#d`: non-members don't see the channel, members do. - History and NIP-50 search: unnamed, a non-member doesn't see the channel's messages; with `#h`, they do; a member sees them unnamed. - A kind:9021 join works, and the joiner then lists the channel. - Listing it again shows it to everyone. - A reader whose channel set was cached before each change sees the change at once. I ran this against a relay with the cache invalidation removed, and it failed. Relay E2E (`test_nip29_solo_share_and_convert_wire`, added to the CI relay E2E step), each flow as a single event: - kind:9007 with an unknown `unlisted` value is refused. A chat created with `unlisted true` publishes the flag, and stays unlisted after a plain `visibility open`. - Share (open + unlisted + `join_role guest` in one kind:9002): a non-member doesn't list the chat. They read it by `#h`, both history and a live message, and can't post. A kind:9021 join lists it for them as a read-only guest. A kind:9022 leave unlists it again, and it stays readable by `#h`. - Unshare (`visibility private`): the viewer's live subscription gets CLOSED, and a new `#h` read is refused. - Convert (`["t", ""]` + `unlisted false`): the Solo label is cleared, and a non-member finds the chat in discovery. - I ran this against a relay that dropped the kind:9007 flag, and it failed. Also run: `cargo test` for buzz-db, buzz-sdk, buzz-cli, buzz-core, buzz-relay and buzz-test-client; the Postgres lane for buzz-db and buzz-relay (1045 tests); and the existing unlisted, announce, departure, guest, role-change and NIP-43 snapshot wire tests. In the lane, two tests failed on pool timeouts (`resend_validated_after_the_first_commit_is_a_duplicate`, `edit_replaces_and_clears_labels`) and passed when rerun alone. An earlier run had the same kind of flake: `routed_fallback_spends_one_acquire_budget_when_aurora_cache_is_cold` (a timing budget) and `resent_edit_is_a_duplicate` (a pool timeout). In a full local `e2e_relay` run, three tests outside CI's list also failed (`test_invite_*` twice, `test_unarchive_emits_member_added_notification`). They fail the same way on a relay built from `main`. ## Not in this PR - The buzz-app follow-up: support open Solo chats, the join-from-card view, the large-audience prompt, and gating on `buzz-unlisted-channels`. - A NIP-CL update. The behavior is documented in NOSTR.md ("Unlisted channels"). buzz-review-completed --------- Signed-off-by: Bradley Axen <baxen@squareup.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: ART <ea39c1c8acf57180161ef4ba4ed68abf8589f5291956b909a5aa991fc66664ca@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz> Co-authored-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
701 lines
25 KiB
Rust
701 lines
25 KiB
Rust
//! PostgreSQL regression tests for the production artifact transaction/query seams.
|
|
use super::artifact::ArtifactOutcome;
|
|
use crate::Db;
|
|
use buzz_core::{artifact, CommunityId};
|
|
use nostr::{Event, EventBuilder, Keys, Kind, Tag};
|
|
use sqlx::PgPool;
|
|
use uuid::Uuid;
|
|
|
|
struct Fixture {
|
|
db: Db,
|
|
community: CommunityId,
|
|
a: Uuid,
|
|
b: Uuid,
|
|
owner: Keys,
|
|
peer: Keys,
|
|
relay: Keys,
|
|
}
|
|
impl Fixture {
|
|
async fn new() -> Self {
|
|
let pool = PgPool::connect(&crate::test_support::database_url())
|
|
.await
|
|
.unwrap();
|
|
if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") {
|
|
crate::migration::run_migrations(&pool).await.unwrap();
|
|
}
|
|
Self::on(Db::from_pool(pool)).await
|
|
}
|
|
/// The fixture on `db`, which is already migrated.
|
|
async fn on(db: Db) -> Self {
|
|
db.maintain_partitions(
|
|
1,
|
|
crate::partition::PartitionMaintenancePolicy {
|
|
create_enabled: true,
|
|
advance_enabled: false,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let community = CommunityId::from_uuid(Uuid::new_v4());
|
|
sqlx::query("INSERT INTO communities(id,host) VALUES($1,$2)")
|
|
.bind(community.as_uuid())
|
|
.bind(format!("artifact-{}.test", community.as_uuid()))
|
|
.execute(&db.pool)
|
|
.await
|
|
.unwrap();
|
|
let owner = Keys::generate();
|
|
let peer = Keys::generate();
|
|
let a = Uuid::new_v4();
|
|
let b = Uuid::new_v4();
|
|
for (id, visibility) in [(a, "open"), (b, "private")] {
|
|
sqlx::query("INSERT INTO channels(community_id,id,name,visibility,channel_type,created_by) VALUES($1,$2,$3,$4::channel_visibility,'stream',$5)")
|
|
.bind(community.as_uuid()).bind(id).bind(id.to_string()).bind(visibility).bind(owner.public_key().to_bytes().as_slice()).execute(&db.pool).await.unwrap();
|
|
sqlx::query("INSERT INTO channel_members(community_id,channel_id,pubkey,role) VALUES($1,$2,$3,'owner')")
|
|
.bind(community.as_uuid()).bind(id).bind(owner.public_key().to_bytes().as_slice()).execute(&db.pool).await.unwrap();
|
|
}
|
|
Self {
|
|
db,
|
|
community,
|
|
a,
|
|
b,
|
|
owner,
|
|
peer,
|
|
relay: Keys::generate(),
|
|
}
|
|
}
|
|
fn revision(
|
|
&self,
|
|
d: Uuid,
|
|
op: &str,
|
|
home: Uuid,
|
|
prev: Option<&Event>,
|
|
key: &Keys,
|
|
extra: Vec<Vec<String>>,
|
|
) -> Event {
|
|
let mut tags = vec![
|
|
vec!["ar".into(), "1".into()],
|
|
vec!["d".into(), d.to_string()],
|
|
vec!["h".into(), home.to_string()],
|
|
vec!["type".into(), "buzz.task".into()],
|
|
vec!["op".into(), op.into()],
|
|
];
|
|
if op != "delete" {
|
|
tags.push(vec!["title".into(), "Test".into()]);
|
|
}
|
|
if let Some(e) = prev {
|
|
tags.push(vec!["prev".into(), e.id.to_hex()]);
|
|
}
|
|
tags.extend(extra);
|
|
EventBuilder::new(
|
|
Kind::Custom(45010),
|
|
if op == "delete" { "" } else { "secret" },
|
|
)
|
|
.tags(tags.into_iter().map(|t| Tag::parse(t).unwrap()))
|
|
.sign_with_keys(key)
|
|
.unwrap()
|
|
}
|
|
async fn accept(&self, e: &Event, source: Option<Uuid>) -> ArtifactOutcome {
|
|
let env = artifact::validate(e).unwrap();
|
|
self.db
|
|
.accept_artifact(self.community, e, &env, source, &self.relay)
|
|
.await
|
|
.unwrap()
|
|
}
|
|
async fn count(&self, key: &Keys, value: serde_json::Value) -> i64 {
|
|
self.query(key, value, true).await.1
|
|
}
|
|
async fn query(
|
|
&self,
|
|
key: &Keys,
|
|
value: serde_json::Value,
|
|
count: bool,
|
|
) -> (Vec<buzz_core::StoredEvent>, i64) {
|
|
self.db
|
|
.query_artifacts(
|
|
self.community,
|
|
key.public_key().as_bytes(),
|
|
&artifact::parse_query(&value).unwrap(),
|
|
count,
|
|
)
|
|
.await
|
|
.unwrap()
|
|
}
|
|
}
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn lifecycle_cas_queries_move_redaction_and_retention() {
|
|
let f = Fixture::new().await;
|
|
let d = Uuid::new_v4();
|
|
let project = |v: &str| vec![vec!["project".to_string(), v.to_string()]];
|
|
let create = f.revision(d, "create", f.a, None, &f.owner, project("A"));
|
|
assert!(matches!(
|
|
f.accept(&create, None).await,
|
|
ArtifactOutcome::Accepted(_)
|
|
));
|
|
let x = f.revision(d, "update", f.a, Some(&create), &f.owner, project("B"));
|
|
let y = f.revision(d, "update", f.a, Some(&create), &f.peer, project("B"));
|
|
let (rx, ry) = tokio::join!(f.accept(&x, None), f.accept(&y, None));
|
|
let head = match (rx, ry) {
|
|
(ArtifactOutcome::Accepted(_), ArtifactOutcome::Conflict(..)) => &x,
|
|
(ArtifactOutcome::Conflict(..), ArtifactOutcome::Accepted(_)) => &y,
|
|
other => panic!("exactly one CAS winner: {other:?}"),
|
|
};
|
|
assert!(matches!(
|
|
f.accept(&create, None).await,
|
|
ArtifactOutcome::Duplicate
|
|
));
|
|
let current = |p: &str| serde_json::json!({"artifact":"current","#d":[d],"#project":[p]});
|
|
assert_eq!(f.count(&f.owner, current("A")).await, 0);
|
|
assert_eq!(f.count(&f.owner, current("B")).await, 1);
|
|
// First value in the same tag, never an annotation or reverse-name match.
|
|
let wrong = f.revision(
|
|
d,
|
|
"update",
|
|
f.a,
|
|
Some(head),
|
|
&f.owner,
|
|
vec![
|
|
vec!["project".into(), "C".into(), "B".into()],
|
|
vec!["B".into(), "project".into()],
|
|
],
|
|
);
|
|
assert!(matches!(
|
|
f.accept(&wrong, None).await,
|
|
ArtifactOutcome::Accepted(_)
|
|
));
|
|
assert_eq!(f.count(&f.owner, current("B")).await, 0);
|
|
|
|
// A move commits only against the source the relay authorized.
|
|
let moved = f.revision(d, "move", f.b, Some(&wrong), &f.owner, vec![]);
|
|
assert!(matches!(
|
|
f.accept(&moved, Some(f.b)).await,
|
|
ArtifactOutcome::Conflict(..)
|
|
));
|
|
let ArtifactOutcome::Accepted(stored) = f.accept(&moved, Some(f.a)).await else {
|
|
panic!("move accepted");
|
|
};
|
|
let removal = &stored[1];
|
|
assert_eq!(removal.event.kind.as_u16(), 45011);
|
|
assert_eq!(removal.channel_id, Some(f.a));
|
|
assert!(!serde_json::to_string(&removal.event)
|
|
.unwrap()
|
|
.contains(&f.b.to_string()));
|
|
let all = serde_json::json!({"artifact":"current","#d":[d]});
|
|
let history = serde_json::json!({"artifact":"history","#d":[d]});
|
|
assert_eq!(f.count(&f.peer, all.clone()).await, 0);
|
|
assert_eq!(f.count(&f.peer, history.clone()).await, 3);
|
|
assert_eq!(f.count(&f.owner, history.clone()).await, 4);
|
|
|
|
// Expiring an earlier payload keeps its identity reserved.
|
|
let retention = |id: nostr::EventId| {
|
|
sqlx::query("DELETE FROM events WHERE community_id=$1 AND id=$2")
|
|
.bind(f.community.as_uuid())
|
|
.bind(id.as_bytes().to_vec())
|
|
};
|
|
assert_eq!(
|
|
retention(create.id)
|
|
.execute(&f.db.pool)
|
|
.await
|
|
.unwrap()
|
|
.rows_affected(),
|
|
1
|
|
);
|
|
assert!(matches!(
|
|
f.accept(&create, None).await,
|
|
ArtifactOutcome::Duplicate
|
|
));
|
|
|
|
// Redaction is the generic soft delete: the head stays, its payload is hidden
|
|
// everywhere and may then be purged.
|
|
assert!(f
|
|
.db
|
|
.soft_delete_event_and_update_thread(f.community, moved.id.as_bytes(), None, None)
|
|
.await
|
|
.unwrap());
|
|
assert_eq!(f.count(&f.owner, all.clone()).await, 0);
|
|
assert_eq!(f.count(&f.owner, history.clone()).await, 2);
|
|
assert_eq!(
|
|
retention(moved.id)
|
|
.execute(&f.db.pool)
|
|
.await
|
|
.unwrap()
|
|
.rows_affected(),
|
|
1
|
|
);
|
|
assert!(f
|
|
.db
|
|
.soft_delete_event_and_update_thread(f.community, removal.event.id.as_bytes(), None, None)
|
|
.await
|
|
.is_err());
|
|
|
|
let deleted = f.revision(d, "delete", f.b, Some(&moved), &f.owner, vec![]);
|
|
assert!(matches!(
|
|
f.accept(&deleted, None).await,
|
|
ArtifactOutcome::Accepted(_)
|
|
));
|
|
assert_eq!(f.count(&f.owner, all.clone()).await, 0);
|
|
let update = f.revision(d, "update", f.b, Some(&deleted), &f.owner, vec![]);
|
|
assert!(matches!(
|
|
f.accept(&update, None).await,
|
|
ArtifactOutcome::Rejected(_)
|
|
));
|
|
let restore = f.revision(d, "restore", f.b, Some(&deleted), &f.peer, vec![]);
|
|
assert!(matches!(
|
|
f.accept(&restore, None).await,
|
|
ArtifactOutcome::Accepted(_)
|
|
));
|
|
assert_eq!(f.count(&f.owner, all.clone()).await, 1);
|
|
// Reads observe removed private membership.
|
|
sqlx::query(
|
|
"UPDATE channel_members SET removed_at=now() WHERE community_id=$1 AND channel_id=$2",
|
|
)
|
|
.bind(f.community.as_uuid())
|
|
.bind(f.b)
|
|
.execute(&f.db.pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(f.count(&f.owner, all).await, 0);
|
|
f.db.validate_deletion_serving_catalog().await.unwrap();
|
|
}
|
|
|
|
/// An artifact in a system channel is read only by a query whose `#h` names
|
|
/// that channel (NIP-CL), even when the channel is open.
|
|
/// Mutation: drop the `channel_type` condition from `query_artifacts` → RED.
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn system_channel_artifacts_are_read_only_by_name() {
|
|
let f = Fixture::new().await;
|
|
let system = Uuid::new_v4();
|
|
sqlx::query("INSERT INTO channels(community_id,id,name,visibility,channel_type,created_by) VALUES($1,$2,$3,'open','system',$4)")
|
|
.bind(f.community.as_uuid())
|
|
.bind(system)
|
|
.bind(system.to_string())
|
|
.bind(f.owner.public_key().to_bytes().as_slice())
|
|
.execute(&f.db.pool)
|
|
.await
|
|
.unwrap();
|
|
for home in [f.a, system] {
|
|
let create = f.revision(Uuid::new_v4(), "create", home, None, &f.owner, vec![]);
|
|
assert!(matches!(
|
|
f.accept(&create, None).await,
|
|
ArtifactOutcome::Accepted(_)
|
|
));
|
|
}
|
|
for reader in [&f.owner, &f.peer] {
|
|
let (events, _) = f
|
|
.query(reader, serde_json::json!({"artifact":"current"}), false)
|
|
.await;
|
|
let homes: Vec<_> = events.iter().map(|e| e.channel_id).collect();
|
|
assert_eq!(homes, [Some(f.a)], "no #h");
|
|
assert_eq!(
|
|
f.count(reader, serde_json::json!({"artifact":"current"}))
|
|
.await,
|
|
1
|
|
);
|
|
let named = serde_json::json!({"artifact":"current","#h":[system.to_string()]});
|
|
let (events, _) = f.query(reader, named.clone(), false).await;
|
|
let homes: Vec<_> = events.iter().map(|e| e.channel_id).collect();
|
|
assert_eq!(homes, [Some(system)], "#h names the system channel");
|
|
assert_eq!(f.count(reader, named).await, 1);
|
|
}
|
|
}
|
|
|
|
/// An artifact in an unlisted open channel is read by a non-member only
|
|
/// through a query whose `#h` names that channel; a member reads it as usual.
|
|
/// Mutation: drop the unlisted condition from `query_artifacts` → RED.
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn unlisted_channel_artifacts_are_read_by_name_for_non_members() {
|
|
let f = Fixture::new().await;
|
|
let unlisted = Uuid::new_v4();
|
|
sqlx::query("INSERT INTO channels(community_id,id,name,visibility,channel_type,created_by,unlisted) VALUES($1,$2,$3,'open','stream',$4,TRUE)")
|
|
.bind(f.community.as_uuid())
|
|
.bind(unlisted)
|
|
.bind(unlisted.to_string())
|
|
.bind(f.owner.public_key().to_bytes().as_slice())
|
|
.execute(&f.db.pool)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query(
|
|
"INSERT INTO channel_members(community_id,channel_id,pubkey,role) VALUES($1,$2,$3,'owner')",
|
|
)
|
|
.bind(f.community.as_uuid())
|
|
.bind(unlisted)
|
|
.bind(f.owner.public_key().to_bytes().as_slice())
|
|
.execute(&f.db.pool)
|
|
.await
|
|
.unwrap();
|
|
for home in [f.a, unlisted] {
|
|
let create = f.revision(Uuid::new_v4(), "create", home, None, &f.owner, vec![]);
|
|
assert!(matches!(
|
|
f.accept(&create, None).await,
|
|
ArtifactOutcome::Accepted(_)
|
|
));
|
|
}
|
|
let unnamed = serde_json::json!({"artifact":"current"});
|
|
let named = serde_json::json!({"artifact":"current","#h":[unlisted.to_string()]});
|
|
let homes = |events: Vec<buzz_core::StoredEvent>| {
|
|
let mut homes: Vec<_> = events.iter().map(|e| e.channel_id).collect();
|
|
homes.sort();
|
|
homes
|
|
};
|
|
|
|
// The member reads both without naming either.
|
|
let (events, _) = f.query(&f.owner, unnamed.clone(), false).await;
|
|
let mut both = vec![Some(f.a), Some(unlisted)];
|
|
both.sort();
|
|
assert_eq!(homes(events), both, "member without #h");
|
|
|
|
// A non-member reads the unlisted one only by naming it.
|
|
let (events, _) = f.query(&f.peer, unnamed.clone(), false).await;
|
|
assert_eq!(homes(events), [Some(f.a)], "non-member without #h");
|
|
assert_eq!(f.count(&f.peer, unnamed).await, 1);
|
|
let (events, _) = f.query(&f.peer, named.clone(), false).await;
|
|
assert_eq!(homes(events), [Some(unlisted)], "non-member #h names it");
|
|
assert_eq!(f.count(&f.peer, named).await, 1);
|
|
}
|
|
|
|
/// An artifact in a channel of a type added later is read only by a query
|
|
/// whose `#h` names that channel, like a system channel (NIP-CL).
|
|
/// Mutation: put back `c.channel_type <> 'system'` in `query_artifacts` → RED.
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn artifacts_of_a_later_channel_type_are_read_only_by_name() {
|
|
use super::legacy_channel_types_postgres_tests::{scratch_db_with_future_type, FUTURE_TYPE};
|
|
let (db, admin, name) = scratch_db_with_future_type().await;
|
|
let f = Fixture::on(db).await;
|
|
let later = Uuid::new_v4();
|
|
sqlx::query("INSERT INTO channels(community_id,id,name,visibility,channel_type,created_by) VALUES($1,$2,$3,'open',$4::channel_type,$5)")
|
|
.bind(f.community.as_uuid())
|
|
.bind(later)
|
|
.bind(later.to_string())
|
|
.bind(FUTURE_TYPE)
|
|
.bind(f.owner.public_key().to_bytes().as_slice())
|
|
.execute(&f.db.pool)
|
|
.await
|
|
.unwrap();
|
|
let mut ds = Vec::new();
|
|
for home in [f.a, later] {
|
|
let d = Uuid::new_v4();
|
|
let create = f.revision(d, "create", home, None, &f.owner, vec![]);
|
|
assert!(matches!(
|
|
f.accept(&create, None).await,
|
|
ArtifactOutcome::Accepted(_)
|
|
));
|
|
let update = f.revision(d, "update", home, Some(&create), &f.owner, vec![]);
|
|
assert!(matches!(
|
|
f.accept(&update, None).await,
|
|
ArtifactOutcome::Accepted(_)
|
|
));
|
|
ds.push(d.to_string());
|
|
}
|
|
// History needs `#d`; both queries name both artifacts.
|
|
for (view, per_channel) in [("current", 1), ("history", 2)] {
|
|
let unnamed = serde_json::json!({ "artifact": view, "#d": ds });
|
|
let (events, _) = f.query(&f.owner, unnamed.clone(), false).await;
|
|
let homes: Vec<_> = events.iter().map(|e| e.channel_id).collect();
|
|
assert_eq!(homes, vec![Some(f.a); per_channel], "{view} without #h");
|
|
assert_eq!(f.count(&f.owner, unnamed).await, per_channel as i64);
|
|
|
|
let named = serde_json::json!({ "artifact": view, "#d": ds, "#h": [later.to_string()] });
|
|
let (events, _) = f.query(&f.owner, named.clone(), false).await;
|
|
let homes: Vec<_> = events.iter().map(|e| e.channel_id).collect();
|
|
assert_eq!(
|
|
homes,
|
|
vec![Some(later); per_channel],
|
|
"{view} #h names the channel"
|
|
);
|
|
assert_eq!(f.count(&f.owner, named).await, per_channel as i64);
|
|
}
|
|
|
|
f.db.pool.close().await;
|
|
let _ = sqlx::query(sqlx::AssertSqlSafe(format!(
|
|
"DROP DATABASE IF EXISTS {name} WITH (FORCE)"
|
|
)))
|
|
.execute(&admin)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn repeated_moves_get_distinct_source_safe_markers() {
|
|
let f = Fixture::new().await;
|
|
let d = Uuid::new_v4();
|
|
let create = f.revision(d, "create", f.a, None, &f.owner, vec![]);
|
|
let there = f.revision(d, "move", f.b, Some(&create), &f.owner, vec![]);
|
|
let back = f.revision(d, "move", f.a, Some(&there), &f.owner, vec![]);
|
|
let again = f.revision(d, "move", f.b, Some(&back), &f.owner, vec![]);
|
|
assert!(matches!(
|
|
f.accept(&create, None).await,
|
|
ArtifactOutcome::Accepted(_)
|
|
));
|
|
let mut markers = Vec::new();
|
|
for (event, source, replaced) in [
|
|
(&there, f.a, &create),
|
|
(&back, f.b, &there),
|
|
(&again, f.a, &back),
|
|
] {
|
|
let ArtifactOutcome::Accepted(stored) = f.accept(event, Some(source)).await else {
|
|
panic!("move accepted");
|
|
};
|
|
let marker = stored[1].event.clone();
|
|
// Only `prev` distinguishes same-second markers for one source.
|
|
let tags: Vec<_> = marker.tags.iter().map(|t| t.as_slice().to_vec()).collect();
|
|
assert_eq!(
|
|
tags,
|
|
[
|
|
vec!["ar".to_string(), "1".into()],
|
|
vec!["d".into(), d.to_string()],
|
|
vec!["h".into(), source.to_string()],
|
|
vec!["reason".into(), "moved".into()],
|
|
vec!["prev".into(), replaced.id.to_hex()],
|
|
]
|
|
);
|
|
markers.push(marker.id);
|
|
}
|
|
assert_ne!(markers[0], markers[2]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn root_anchor_and_tenant_boundaries() {
|
|
let f = Fixture::new().await;
|
|
let d = Uuid::new_v4();
|
|
let invalid = f.revision(
|
|
d,
|
|
"create",
|
|
f.a,
|
|
None,
|
|
&f.owner,
|
|
vec![vec!["root".into(), "a".repeat(64)]],
|
|
);
|
|
assert!(matches!(
|
|
f.accept(&invalid, None).await,
|
|
ArtifactOutcome::Rejected(_)
|
|
));
|
|
let anchor = EventBuilder::new(Kind::Custom(9), "anchor")
|
|
.tags([Tag::parse(["h", &f.a.to_string()]).unwrap()])
|
|
.sign_with_keys(&f.owner)
|
|
.unwrap();
|
|
f.db.insert_event(f.community, &anchor, Some(f.a))
|
|
.await
|
|
.unwrap();
|
|
let root = || vec![vec!["root".to_string(), anchor.id.to_hex()]];
|
|
let create = f.revision(d, "create", f.a, None, &f.owner, root());
|
|
assert!(matches!(
|
|
f.accept(&create, None).await,
|
|
ArtifactOutcome::Accepted(_)
|
|
));
|
|
// Later loss of the anchor does not block edits that keep it.
|
|
sqlx::query("UPDATE events SET deleted_at=now() WHERE community_id=$1 AND id=$2")
|
|
.bind(f.community.as_uuid())
|
|
.bind(anchor.id.as_bytes().as_slice())
|
|
.execute(&f.db.pool)
|
|
.await
|
|
.unwrap();
|
|
let update = f.revision(d, "update", f.a, Some(&create), &f.peer, root());
|
|
assert!(matches!(
|
|
f.accept(&update, None).await,
|
|
ArtifactOutcome::Accepted(_)
|
|
));
|
|
let bad_delete = f.revision(d, "delete", f.a, Some(&update), &f.owner, vec![]);
|
|
assert!(matches!(
|
|
f.accept(&bad_delete, None).await,
|
|
ArtifactOutcome::Rejected(_)
|
|
));
|
|
let other = Fixture::new().await;
|
|
assert!(!other
|
|
.db
|
|
.artifact_accepted(other.community, create.id.as_bytes())
|
|
.await
|
|
.unwrap());
|
|
let same_id = other.revision(d, "create", other.a, None, &other.owner, vec![]);
|
|
assert!(matches!(
|
|
other.accept(&same_id, None).await,
|
|
ArtifactOutcome::Accepted(_)
|
|
));
|
|
assert_eq!(
|
|
other
|
|
.count(
|
|
&other.owner,
|
|
serde_json::json!({"artifact":"history","#d":[d]})
|
|
)
|
|
.await,
|
|
1
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn concurrent_artifacts_on_ttl_channel_commit() {
|
|
let f = Fixture::new().await;
|
|
sqlx::query("UPDATE channels SET ttl_seconds=60 WHERE community_id=$1 AND id=$2")
|
|
.bind(f.community.as_uuid())
|
|
.bind(f.a)
|
|
.execute(&f.db.pool)
|
|
.await
|
|
.unwrap();
|
|
let a = f.revision(Uuid::new_v4(), "create", f.a, None, &f.owner, vec![]);
|
|
let b = f.revision(Uuid::new_v4(), "create", f.a, None, &f.peer, vec![]);
|
|
let (a, b) = tokio::join!(f.accept(&a, None), f.accept(&b, None));
|
|
assert!(matches!(a, ArtifactOutcome::Accepted(_)));
|
|
assert!(matches!(b, ArtifactOutcome::Accepted(_)));
|
|
let live: bool = sqlx::query_scalar(
|
|
"SELECT ttl_deadline>now() FROM channels WHERE community_id=$1 AND id=$2",
|
|
)
|
|
.bind(f.community.as_uuid())
|
|
.bind(f.a)
|
|
.fetch_one(&f.db.pool)
|
|
.await
|
|
.unwrap();
|
|
assert!(live);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn quiescing_community_rejects_artifact_at_admission_before_coordinate_lock() {
|
|
let f = Fixture::new().await;
|
|
let d = Uuid::new_v4();
|
|
let create = f.revision(d, "create", f.a, None, &f.owner, vec![]);
|
|
let env = artifact::validate(&create).unwrap();
|
|
crate::test_support::quiesce_community_for_tests(&f.db.pool, f.community).await;
|
|
|
|
// Hold the artifact coordinate lock. Admission must reject before
|
|
// `accept_artifact` reaches it, so the write cannot queue behind it.
|
|
let mut holder = f.db.pool.begin().await.unwrap();
|
|
sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1,0))")
|
|
.bind(format!("artifact:{}:{}", f.community.as_uuid(), env.id))
|
|
.execute(&mut *holder)
|
|
.await
|
|
.unwrap();
|
|
let error = tokio::time::timeout(
|
|
std::time::Duration::from_secs(5),
|
|
f.db.accept_artifact(f.community, &create, &env, None, &f.relay),
|
|
)
|
|
.await
|
|
.expect("admission must reject before waiting on the coordinate lock")
|
|
.expect_err("a quiescing community must reject artifact writes");
|
|
holder.rollback().await.unwrap();
|
|
assert!(
|
|
crate::test_support::is_admission_rejection(&error),
|
|
"expected entry admission rejection, got: {error:#}"
|
|
);
|
|
|
|
let persisted: (i64, i64) = sqlx::query_as(
|
|
"SELECT (SELECT count(*) FROM events WHERE community_id=$1 AND id=$2), \
|
|
(SELECT count(*) FROM artifact_heads WHERE community_id=$1 AND artifact_id=$3)",
|
|
)
|
|
.bind(f.community.as_uuid())
|
|
.bind(create.id.as_bytes().as_slice())
|
|
.bind(env.id)
|
|
.fetch_one(&f.db.pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
persisted,
|
|
(0, 0),
|
|
"a rejected artifact must persist nothing"
|
|
);
|
|
}
|
|
|
|
/// A non-create revision whose head lives outside the channel the caller
|
|
/// authorized answers exactly like a missing artifact, before the revision,
|
|
/// type, deleted and root checks could describe the head.
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn head_outside_authorized_channel_matches_missing_head() {
|
|
let f = Fixture::new().await;
|
|
let d = Uuid::new_v4();
|
|
let create = f.revision(d, "create", f.a, None, &f.owner, vec![]);
|
|
assert!(matches!(
|
|
f.accept(&create, None).await,
|
|
ArtifactOutcome::Accepted(_)
|
|
));
|
|
let gone = Uuid::new_v4();
|
|
let deleted_d = Uuid::new_v4();
|
|
let deleted_create = f.revision(deleted_d, "create", f.a, None, &f.owner, vec![]);
|
|
assert!(matches!(
|
|
f.accept(&deleted_create, None).await,
|
|
ArtifactOutcome::Accepted(_)
|
|
));
|
|
let deleted = f.revision(
|
|
deleted_d,
|
|
"delete",
|
|
f.a,
|
|
Some(&deleted_create),
|
|
&f.owner,
|
|
vec![],
|
|
);
|
|
assert!(matches!(
|
|
f.accept(&deleted, None).await,
|
|
ArtifactOutcome::Accepted(_)
|
|
));
|
|
let retyped = {
|
|
let mut tags: Vec<Vec<String>> = vec![
|
|
vec!["ar".into(), "1".into()],
|
|
vec!["d".into(), d.to_string()],
|
|
vec!["h".into(), f.b.to_string()],
|
|
vec!["type".into(), "buzz.note".into()],
|
|
vec!["op".into(), "update".into()],
|
|
vec!["title".into(), "Test".into()],
|
|
];
|
|
tags.push(vec!["prev".into(), create.id.to_hex()]);
|
|
EventBuilder::new(Kind::Custom(45010), "secret")
|
|
.tags(tags.into_iter().map(|t| Tag::parse(t).unwrap()))
|
|
.sign_with_keys(&f.peer)
|
|
.unwrap()
|
|
};
|
|
let cases = [
|
|
(
|
|
"missing",
|
|
f.revision(gone, "update", f.b, Some(&create), &f.peer, vec![]),
|
|
None,
|
|
),
|
|
(
|
|
"current prev",
|
|
f.revision(d, "update", f.b, Some(&create), &f.peer, vec![]),
|
|
None,
|
|
),
|
|
(
|
|
"stale prev",
|
|
f.revision(d, "update", f.b, Some(&deleted), &f.peer, vec![]),
|
|
None,
|
|
),
|
|
("other type", retyped, None),
|
|
(
|
|
"delete",
|
|
f.revision(d, "delete", f.b, Some(&create), &f.peer, vec![]),
|
|
None,
|
|
),
|
|
(
|
|
"restore deleted",
|
|
f.revision(deleted_d, "restore", f.b, Some(&deleted), &f.peer, vec![]),
|
|
None,
|
|
),
|
|
(
|
|
"update deleted",
|
|
f.revision(deleted_d, "update", f.b, Some(&deleted), &f.peer, vec![]),
|
|
None,
|
|
),
|
|
(
|
|
"move from unauthorized source",
|
|
f.revision(d, "move", f.b, Some(&create), &f.peer, vec![]),
|
|
Some(f.b),
|
|
),
|
|
];
|
|
for (case, event, source) in cases {
|
|
match f.accept(&event, source).await {
|
|
ArtifactOutcome::Conflict(reason) => {
|
|
assert_eq!(reason, super::artifact::HEAD_UNAVAILABLE, "{case}")
|
|
}
|
|
other => panic!("{case}: {other:?}"),
|
|
}
|
|
}
|
|
// Positive control: the same revision homed where the head lives.
|
|
let update = f.revision(d, "update", f.a, Some(&create), &f.peer, vec![]);
|
|
assert!(matches!(
|
|
f.accept(&update, None).await,
|
|
ArtifactOutcome::Accepted(_)
|
|
));
|
|
}
|