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: 1 addition & 2 deletions cli/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -429,8 +429,7 @@ mod tests {
let loaded = storage.load_rooms().unwrap();
let owner_key_str = bs58::encode(owner_vk.as_bytes()).into_string();
assert_eq!(
loaded.rooms[&owner_key_str].previous_contract_key,
None,
loaded.rooms[&owner_key_str].previous_contract_key, None,
"previous_contract_key should be None when WASM hasn't changed"
);
}
Expand Down
100 changes: 100 additions & 0 deletions common/tests/import_merge_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//! Regression test for #195: importing an identity creates a default state with
//! owner_member_id: FastHash(0). Merging the real network state fails because
//! apply_delta rejects owner_member_id changes. The fix: replace the state
//! wholesale when is_awaiting_initial_sync() is true instead of merging.

use ed25519_dalek::SigningKey;
use freenet_scaffold::util::FastHash;
use freenet_scaffold::ComposableState;
use rand::rngs::OsRng;
use river_core::room_state::configuration::{AuthorizedConfigurationV1, Configuration};
use river_core::room_state::member::{AuthorizedMember, Member, MemberId};
use river_core::room_state::ChatRoomParametersV1;
use river_core::room_state::ChatRoomStateV1;

#[test]
fn test_default_state_has_placeholder_owner() {
let default_state = ChatRoomStateV1::default();
assert_eq!(
default_state.configuration.configuration.owner_member_id,
MemberId(FastHash(0)),
"Default state should have placeholder owner_member_id"
);
}

#[test]
fn test_merge_fails_when_owner_member_id_differs() {
let owner_sk = SigningKey::generate(&mut OsRng);
let owner_vk = owner_sk.verifying_key();
let params = ChatRoomParametersV1 { owner: owner_vk };

// Simulate import: start with default state (placeholder owner)
let mut local_state = ChatRoomStateV1::default();
let current = local_state.clone();

// Build network state with real owner and config version > 1
let config = Configuration {
owner_member_id: owner_vk.into(),
configuration_version: 2,
..Default::default()
};
let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
let network_state = ChatRoomStateV1 {
configuration: auth_config,
..Default::default()
};

// Merge should fail because owner_member_id differs
let result = local_state.merge(&current, &params, &network_state);
assert!(
result.is_err(),
"Merge should fail due to owner_member_id mismatch"
);
assert!(
result.unwrap_err().contains("owner_member_id"),
"Error should mention owner_member_id"
);
}

#[test]
fn test_wholesale_replacement_works_for_imported_rooms() {
let owner_sk = SigningKey::generate(&mut OsRng);
let owner_vk = owner_sk.verifying_key();
let invitee_sk = SigningKey::generate(&mut OsRng);

// Build network state with proper owner, config, and a member
let config = Configuration {
owner_member_id: owner_vk.into(),
configuration_version: 2,
..Default::default()
};
let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
let member = Member {
owner_member_id: owner_vk.into(),
invited_by: owner_vk.into(),
member_vk: invitee_sk.verifying_key(),
};
let auth_member = AuthorizedMember::new(member, &owner_sk);
let mut network_state = ChatRoomStateV1 {
configuration: auth_config,
..Default::default()
};
network_state.members.members.push(auth_member);

// Start with default (import) state
let mut local_state = ChatRoomStateV1::default();
assert!(local_state.members.members.is_empty());
assert_eq!(
local_state.configuration.configuration.owner_member_id,
MemberId(FastHash(0)),
);

// Wholesale replacement (the fix for #195)
local_state = network_state;

assert_eq!(local_state.members.members.len(), 1);
assert_eq!(
local_state.configuration.configuration.owner_member_id,
MemberId::from(owner_vk),
);
}
68 changes: 39 additions & 29 deletions ui/src/components/app/freenet_api/response_handler/get_response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -522,37 +522,47 @@ pub async fn handle_get_response(
crate::util::defer(move || {
ROOMS.with_mut(|rooms| {
if let Some(room_data) = rooms.map.get_mut(&owner_vk) {
// Create parameters for merge
let params = ChatRoomParametersV1 { owner: owner_vk };

// Clone current state to avoid borrow issues during merge
let current_state = room_data.room_state.clone();

// Merge the retrieved state into the existing state
match room_data
.room_state
.merge(&current_state, &params, &retrieved_state)
{
Ok(_) => {
info!(
"Successfully merged refreshed state for room {:?}",
MemberId::from(owner_vk)
);
// Note: we intentionally do NOT record receive times here.
// GET responses don't reflect real-time message arrival —
// we don't know when these messages actually propagated
// to our node. Only subscription UPDATE notifications
// capture the true arrival moment.

// Migration: capture self membership data for old rooms
room_data.capture_self_membership_data(&params);
}
Err(e) => {
error!(
"Failed to merge refreshed state for room {:?}: {}",
MemberId::from(owner_vk),
e
);
// Note: we intentionally do NOT record receive times here.
// GET responses don't reflect real-time message arrival —
// we don't know when these messages actually propagated
// to our node. Only subscription UPDATE notifications
// capture the true arrival moment.

if room_data.is_awaiting_initial_sync() {
// Imported rooms have a placeholder default state with
// owner_member_id: FastHash(0). Merging fails because
// the retrieved state has the real owner's member ID
// and apply_delta rejects owner_member_id changes.
// Replace the state wholesale — the default has no
// useful data to preserve.
info!(
"Replacing placeholder state for imported room {:?} with network state",
MemberId::from(owner_vk)
);
room_data.room_state = retrieved_state;
room_data.capture_self_membership_data(&params);
} else {
let current_state = room_data.room_state.clone();
match room_data
.room_state
.merge(&current_state, &params, &retrieved_state)
{
Ok(_) => {
info!(
"Successfully merged refreshed state for room {:?}",
MemberId::from(owner_vk)
);
room_data.capture_self_membership_data(&params);
}
Err(e) => {
error!(
"Failed to merge refreshed state for room {:?}: {}",
MemberId::from(owner_vk),
e
);
}
}
}
}
Expand Down
Loading