mirror of
https://github.com/narrrl/proton-sdk-rs.git
synced 2026-11-03 16:41:26 +01:00
implemented share membership permission + shared with me enumeration
This commit is contained in:
1 parent
5b38a85ccb
commit
d4bebaa2ac
6 files changed
+347
-32
No files matched your search
@@ -165,6 +165,7 @@ mod tests {
|
||||
is_shared: false,
|
||||
is_shared_publicly: false,
|
||||
signature_email: None,
|
||||
membership: None,
|
||||
verification: crate::node::NodeVerification::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,12 +62,12 @@ use crate::dtos::{
|
||||
NodeNameAvailabilityResponse, PhotosAttributesDto, RenameLinkRequest, RevisionConflict,
|
||||
RevisionCreationRequest, RevisionCreationResponse, RevisionDto, RevisionListItemDto,
|
||||
RevisionListResponse, RevisionMetadataResponse, RevisionResponse, RevisionUpdateRequest,
|
||||
ShareInvitationDto, ShareInvitationsResponse, ShareMembersResponse, ShareResponse,
|
||||
ShareTargetType, ShareUrlDto, ShareUrlsResponse, SharedByMeResponse, SharedWithMeResponse,
|
||||
SmallFileUploadMetadataRequest, SmallRevisionUploadMetadataRequest, SmallUploadResponse,
|
||||
ThumbnailBlockListRequest, ThumbnailBlockListResponse, ThumbnailCreationRequest, ThumbnailDto,
|
||||
TimelinePhotoListResponse, UpdatePermissionsRequest, VolumeCreationRequest, VolumeEventDto,
|
||||
VolumeEventListResponse, VolumeTrashResponse,
|
||||
ShareInvitationDto, ShareInvitationsResponse, ShareMembersResponse, ShareMembershipSummaryDto,
|
||||
ShareResponse, ShareTargetType, ShareUrlDto, ShareUrlsResponse, SharedByMeResponse,
|
||||
SharedWithMeResponse, SmallFileUploadMetadataRequest, SmallRevisionUploadMetadataRequest,
|
||||
SmallUploadResponse, ThumbnailBlockListRequest, ThumbnailBlockListResponse,
|
||||
ThumbnailCreationRequest, ThumbnailDto, TimelinePhotoListResponse, UpdatePermissionsRequest,
|
||||
VolumeCreationRequest, VolumeEventDto, VolumeEventListResponse, VolumeTrashResponse,
|
||||
};
|
||||
use crate::events::{DriveEvent, DriveEventScopeId};
|
||||
use crate::node::{FileThumbnail, Node, NodeKind, RevisionState, Thumbnail, ThumbnailType};
|
||||
@@ -77,7 +77,7 @@ use crate::revision::{
|
||||
};
|
||||
use crate::sharing::{
|
||||
Bookmark, ExternalInvitation, ExternalInvitationState, IncomingInvitation, MemberRole,
|
||||
PublicLink, ShareInvitation, ShareMember,
|
||||
PublicLink, ShareInvitation, ShareMember, ShareMembership, SharedWithMeItem,
|
||||
};
|
||||
use crate::single_flight::SingleFlight;
|
||||
|
||||
@@ -2883,9 +2883,29 @@ impl ProtonDriveClient {
|
||||
/// [`enumerate_nodes`](Self::enumerate_nodes).
|
||||
pub async fn enumerate_shared_with_me_node_uids(&self) -> Result<Vec<NodeUid>> {
|
||||
let mut timer = self.telemetry.start("enumerate_shared_with_me_node_uids");
|
||||
let uids = self.page_shared_with_me().await?;
|
||||
let items = self.page_shared_with_me().await?;
|
||||
timer.success();
|
||||
Ok(uids)
|
||||
Ok(items.into_iter().map(|item| item.uid).collect())
|
||||
}
|
||||
|
||||
/// The nodes shared *with* me, each with the share it was granted through.
|
||||
///
|
||||
/// Same listing as
|
||||
/// [`enumerate_shared_with_me_node_uids`](Self::enumerate_shared_with_me_node_uids),
|
||||
/// but keeping the `ShareID` the wire carries: a shared node is the root of
|
||||
/// the sharer's share on *their* volume, so it comes back parentless and
|
||||
/// only that share's key unlocks it. A caller that mounts shared content
|
||||
/// needs the share id to reason about it without re-deriving it from link
|
||||
/// details.
|
||||
///
|
||||
/// Order is the API's and is preserved. Materialize the uids with
|
||||
/// [`enumerate_nodes`](Self::enumerate_nodes) — the resulting
|
||||
/// [`Node::membership`](crate::Node::membership) carries our role.
|
||||
pub async fn enumerate_shared_with_me(&self) -> Result<Vec<SharedWithMeItem>> {
|
||||
let mut timer = self.telemetry.start("enumerate_shared_with_me");
|
||||
let items = self.page_shared_with_me().await?;
|
||||
timer.success();
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Page `v2/sharedwithme` in the order the API returns, recording each item's
|
||||
@@ -2894,8 +2914,8 @@ impl ProtonDriveClient {
|
||||
///
|
||||
/// Order is the API's and is preserved: it is what a front-end lists, and a
|
||||
/// set's iteration order would reshuffle the page on every refresh.
|
||||
async fn page_shared_with_me(&self) -> Result<Vec<NodeUid>> {
|
||||
let mut uids = Vec::new();
|
||||
async fn page_shared_with_me(&self) -> Result<Vec<SharedWithMeItem>> {
|
||||
let mut items = Vec::new();
|
||||
let mut shares = HashMap::new();
|
||||
let mut anchor: Option<String> = None;
|
||||
|
||||
@@ -2906,14 +2926,9 @@ impl ProtonDriveClient {
|
||||
};
|
||||
let page: SharedWithMeResponse = self.http.get(&path).await?;
|
||||
|
||||
for link in &page.links {
|
||||
let is_drive_item = ShareTargetType::from_raw(link.share_target_type)
|
||||
.is_some_and(ShareTargetType::is_drive_item);
|
||||
if is_drive_item {
|
||||
let uid = NodeUid::new(link.volume_id.clone(), link.link_id.clone());
|
||||
shares.insert(uid.clone(), link.share_id.clone());
|
||||
uids.push(uid);
|
||||
}
|
||||
for item in drive_items(&page) {
|
||||
shares.insert(item.uid.clone(), item.share_id.clone());
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
anchor = page.anchor_id.filter(|id| !id.is_empty());
|
||||
@@ -2923,7 +2938,7 @@ impl ProtonDriveClient {
|
||||
}
|
||||
|
||||
self.cache.lock().await.shared_with_me_shares = Some(shares);
|
||||
Ok(uids)
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// The membership share id behind each node shared with us, keyed by the
|
||||
@@ -5833,6 +5848,9 @@ impl ProtonDriveClient {
|
||||
.as_ref()
|
||||
.is_some_and(|sharing| sharing.share_url_id.is_some()),
|
||||
signature_email: link.signature_email.clone(),
|
||||
// Present only when the node is shared *with* us; it is what says
|
||||
// whether we may write to it.
|
||||
membership: details.membership.as_ref().map(share_membership_from_dto),
|
||||
verification,
|
||||
};
|
||||
|
||||
@@ -6496,22 +6514,55 @@ fn is_upload_timeout(error: &ProtonError) -> bool {
|
||||
matches!(error, ProtonError::Transport(e) if e.is_timeout())
|
||||
}
|
||||
|
||||
/// Copy the already-fetched wire membership into the public read model.
|
||||
fn share_membership_from_dto(dto: &ShareMembershipSummaryDto) -> ShareMembership {
|
||||
ShareMembership {
|
||||
share_id: dto.share_id.clone(),
|
||||
membership_id: dto.membership_id.clone(),
|
||||
permissions: dto.permissions,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Drive-owned items of one `v2/sharedwithme` page, in the order the API
|
||||
/// returned them.
|
||||
///
|
||||
/// Albums and photos belong to [`ProtonPhotosClient`](crate::ProtonPhotosClient)
|
||||
/// and a `Root` target is not an item, so all three are dropped — as is a target
|
||||
/// type this build does not recognise, which is safer to skip than to guess at.
|
||||
/// Order is preserved: it is what a front-end lists, and reshuffling it would
|
||||
/// move rows around on every refresh.
|
||||
fn drive_items(page: &SharedWithMeResponse) -> Vec<SharedWithMeItem> {
|
||||
page.links
|
||||
.iter()
|
||||
.filter(|link| {
|
||||
ShareTargetType::from_raw(link.share_target_type)
|
||||
.is_some_and(ShareTargetType::is_drive_item)
|
||||
})
|
||||
.map(|link| SharedWithMeItem {
|
||||
uid: NodeUid::new(link.volume_id.clone(), link.link_id.clone()),
|
||||
share_id: link.share_id.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
DriveCache, DriveEvent, FOLDER_KEY_CACHE_CAP, aggregate_outcomes, alternate_names,
|
||||
assemble_manifest, check_aggregate, epoch_to_iso8601, is_expired_upload_token,
|
||||
assemble_manifest, check_aggregate, drive_items, epoch_to_iso8601, is_expired_upload_token,
|
||||
is_listable_revision_state, is_upload_timeout, join_node_path, path_segments,
|
||||
small_upload_applicable, take_block_target, take_thumbnail_target, to_drive_event,
|
||||
share_membership_from_dto, small_upload_applicable, take_block_target,
|
||||
take_thumbnail_target, to_drive_event,
|
||||
};
|
||||
use crate::dtos::{
|
||||
AggregateLinksResponse, BlockUploadTarget, LinkIdResponsePair, VolumeEventDto,
|
||||
VolumeEventLinkDto,
|
||||
AggregateLinksResponse, BlockUploadTarget, LinkIdResponsePair, ShareMembershipSummaryDto,
|
||||
SharedWithMeLinkDto, SharedWithMeResponse, VolumeEventDto, VolumeEventLinkDto,
|
||||
};
|
||||
use crate::node::RevisionState;
|
||||
use crate::sharing::MemberRole;
|
||||
use proton_sdk::api::{ApiResponse, ResponseCode};
|
||||
use proton_sdk::error::{ProtonApiError, ProtonError};
|
||||
use proton_sdk::ids::{DriveEventId, LinkId, VolumeId};
|
||||
use proton_sdk::ids::{DriveEventId, LinkId, ShareId, ShareMembershipId, VolumeId};
|
||||
|
||||
/// A batch envelope with one successful link and one that failed with `code`.
|
||||
fn mixed_aggregate(code: ResponseCode) -> AggregateLinksResponse {
|
||||
@@ -6570,6 +6621,53 @@ mod tests {
|
||||
assert!(!message.contains("ok-link"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_with_me_items_keep_share_ids_order_and_drive_targets() {
|
||||
let link = |link_id: &str, share_id: &str, target_type| SharedWithMeLinkDto {
|
||||
volume_id: VolumeId::new("shared-volume"),
|
||||
share_id: ShareId::new(share_id),
|
||||
link_id: LinkId::new(link_id),
|
||||
share_target_type: target_type,
|
||||
};
|
||||
let page = SharedWithMeResponse {
|
||||
links: vec![
|
||||
link("folder", "share-folder", 1),
|
||||
link("album", "share-album", 3),
|
||||
link("file", "share-file", 2),
|
||||
link("root", "share-root", 0),
|
||||
link("vendor", "share-vendor", 5),
|
||||
link("future", "share-future", 99),
|
||||
],
|
||||
anchor_id: None,
|
||||
more: false,
|
||||
};
|
||||
|
||||
let items = drive_items(&page);
|
||||
assert_eq!(items.len(), 3);
|
||||
assert_eq!(items[0].uid.link_id, LinkId::new("folder"));
|
||||
assert_eq!(items[0].share_id, ShareId::new("share-folder"));
|
||||
assert_eq!(items[1].uid.link_id, LinkId::new("file"));
|
||||
assert_eq!(items[1].share_id, ShareId::new("share-file"));
|
||||
assert_eq!(items[2].uid.link_id, LinkId::new("vendor"));
|
||||
assert_eq!(items[2].share_id, ShareId::new("share-vendor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_dto_copies_raw_authority_into_the_public_shape() {
|
||||
let dto = ShareMembershipSummaryDto {
|
||||
share_id: ShareId::new("share-1"),
|
||||
membership_id: ShareMembershipId::new("membership-1"),
|
||||
permissions: 38,
|
||||
};
|
||||
|
||||
let membership = share_membership_from_dto(&dto);
|
||||
assert_eq!(membership.share_id, dto.share_id);
|
||||
assert_eq!(membership.membership_id, dto.membership_id);
|
||||
assert_eq!(membership.permissions, 38);
|
||||
assert_eq!(membership.role(), MemberRole::Viewer);
|
||||
assert_eq!(membership.role_exact(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_sealed_revisions_are_listable() {
|
||||
assert!(is_listable_revision_state(Some(1)), "active");
|
||||
|
||||
@@ -62,7 +62,7 @@ pub use public_link::{ProtonDrivePublicLinkClient, PublicLinkInfo};
|
||||
pub use revision::{Revision, RevisionReader};
|
||||
pub use sharing::{
|
||||
Bookmark, ExternalInvitation, ExternalInvitationState, IncomingInvitation, MemberRole,
|
||||
PublicLink, ShareInvitation, ShareMember,
|
||||
PublicLink, ShareInvitation, ShareMember, ShareMembership, SharedWithMeItem,
|
||||
};
|
||||
|
||||
pub use proton_sdk;
|
||||
@@ -4,6 +4,8 @@ use proton_sdk::crypto::VerificationStatus;
|
||||
use proton_sdk::ids::NodeUid;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::sharing::ShareMembership;
|
||||
|
||||
/// A decrypted Drive node (folder or file).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Node {
|
||||
@@ -27,6 +29,14 @@ pub struct Node {
|
||||
pub is_shared_publicly: bool,
|
||||
/// Email address that signed the node, if present.
|
||||
pub signature_email: Option<String>,
|
||||
/// Our membership in the share this node was reached through, when it is
|
||||
/// shared *with* us — this is what says whether we may write to it. `None`
|
||||
/// for nodes we own (the link details carry no `Membership` block).
|
||||
///
|
||||
/// `#[serde(default)]` is load-bearing: consumers persist `Node` blobs, and
|
||||
/// one written before this field existed must keep deserializing.
|
||||
#[serde(default)]
|
||||
pub membership: Option<ShareMembership>,
|
||||
/// Per-field signature-verification results gathered while decrypting the
|
||||
/// node. Non-fatal metadata (mirrors C# `AuthorshipVerificationFailure`):
|
||||
/// the node is always returned; the caller inspects this to decide trust.
|
||||
@@ -277,4 +287,51 @@ mod tests {
|
||||
assert_eq!(RevisionState::from_raw(Some(0)), RevisionState::Active);
|
||||
assert_eq!(RevisionState::from_raw(None), RevisionState::Active);
|
||||
}
|
||||
|
||||
/// Consumers persist whole `Node` blobs (the Linux client keeps them in a
|
||||
/// SQLite column), so a node written before `membership` existed has to keep
|
||||
/// deserializing — otherwise adding the field bricks every cached row on
|
||||
/// upgrade.
|
||||
#[test]
|
||||
fn a_node_round_trips_with_and_without_a_membership() {
|
||||
let node = Node {
|
||||
uid: NodeUid::new("vol1".into(), "link1".into()),
|
||||
parent_uid: None,
|
||||
kind: NodeKind::Folder,
|
||||
name: "Team Budget".into(),
|
||||
creation_time: 1,
|
||||
modification_time: 2,
|
||||
trashed: false,
|
||||
is_shared: true,
|
||||
is_shared_publicly: false,
|
||||
signature_email: None,
|
||||
membership: Some(ShareMembership {
|
||||
share_id: "share-1".into(),
|
||||
membership_id: "member-1".into(),
|
||||
permissions: 6,
|
||||
}),
|
||||
verification: NodeVerification::default(),
|
||||
};
|
||||
|
||||
let back: Node = serde_json::from_str(&serde_json::to_string(&node).unwrap()).unwrap();
|
||||
let membership = back.membership.expect("membership survives the round trip");
|
||||
assert_eq!(membership.role_exact(), Some(crate::MemberRole::Editor));
|
||||
assert_eq!(membership.share_id.as_str(), "share-1");
|
||||
|
||||
// A blob written before the field existed reads back as "not shared with
|
||||
// us", which is the same thing an owned node says.
|
||||
let legacy = r#"{
|
||||
"uid": {"volume_id": "vol1", "link_id": "link1"},
|
||||
"parent_uid": null,
|
||||
"kind": "Folder",
|
||||
"name": "My Documents",
|
||||
"creation_time": 1,
|
||||
"modification_time": 2,
|
||||
"trashed": false,
|
||||
"signature_email": null
|
||||
}"#;
|
||||
let back: Node = serde_json::from_str(legacy).unwrap();
|
||||
assert!(back.membership.is_none());
|
||||
assert_eq!(back.name, "My Documents");
|
||||
}
|
||||
}
|
||||
@@ -553,6 +553,9 @@ impl ProtonDrivePublicLinkClient {
|
||||
is_shared: true,
|
||||
is_shared_publicly: true,
|
||||
signature_email: link.name_signature_email.clone(),
|
||||
// A public-link visitor has no account membership — access comes
|
||||
// from the link's own share password, not from a share member row.
|
||||
membership: None,
|
||||
verification: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! Reading shared-with-me nodes and leaving them lives on the client already.
|
||||
|
||||
use proton_sdk::ids::{NodeUid, ShareId, ShareMembershipId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A sharing permission role. The wire form is a permissions bitmask
|
||||
/// (`Read = 4`, `Write = 2`, `Admin = 16`); Proton only uses three combinations.
|
||||
@@ -23,14 +24,18 @@ pub enum MemberRole {
|
||||
}
|
||||
|
||||
impl MemberRole {
|
||||
const VIEWER_PERMISSIONS: i32 = 4;
|
||||
const EDITOR_PERMISSIONS: i32 = 6;
|
||||
const ADMIN_PERMISSIONS: i32 = 22;
|
||||
|
||||
/// The permissions bitmask for this role, or `None` for [`MemberRole::Inherited`]
|
||||
/// (which has no wire representation when inviting). Mirrors JS
|
||||
/// `memberRoleToPermission`.
|
||||
pub fn to_permissions(self) -> Option<i32> {
|
||||
match self {
|
||||
MemberRole::Viewer => Some(4),
|
||||
MemberRole::Editor => Some(6),
|
||||
MemberRole::Admin => Some(22),
|
||||
MemberRole::Viewer => Some(Self::VIEWER_PERMISSIONS),
|
||||
MemberRole::Editor => Some(Self::EDITOR_PERMISSIONS),
|
||||
MemberRole::Admin => Some(Self::ADMIN_PERMISSIONS),
|
||||
MemberRole::Inherited => None,
|
||||
}
|
||||
}
|
||||
@@ -41,12 +46,73 @@ impl MemberRole {
|
||||
pub fn from_permissions(permissions: Option<i32>) -> Self {
|
||||
match permissions {
|
||||
None => MemberRole::Inherited,
|
||||
Some(4) => MemberRole::Viewer,
|
||||
Some(6) => MemberRole::Editor,
|
||||
Some(22) => MemberRole::Admin,
|
||||
Some(Self::VIEWER_PERMISSIONS) => MemberRole::Viewer,
|
||||
Some(Self::EDITOR_PERMISSIONS) => MemberRole::Editor,
|
||||
Some(Self::ADMIN_PERMISSIONS) => MemberRole::Admin,
|
||||
Some(_) => MemberRole::Viewer,
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`from_permissions`](Self::from_permissions), but `None` for a mask
|
||||
/// this build does not recognise instead of degrading it to
|
||||
/// [`MemberRole::Viewer`].
|
||||
///
|
||||
/// The lenient mapping is right for display — the holder can at least read —
|
||||
/// but exact decoding preserves "we do not know" for permission enforcement.
|
||||
/// P2 can then deny writes while logging or otherwise handling the unknown
|
||||
/// mask explicitly.
|
||||
pub fn from_permissions_exact(permissions: Option<i32>) -> Option<Self> {
|
||||
match permissions {
|
||||
None => Some(MemberRole::Inherited),
|
||||
Some(Self::VIEWER_PERMISSIONS) => Some(MemberRole::Viewer),
|
||||
Some(Self::EDITOR_PERMISSIONS) => Some(MemberRole::Editor),
|
||||
Some(Self::ADMIN_PERMISSIONS) => Some(MemberRole::Admin),
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Our membership in a share someone else owns — the wire's
|
||||
/// `LinkDetailsDto.Membership`, which says what *we* are allowed to do with a
|
||||
/// node shared with us.
|
||||
///
|
||||
/// Rides on [`Node::membership`](crate::Node::membership) for anything reached
|
||||
/// through a share, and is `None` for nodes we own.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ShareMembership {
|
||||
/// The share the node was granted through.
|
||||
pub share_id: ShareId,
|
||||
/// Our membership in it (the handle for leaving the share).
|
||||
pub membership_id: ShareMembershipId,
|
||||
/// The raw permissions bitmask, kept so an unrecognised value can still be
|
||||
/// logged or round-tripped rather than being flattened into a role.
|
||||
pub permissions: i32,
|
||||
}
|
||||
|
||||
impl ShareMembership {
|
||||
/// Decode the raw permissions using the existing lenient mapping.
|
||||
pub fn role(&self) -> MemberRole {
|
||||
MemberRole::from_permissions(Some(self.permissions))
|
||||
}
|
||||
|
||||
/// Decode the raw permissions without guessing at an unrecognised mask.
|
||||
pub fn role_exact(&self) -> Option<MemberRole> {
|
||||
MemberRole::from_permissions_exact(Some(self.permissions))
|
||||
}
|
||||
}
|
||||
|
||||
/// One item from the shared-with-me listing, with the share it was granted
|
||||
/// through.
|
||||
///
|
||||
/// The share id matters because a shared node is the **root of the sharer's
|
||||
/// share on the sharer's volume**: it comes back parentless and only that
|
||||
/// share's key unlocks it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SharedWithMeItem {
|
||||
/// The shared node.
|
||||
pub uid: NodeUid,
|
||||
/// The share the node was granted through.
|
||||
pub share_id: ShareId,
|
||||
}
|
||||
|
||||
/// A member of a share (an invitation that has been accepted).
|
||||
@@ -182,3 +248,93 @@ pub struct Bookmark {
|
||||
/// When the bookmark was created (Unix seconds).
|
||||
pub creation_time: i64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_strict_role_mapping_refuses_to_guess_at_an_unknown_mask() {
|
||||
// The three masks Proton actually uses, plus the absent case.
|
||||
for (mask, want) in [
|
||||
(None, MemberRole::Inherited),
|
||||
(Some(4), MemberRole::Viewer),
|
||||
(Some(6), MemberRole::Editor),
|
||||
(Some(22), MemberRole::Admin),
|
||||
] {
|
||||
assert_eq!(MemberRole::from_permissions_exact(mask), Some(want));
|
||||
assert_eq!(MemberRole::from_permissions(mask), want);
|
||||
}
|
||||
|
||||
// An unrecognised mask is where the two mappings part company: the
|
||||
// lenient one degrades to Viewer so the holder can at least read, the
|
||||
// strict one admits it does not know so an enforcing caller can fail
|
||||
// closed instead of silently denying a real editor.
|
||||
for mask in [0, 2, 8, 16, 30, -1] {
|
||||
assert_eq!(MemberRole::from_permissions_exact(Some(mask)), None);
|
||||
assert_eq!(MemberRole::from_permissions(Some(mask)), MemberRole::Viewer);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_membership_decodes_roles_from_its_raw_permissions() {
|
||||
let membership = |permissions| ShareMembership {
|
||||
share_id: "s1".into(),
|
||||
membership_id: "m1".into(),
|
||||
permissions,
|
||||
};
|
||||
|
||||
let editor = membership(6);
|
||||
assert_eq!(editor.role(), MemberRole::Editor);
|
||||
assert_eq!(editor.role_exact(), Some(MemberRole::Editor));
|
||||
|
||||
let viewer = membership(4);
|
||||
assert_eq!(viewer.role(), MemberRole::Viewer);
|
||||
assert_eq!(viewer.role_exact(), Some(MemberRole::Viewer));
|
||||
|
||||
// A future permission bit remains available to callers. The lenient
|
||||
// display mapping and exact decoder are derived each time.
|
||||
let unknown = membership(38);
|
||||
assert_eq!(unknown.role(), MemberRole::Viewer);
|
||||
assert_eq!(unknown.role_exact(), None);
|
||||
assert_eq!(unknown.permissions, 38);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn membership_serde_stores_only_authoritative_data() {
|
||||
let membership = ShareMembership {
|
||||
share_id: "share-1".into(),
|
||||
membership_id: "member-1".into(),
|
||||
permissions: 22,
|
||||
};
|
||||
let json = serde_json::to_value(&membership).unwrap();
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({
|
||||
"share_id": "share-1",
|
||||
"membership_id": "member-1",
|
||||
"permissions": 22
|
||||
})
|
||||
);
|
||||
let back: ShareMembership = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(back, membership);
|
||||
assert_eq!(back.role(), MemberRole::Admin);
|
||||
assert_eq!(back.role_exact(), Some(MemberRole::Admin));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forged_serialized_roles_cannot_override_raw_permissions() {
|
||||
let json = serde_json::json!({
|
||||
"share_id": "share-1",
|
||||
"membership_id": "member-1",
|
||||
"permissions": 38,
|
||||
"role": "admin",
|
||||
"role_exact": "admin"
|
||||
});
|
||||
|
||||
let membership: ShareMembership = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(membership.permissions, 38);
|
||||
assert_eq!(membership.role(), MemberRole::Viewer);
|
||||
assert_eq!(membership.role_exact(), None);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user