From bc3abba83f869b1c0e8e7c6c590213db055fb6fe Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 05:54:15 +0000 Subject: [PATCH] Scope link-sharing settings to the window's team (P6) Adopts #15443's TeamScope/team_workspace_settings pattern (already landed on master), replacing this PR's own now-superseded scope plumbing. - Moves is_anyone_with_link_sharing_enabled and is_direct_link_sharing_enabled from the ambient UserWorkspaces impl into team_workspace_settings.rs, taking a TeamScope the way team_byo_for_scope does. - A teamless scope reads the workspace's own settings only when the user belongs to no team; on exactly one team it reads that team directly; on several teams (or an unresolvable named team) it denies rather than read an arbitrarily-elected team's settings. No workspace at all still permits, matching the getters' pre-scoping behavior. - is_invite_link_enabled and is_discoverable stay ambient/workspace-scoped, per TeamSettings' existing doc comment. - SharingDialog resolves its window's TeamContext via UserWorkspaces::team_context and re-checks the policy before send_invitations and SetLinkPermissions grants, not just at render. - update_session_sharing_enablement is left unscoped: it reads workspace-level billing tier data and sets a global FeatureFlag from data-refresh callbacks with no window/view context, so a per-window TeamScope does not apply to it. --- app/src/drive/sharing/dialog/mod.rs | 56 ++- app/src/drive/sharing/dialog/mod_tests.rs | 338 +++++++++++++++++- app/src/workspaces/user_workspaces/mod.rs | 22 -- .../team_workspace_settings.rs | 67 +++- .../user_workspaces/user_workspaces_tests.rs | 238 +++++++++++- 5 files changed, 689 insertions(+), 32 deletions(-) diff --git a/app/src/drive/sharing/dialog/mod.rs b/app/src/drive/sharing/dialog/mod.rs index 6c74a8a881c..ac76c862c6b 100644 --- a/app/src/drive/sharing/dialog/mod.rs +++ b/app/src/drive/sharing/dialog/mod.rs @@ -60,7 +60,7 @@ use crate::word_block_editor::{ WordBlockStyles, }; use crate::workspace::{ToastStack, WorkspaceAction}; -use crate::workspaces::user_workspaces::UserWorkspaces; +use crate::workspaces::user_workspaces::{TeamContext, UserWorkspaces, UserWorkspacesEvent}; use crate::{TelemetryEvent, send_telemetry_from_ctx}; mod inheritance; @@ -262,6 +262,10 @@ impl SharingDialog { }, ); + ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, _, event, ctx| { + me.handle_user_workspaces_event(event, ctx); + }); + let invite_form = EmailInviteForm { email_editor: ctx.add_typed_action_view(|ctx| { let mut view = WordBlockEditorView::new( @@ -302,6 +306,26 @@ impl SharingDialog { } } + /// The link-sharing controls are drawn from this window's team policy, so a team change has + /// to redraw: the invite form and the link-sharing row appear and disappear with it, and a + /// control left on screen under the old team's policy is one the action guards now refuse. + fn handle_user_workspaces_event( + &mut self, + event: &UserWorkspacesEvent, + ctx: &mut ViewContext, + ) { + let changes_this_windows_policy = match event { + UserWorkspacesEvent::TeamsChanged => true, + UserWorkspacesEvent::WindowTeamChanged { window_id } => *window_id == ctx.window_id(), + // Everything else this model emits is workspace administration, which does not + // change which team this window shares as. + _ => false, + }; + if changes_this_windows_policy { + ctx.notify(); + } + } + fn handle_update_manager_event( &mut self, event: &UpdateManagerEvent, @@ -475,12 +499,20 @@ impl SharingDialog { self.target.is_some() && self.access_level(app).can_edit_access() } + /// The team this dialog's window is sharing as, resolved afresh on every read so a window + /// that moves to another team is never governed by the team it opened with. + fn team_scope<'a>(&self, app: &'a AppContext) -> TeamContext<'a> { + UserWorkspaces::as_ref(app).team_context(&self.self_handle, app) + } + + /// Whether this window's team permits sharing the target with anyone who holds its link. fn can_anyone_with_link_share(&self, app: &AppContext) -> bool { - UserWorkspaces::as_ref(app).is_anyone_with_link_sharing_enabled() + UserWorkspaces::as_ref(app).is_anyone_with_link_sharing_enabled(&self.team_scope(app)) } + /// [`Self::can_anyone_with_link_share`] for sharing directly with named people. fn can_direct_link_share(&self, app: &AppContext) -> bool { - UserWorkspaces::as_ref(app).is_direct_link_sharing_enabled() + UserWorkspaces::as_ref(app).is_direct_link_sharing_enabled(&self.team_scope(app)) } /// The editability state of the object. @@ -1615,6 +1647,15 @@ impl SharingDialog { /// Send all pending email invitations. fn send_invitations(&mut self, ctx: &mut ViewContext) { + // Re-read the policy instead of trusting the render that put the form on screen: an + // open dialog whose window moved to a team that forbids direct sharing must not send + // the invitations that team now disallows. Redraw on the way out so the form the user + // just submitted disappears rather than sitting there as a dead control. + if !self.can_direct_link_share(ctx) { + ctx.notify(); + return; + } + let form_state = self.invite_form_state(ctx); if !form_state.is_valid() { return; @@ -2930,6 +2971,15 @@ impl TypedActionView for SharingDialog { } SharingDialogAction::SetLinkPermissions(access_level) => { self.set_open_menu(OpenMenuState::None, ctx); + // The menu's items were built when it opened. Re-read the policy so a window + // that has since moved to a team forbidding link sharing cannot act on the + // permission it was offered under the old one. Only granting is blocked: + // `None` revokes link access, and a user tightening an over-shared object must + // not be turned away by the very policy that wants it tightened. + if access_level.is_some() && !self.can_anyone_with_link_share(ctx) { + ctx.notify(); + return; + } if let Some(ShareableObject::WarpDriveObject(id)) = self.target.as_ref() { UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| { update_manager.set_object_link_permissions(*id, *access_level, ctx); diff --git a/app/src/drive/sharing/dialog/mod_tests.rs b/app/src/drive/sharing/dialog/mod_tests.rs index 4d30ce4ff68..ad6a5bddcf6 100644 --- a/app/src/drive/sharing/dialog/mod_tests.rs +++ b/app/src/drive/sharing/dialog/mod_tests.rs @@ -1,14 +1,29 @@ use chrono::Local; use session_sharing_protocol::common::SessionId; -use warpui::{App, SingletonEntity, ViewHandle}; +use warpui::{App, SingletonEntity, TypedActionView, ViewHandle}; -use super::{SharingDialog, SharingDialogMode}; -use crate::drive::sharing::ShareableObject; +use super::{SharingDialog, SharingDialogAction, SharingDialogMode}; +use crate::auth::UserUid; +use crate::cloud_object::Owner; +use crate::cloud_object::model::persistence::CloudModel; +use crate::cloud_object::model::view::CloudViewModel; +use crate::drive::sharing::{ShareableObject, SharingAccessLevel}; +use crate::server::ids::{ClientId, ServerId, SyncId}; use crate::terminal::TerminalView; use crate::terminal::shared_session::manager::Manager; use crate::terminal::shared_session::{SharedSessionSource, SharedSessionStatus}; use crate::test_util::add_window_with_terminal; -use crate::test_util::terminal::initialize_app_for_terminal_view; +use crate::test_util::terminal::{ + add_window_with_id_and_terminal, initialize_app_for_terminal_view, +}; +use crate::workflows::workflow::Workflow; +use crate::workflows::{CloudWorkflow, CloudWorkflowModel}; +use crate::workspaces::team::{Team, TeamVisibility}; +use crate::workspaces::user_profiles::UserProfiles; +use crate::workspaces::user_workspaces::UserWorkspaces; +use crate::workspaces::workspace::{ + EnforceableSetting, TeamLinkSharingSettings, TeamSettings, Workspace, +}; fn set_shared_session_status( terminal: &ViewHandle, @@ -157,3 +172,318 @@ fn session_qr_code_requires_status_eligible_matching_session_id() { assert_session_link_state(&terminal, &dialog, Some(second_session_id), &app); }); } + +/// A team whose admins either permit or forbid both link-sharing channels. +fn team_with_link_sharing(uid: i64, name: &str, permitted: bool) -> Team { + let permission = EnforceableSetting { + value: permitted, + is_enforced_by_workspace: false, + }; + Team { + uid: uid.into(), + name: name.to_string(), + color: None, + invite_link: None, + members: vec![], + pending_email_invites: vec![], + invite_link_domain_restrictions: vec![], + billing_metadata: Default::default(), + stripe_customer_id: None, + settings: TeamSettings { + link_sharing: TeamLinkSharingSettings { + anyone_with_link_sharing_enabled: permission.clone(), + direct_link_sharing_enabled: permission, + }, + ..Default::default() + }, + is_eligible_for_discovery: false, + has_billing_history: false, + visibility: TeamVisibility::Open, + } +} + +/// Replaces the user's workspaces with one holding `teams` and selects it, the way a +/// workspaces-metadata refresh does. Windows already assigned to a team that `teams` no longer +/// contains reconcile onto the first remaining one. +fn install_workspace_with_teams(app: &mut App, teams: Vec) { + let workspace = Workspace { + uid: "workspace_uid123456789".to_string().into(), + name: "test".to_string(), + stripe_customer_id: None, + teams, + billing_metadata: Default::default(), + bonus_grants_purchased_this_month: Default::default(), + billing_cycle_usage: None, + has_billing_history: false, + settings: Default::default(), + invite_link_domain_restrictions: vec![], + pending_email_invites: vec![], + is_eligible_for_discovery: false, + members: vec![], + total_requests_used_since_last_refresh: 0, + }; + let workspace_uid = workspace.uid; + + let user_workspaces = UserWorkspaces::handle(&*app); + user_workspaces.update(app, |user_workspaces, ctx| { + user_workspaces.update_workspaces(vec![workspace], ctx); + user_workspaces.set_current_workspace_uid(workspace_uid, ctx); + }); +} + +#[test] +fn link_sharing_gates_resolve_each_windows_own_team() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + + let (permitted_window, _permitted_terminal) = + add_window_with_id_and_terminal(&mut app, None); + let (forbidden_window, _forbidden_terminal) = + add_window_with_id_and_terminal(&mut app, None); + + let permitted_team = team_with_link_sharing(123, "permits-sharing", true); + let forbidden_team = team_with_link_sharing(456, "forbids-sharing", false); + install_workspace_with_teams( + &mut app, + vec![permitted_team.clone(), forbidden_team.clone()], + ); + + let user_workspaces = UserWorkspaces::handle(&app); + user_workspaces.update(&mut app, |user_workspaces, ctx| { + user_workspaces.set_team_for_window(permitted_window, permitted_team.uid, ctx); + user_workspaces.set_team_for_window(forbidden_window, forbidden_team.uid, ctx); + }); + + let permitted_dialog = + app.add_typed_action_view(permitted_window, |ctx| SharingDialog::new(None, ctx)); + let forbidden_dialog = + app.add_typed_action_view(forbidden_window, |ctx| SharingDialog::new(None, ctx)); + + permitted_dialog.read(&app, |dialog, ctx| { + assert!(dialog.can_anyone_with_link_share(ctx)); + assert!(dialog.can_direct_link_share(ctx)); + }); + forbidden_dialog.read(&app, |dialog, ctx| { + assert!( + !dialog.can_anyone_with_link_share(ctx), + "a dialog in a window on a forbidding team must not inherit the other window's \ + permission" + ); + assert!(!dialog.can_direct_link_share(ctx)); + }); + }); +} + +/// The dialog re-reads these gates when it renders and again before it acts, in +/// `send_invitations` and in the `SetLinkPermissions` handler. Resolving them from the window +/// rather than caching them at open is what stops an already-open dialog from sharing under +/// the policy it opened with after its window has moved to a team that forbids sharing. +#[test] +fn link_sharing_gates_follow_a_window_onto_its_new_team() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + + let (window_id, _terminal) = add_window_with_id_and_terminal(&mut app, None); + + let permitted_team = team_with_link_sharing(123, "permits-sharing", true); + let forbidden_team = team_with_link_sharing(456, "forbids-sharing", false); + install_workspace_with_teams( + &mut app, + vec![permitted_team.clone(), forbidden_team.clone()], + ); + + let user_workspaces = UserWorkspaces::handle(&app); + user_workspaces.update(&mut app, |user_workspaces, ctx| { + user_workspaces.set_team_for_window(window_id, permitted_team.uid, ctx); + }); + + let dialog = app.add_typed_action_view(window_id, |ctx| SharingDialog::new(None, ctx)); + dialog.read(&app, |dialog, ctx| { + assert!(dialog.can_anyone_with_link_share(ctx)); + assert!(dialog.can_direct_link_share(ctx)); + }); + + // The permitting team leaves the workspace, so the window reconciles onto the + // forbidding one while the dialog is still open. + install_workspace_with_teams(&mut app, vec![forbidden_team]); + + dialog.read(&app, |dialog, ctx| { + assert!( + !dialog.can_anyone_with_link_share(ctx), + "an open dialog must lose link sharing when its window moves to a team that \ + forbids it" + ); + assert!(!dialog.can_direct_link_share(ctx)); + }); + }); +} + +/// A window `UserWorkspaces` never explicitly registered still resolves a scope with no team, +/// per `TeamScope`'s contract, so it falls back the same way a registered teamless window's +/// scope would: to the workspace's sole team when there is exactly one. +#[test] +fn link_sharing_gates_follow_the_sole_team_for_an_unregistered_window() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + + let (window_id, _terminal) = add_window_with_id_and_terminal(&mut app, None); + install_workspace_with_teams(&mut app, vec![team_with_link_sharing(123, "team", true)]); + + // Deliberately never registered: `add_window_with_id_and_terminal` roots the window in + // a `TerminalView`, so nothing calls `UserWorkspaces::register_window` for it. + let dialog = app.add_typed_action_view(window_id, |ctx| SharingDialog::new(None, ctx)); + + dialog.read(&app, |dialog, ctx| { + assert!(dialog.can_anyone_with_link_share(ctx)); + assert!(dialog.can_direct_link_share(ctx)); + }); + }); +} + +/// With several teams a teamless window has no unambiguous policy to inherit, so the dialog +/// must deny rather than adopt whichever team the server happened to elect for the workspace. +#[test] +fn link_sharing_gates_deny_an_unregistered_window_with_several_teams() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + + let (window_id, _terminal) = add_window_with_id_and_terminal(&mut app, None); + install_workspace_with_teams( + &mut app, + vec![ + team_with_link_sharing(123, "permits-sharing", true), + team_with_link_sharing(456, "forbids-sharing", false), + ], + ); + + let dialog = app.add_typed_action_view(window_id, |ctx| SharingDialog::new(None, ctx)); + + dialog.read(&app, |dialog, ctx| { + assert!( + !dialog.can_anyone_with_link_share(ctx), + "a window on no team must not inherit either team's policy when the user is on \ + several" + ); + assert!(!dialog.can_direct_link_share(ctx)); + }); + }); +} + +/// A dialog targeting a Warp Drive object renders its owner and reads its access level, which +/// [`initialize_app_for_terminal_view`] alone does not provide models for. +fn initialize_app_for_drive_object_dialog(app: &mut App) { + initialize_app_for_terminal_view(app); + app.add_singleton_model(CloudViewModel::mock); + app.add_singleton_model(|_| UserProfiles::new(Vec::new())); +} + +/// Puts a Warp Drive object in the cloud model under a server id, so the sharing dialog can +/// target it and `UpdateManager` can find it. +fn add_shareable_object(app: &mut App) -> ServerId { + let object_uid: ServerId = 789.into(); + let mut object = CloudWorkflow::new_local( + CloudWorkflowModel { + data: Workflow::new("shared workflow", "echo shared"), + }, + Owner::User { + user_uid: UserUid::new("owner"), + }, + None, + ClientId::default(), + ); + object.id = SyncId::ServerId(object_uid); + + let cloud_model = CloudModel::handle(&*app); + cloud_model.update(app, |cloud_model, _| { + cloud_model.add_object(object.id, object); + }); + object_uid +} + +/// Whether a permissions change reached the object. `UpdateManager` marks the object +/// synchronously, before it issues any request, so a dispatch the dialog refused leaves this +/// clear. +fn permissions_change_reached_object(app: &App, object_uid: ServerId) -> bool { + app.read(|ctx| { + CloudModel::as_ref(ctx) + .get_by_uid(&object_uid.uid()) + .expect("the targeted object should be in the cloud model") + .metadata() + .pending_changes_statuses + .has_pending_permissions_change + }) +} + +fn set_link_permissions( + dialog: &ViewHandle, + access_level: Option, + app: &mut App, +) { + dialog.update(app, |dialog, ctx| { + dialog.handle_action(&SharingDialogAction::SetLinkPermissions(access_level), ctx); + }); +} + +/// The link-sharing menu builds its items once, when it opens, so acting on one has to re-read +/// the policy. Deleting the guard in the `SetLinkPermissions` handler must fail this test. +#[test] +fn set_link_permissions_refuses_to_grant_under_a_forbidding_team() { + App::test((), |mut app| async move { + initialize_app_for_drive_object_dialog(&mut app); + + let (window_id, _terminal) = add_window_with_id_and_terminal(&mut app, None); + let forbidden_team = team_with_link_sharing(456, "forbids-sharing", false); + install_workspace_with_teams(&mut app, vec![forbidden_team.clone()]); + + let user_workspaces = UserWorkspaces::handle(&app); + user_workspaces.update(&mut app, |user_workspaces, ctx| { + user_workspaces.set_team_for_window(window_id, forbidden_team.uid, ctx); + }); + + let object_uid = add_shareable_object(&mut app); + let dialog = app.add_typed_action_view(window_id, |ctx| { + SharingDialog::new(Some(ShareableObject::WarpDriveObject(object_uid)), ctx) + }); + + set_link_permissions(&dialog, Some(SharingAccessLevel::View), &mut app); + assert!( + !permissions_change_reached_object(&app, object_uid), + "granting link access under a team that forbids it must not reach the object" + ); + + // Revoking is how a user tightens an over-shared object, so the guard must let it + // through: the forbidding policy is the reason to allow this, not to block it. + set_link_permissions(&dialog, None, &mut app); + assert!( + permissions_change_reached_object(&app, object_uid), + "revoking link access must go through even under a team that forbids granting it" + ); + }); +} + +#[test] +fn set_link_permissions_grants_under_a_permitting_team() { + App::test((), |mut app| async move { + initialize_app_for_drive_object_dialog(&mut app); + + let (window_id, _terminal) = add_window_with_id_and_terminal(&mut app, None); + let permitted_team = team_with_link_sharing(123, "permits-sharing", true); + install_workspace_with_teams(&mut app, vec![permitted_team.clone()]); + + let user_workspaces = UserWorkspaces::handle(&app); + user_workspaces.update(&mut app, |user_workspaces, ctx| { + user_workspaces.set_team_for_window(window_id, permitted_team.uid, ctx); + }); + + let object_uid = add_shareable_object(&mut app); + let dialog = app.add_typed_action_view(window_id, |ctx| { + SharingDialog::new(Some(ShareableObject::WarpDriveObject(object_uid)), ctx) + }); + + set_link_permissions(&dialog, Some(SharingAccessLevel::View), &mut app); + assert!( + permissions_change_reached_object(&app, object_uid), + "the guard must let a grant through when the window's team permits link sharing" + ); + }); +} diff --git a/app/src/workspaces/user_workspaces/mod.rs b/app/src/workspaces/user_workspaces/mod.rs index 8415485f5af..6df08c47425 100644 --- a/app/src/workspaces/user_workspaces/mod.rs +++ b/app/src/workspaces/user_workspaces/mod.rs @@ -1599,28 +1599,6 @@ impl UserWorkspaces { .unwrap_or_default() } - pub fn is_anyone_with_link_sharing_enabled(&self) -> bool { - self.current_workspace() - .map(|workspace| { - workspace - .settings - .link_sharing_settings - .anyone_with_link_sharing_enabled - }) - .unwrap_or(true) - } - - pub fn is_direct_link_sharing_enabled(&self) -> bool { - self.current_workspace() - .map(|workspace| { - workspace - .settings - .link_sharing_settings - .direct_link_sharing_enabled - }) - .unwrap_or(true) - } - /// Whether invite links are enabled for the current workspace. This is a /// workspace-level setting; the teams-settings page reads it from here rather /// than from the `Team` struct. diff --git a/app/src/workspaces/user_workspaces/team_workspace_settings.rs b/app/src/workspaces/user_workspaces/team_workspace_settings.rs index 4dd6f6f130b..66ec6412479 100644 --- a/app/src/workspaces/user_workspaces/team_workspace_settings.rs +++ b/app/src/workspaces/user_workspaces/team_workspace_settings.rs @@ -20,7 +20,7 @@ use super::{SoleTeamError, UserWorkspaces}; use crate::ai::llms::{LLMId, LLMProvider}; use crate::server::ids::ServerId; use crate::workspaces::team::Team; -use crate::workspaces::workspace::{AiAutonomySettings, TeamByoSettings}; +use crate::workspaces::workspace::{AiAutonomySettings, LinkSharingSettings, TeamByoSettings}; /// The team an operation is scoped to, captured once from the window that started it. /// @@ -365,6 +365,71 @@ impl UserWorkspaces { } } + /// The link-sharing policy that governs `scope`, when the answer is unambiguous. + /// + /// A scope that names a team reads that team's own policy, and only that team's: an + /// unresolvable team yields `None`, never another team's policy -- mirrors + /// [`Self::team_byo_for_scope`]. + /// + /// A scope with no team falls back on the current workspace, but only where that has an + /// unambiguous answer: `workspace.settings` when the user is on no team there, and their + /// own team's policy when they are on exactly one. On several teams there is nothing to + /// fall back to -- `workspace.settings` would be an arbitrarily elected one of them, see + /// [`TeamScope`] -- so this returns `None` too. + fn link_sharing_settings_for_scope( + &self, + scope: &S, + ) -> Option { + fn from_team(team: &Team) -> LinkSharingSettings { + LinkSharingSettings { + anyone_with_link_sharing_enabled: team + .settings + .link_sharing + .anyone_with_link_sharing_enabled + .value, + direct_link_sharing_enabled: team + .settings + .link_sharing + .direct_link_sharing_enabled + .value, + } + } + match scope.team_uid() { + Some(_) => self.team_from_scope(scope).map(from_team), + None => { + let workspace = self.current_workspace()?; + match workspace.teams.as_slice() { + [] => Some(workspace.settings.link_sharing_settings.clone()), + [team] => Some(from_team(team)), + _ => None, + } + } + } + } + + /// Whether `scope` may share an object with anyone who holds its link. + /// + /// With no workspace at all there is no team policy to enforce, so this permits sharing -- + /// the behaviour this getter had before it took a scope. An ambiguous or unresolvable team + /// scope denies instead of guessing at a policy. + pub(crate) fn is_anyone_with_link_sharing_enabled( + &self, + scope: &S, + ) -> bool { + self.current_workspace().is_none() + || self + .link_sharing_settings_for_scope(scope) + .is_some_and(|settings| settings.anyone_with_link_sharing_enabled) + } + + /// [`Self::is_anyone_with_link_sharing_enabled`] for sharing directly with named people. + pub(crate) fn is_direct_link_sharing_enabled(&self, scope: &S) -> bool { + self.current_workspace().is_none() + || self + .link_sharing_settings_for_scope(scope) + .is_some_and(|settings| settings.direct_link_sharing_enabled) + } + /// The AI autonomy policy that applies to `scope`'s team. pub(crate) fn ai_autonomy_settings( &self, diff --git a/app/src/workspaces/user_workspaces/user_workspaces_tests.rs b/app/src/workspaces/user_workspaces/user_workspaces_tests.rs index 7453939cf30..65e4e9050a5 100644 --- a/app/src/workspaces/user_workspaces/user_workspaces_tests.rs +++ b/app/src/workspaces/user_workspaces/user_workspaces_tests.rs @@ -76,8 +76,9 @@ use crate::workspaces::update_manager::TeamUpdateManager; use crate::workspaces::user_workspaces::UserWorkspaces; use crate::workspaces::workspace::{ AdminEnablementSetting, ByoFirstPartyKey, EnforceableSetting, HostEnablementSetting, - LlmHostSettings, ManagedByokByoePolicy, MultiAdminPolicy, PurchaseAddOnCreditsPolicy, - SplitListSetting, TeamByoSettings, Workspace, + LinkSharingSettings, LlmHostSettings, ManagedByokByoePolicy, MultiAdminPolicy, + PurchaseAddOnCreditsPolicy, SplitListSetting, TeamByoSettings, TeamLinkSharingSettings, + Workspace, }; #[derive(Default)] @@ -2142,6 +2143,239 @@ fn member_byo_policy_follows_a_window_reconciled_onto_another_team() { }) } +/// Two teams whose admins take opposite positions on link sharing: `team_a` permits both +/// channels, `team_b` forbids both. +fn two_teams_with_opposing_link_sharing_policy() -> (Team, Team) { + fn link_sharing_settings(permitted: bool) -> TeamLinkSharingSettings { + let setting = EnforceableSetting { + value: permitted, + is_enforced_by_workspace: false, + }; + TeamLinkSharingSettings { + anyone_with_link_sharing_enabled: setting.clone(), + direct_link_sharing_enabled: setting, + } + } + let (mut team_a, mut team_b) = two_teams(); + team_a.settings.link_sharing = link_sharing_settings(true); + team_b.settings.link_sharing = link_sharing_settings(false); + (team_a, team_b) +} + +#[test] +fn link_sharing_follows_each_windows_own_team() { + let (team_a, team_b) = two_teams_with_opposing_link_sharing_policy(); + let mut workspace = workspace_for_test(&team_a); + workspace.teams.push(team_b.clone()); + + App::test((), |mut app| async move { + initialize_window_team_test_app(&mut app, vec![workspace]); + + let (window_a, _view_a) = create_test_window(&mut app); + let (window_b, _view_b) = create_test_window(&mut app); + UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| { + user_workspaces.set_team_for_window(window_a, team_a.uid, ctx); + user_workspaces.set_team_for_window(window_b, team_b.uid, ctx); + }); + + app.read(|ctx| { + let user_workspaces = UserWorkspaces::as_ref(ctx); + let scope_a = user_workspaces.team_context_for_window_for_test(window_a); + let scope_b = user_workspaces.team_context_for_window_for_test(window_b); + + assert!(user_workspaces.is_anyone_with_link_sharing_enabled(&scope_a)); + assert!(user_workspaces.is_direct_link_sharing_enabled(&scope_a)); + assert!( + !user_workspaces.is_anyone_with_link_sharing_enabled(&scope_b), + "the window on the forbidding team should not allow anyone-with-link sharing" + ); + assert!( + !user_workspaces.is_direct_link_sharing_enabled(&scope_b), + "the window on the forbidding team should not allow direct link sharing" + ); + }); + }) +} + +/// A scope with no team reads the workspace's `link_sharing_settings`, which is the intended +/// answer for a teamless user and for a window with no team selected. It must be a real read, +/// not a permissive constant: a workspace policy that forbids sharing has to bind. +fn assert_teamless_window_reads_workspace_link_sharing_policy(permitted: bool) { + let (_team_a, team_b) = two_teams_with_opposing_link_sharing_policy(); + let mut workspace = workspace_for_test(&team_b); + // Reconciliation assigns a teamless window to the workspace's first team, so the window + // can only stay teamless while the workspace itself has no teams to fall back to. + workspace.teams.clear(); + workspace.settings.link_sharing_settings = LinkSharingSettings { + anyone_with_link_sharing_enabled: permitted, + direct_link_sharing_enabled: permitted, + }; + + App::test((), |mut app| async move { + initialize_window_team_test_app(&mut app, vec![workspace]); + + let (window_id, _view) = create_test_window(&mut app); + UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| { + user_workspaces.register_window(window_id, None, ctx); + }); + + app.read(|ctx| { + let user_workspaces = UserWorkspaces::as_ref(ctx); + let scope = user_workspaces.team_context_for_window_for_test(window_id); + assert_eq!(scope.team_uid(), None); + assert_eq!( + user_workspaces.is_anyone_with_link_sharing_enabled(&scope), + permitted, + "a teamless window must read the workspace's link-sharing policy" + ); + assert_eq!( + user_workspaces.is_direct_link_sharing_enabled(&scope), + permitted, + "a teamless window must read the workspace's link-sharing policy" + ); + }); + }) +} + +#[test] +fn link_sharing_for_a_window_with_no_team_follows_a_permissive_workspace() { + assert_teamless_window_reads_workspace_link_sharing_policy(true); +} + +/// The half that a hardcoded permissive answer would have got wrong. +#[test] +fn link_sharing_for_a_window_with_no_team_follows_a_restrictive_workspace() { + assert_teamless_window_reads_workspace_link_sharing_policy(false); +} + +/// The fallback a single-team user needs. Their window has no team selected, but they are on +/// exactly one team, so that team's policy is the unambiguous answer. +/// +/// The workspace's own `link_sharing_settings` is left at its permissive default while the sole +/// team forbids sharing, so both assertions fail if this reads the ambient value instead of the +/// team. +#[test] +fn link_sharing_for_a_teamless_window_reads_a_sole_team() { + let (_team_a, team_b) = two_teams_with_opposing_link_sharing_policy(); + let mut workspace = workspace_for_test(&team_b); + workspace.settings.link_sharing_settings = LinkSharingSettings { + anyone_with_link_sharing_enabled: true, + direct_link_sharing_enabled: true, + }; + + App::test((), |mut app| async move { + initialize_window_team_test_app(&mut app, vec![workspace]); + + let (window_id, _view) = create_test_window(&mut app); + UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| { + user_workspaces.register_window(window_id, None, ctx); + }); + + app.read(|ctx| { + let user_workspaces = UserWorkspaces::as_ref(ctx); + let scope = user_workspaces.team_context_for_window_for_test(window_id); + assert_eq!(scope.team_uid(), None); + assert!( + !user_workspaces.is_anyone_with_link_sharing_enabled(&scope), + "the sole team forbids sharing, so the teamless window must too" + ); + assert!( + !user_workspaces.is_direct_link_sharing_enabled(&scope), + "the sole team forbids sharing, so the teamless window must too" + ); + }); + }) +} + +/// A user on several teams has no unambiguous fallback for a teamless scope: the workspace's +/// `link_sharing_settings` is whichever one of their teams the server elected, so reading it +/// would hand this window another team's policy. +#[test] +fn link_sharing_denies_a_multi_team_users_teamless_window() { + let (team_a, team_b) = two_teams_with_opposing_link_sharing_policy(); + let mut workspace = workspace_for_test(&team_b); + workspace.teams.push(team_a.clone()); + // Permissive on purpose: if the teamless scope fell through to this ambient value, both + // assertions below would flip. + workspace.settings.link_sharing_settings = LinkSharingSettings { + anyone_with_link_sharing_enabled: true, + direct_link_sharing_enabled: true, + }; + + App::test((), |mut app| async move { + initialize_window_team_test_app(&mut app, vec![workspace]); + + let (window_id, _view) = create_test_window(&mut app); + UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| { + user_workspaces.register_window(window_id, None, ctx); + }); + + app.read(|ctx| { + let user_workspaces = UserWorkspaces::as_ref(ctx); + assert!(user_workspaces.can_switch_teams()); + let scope = user_workspaces.team_context_for_window_for_test(window_id); + assert_eq!(scope.team_uid(), None); + assert!( + !user_workspaces.is_anyone_with_link_sharing_enabled(&scope), + "a multi-team user's teamless window must not inherit any team's link-sharing \ + policy" + ); + assert!( + !user_workspaces.is_direct_link_sharing_enabled(&scope), + "a multi-team user's teamless window must not inherit any team's link-sharing \ + policy" + ); + }); + }) +} + +/// Guards the shape of the getters rather than a reachable user scenario: a scope that names +/// an unresolvable team must deny, not fall through to the no-team branch. +#[test] +fn link_sharing_denies_a_scope_naming_an_unresolvable_team() { + let (team_a, _team_b) = two_teams_with_opposing_link_sharing_policy(); + let workspace = workspace_for_test(&team_a); + + App::test((), |mut app| async move { + initialize_window_team_test_app(&mut app, vec![workspace]); + + let unresolvable_team_scope = TeamContextForOperation::new_for_test(9999.into()); + app.read(|ctx| { + let user_workspaces = UserWorkspaces::as_ref(ctx); + assert!( + !user_workspaces.is_anyone_with_link_sharing_enabled(&unresolvable_team_scope), + "a team whose policy cannot be read must not inherit another team's" + ); + assert!( + !user_workspaces.is_direct_link_sharing_enabled(&unresolvable_team_scope), + "a team whose policy cannot be read must not inherit another team's" + ); + }); + }) +} + +/// With no workspace at all there is no team policy to enforce, so both channels stay open -- +/// the behaviour these getters had before they took a scope. +#[test] +fn link_sharing_permits_everything_with_no_workspace_at_all() { + App::test((), |mut app| async move { + initialize_window_team_test_app(&mut app, vec![]); + + let (window_id, _view) = create_test_window(&mut app); + UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| { + user_workspaces.register_window(window_id, None, ctx); + }); + + app.read(|ctx| { + let user_workspaces = UserWorkspaces::as_ref(ctx); + let scope = user_workspaces.team_context_for_window_for_test(window_id); + assert_eq!(scope.team_uid(), None); + assert!(user_workspaces.is_anyone_with_link_sharing_enabled(&scope)); + assert!(user_workspaces.is_direct_link_sharing_enabled(&scope)); + }); + }) +} + #[test] fn test_spaces_for_window_orders_selected_team_shared_and_personal() { let _flag = FeatureFlag::SharedWithMe.override_enabled(true);