Coverage Report

Created: 2026-03-25 23:22

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/tenet/tenet/tenet-aws/src/models/member.rs
Line
Count
Source
1
use std::collections::HashMap;
2
3
use aws_sdk_dynamodb::types::AttributeValue;
4
use chrono::{DateTime, Utc};
5
use serde::{Deserialize, Serialize};
6
use serde_dynamo::to_item;
7
8
use crate::{
9
    ApiError,
10
    clock::{Clock, SystemClock},
11
    db::{get_string, insert_entity_type, string_or_error, time_or_error},
12
    models::{organisation::OrganisationId, profile::UserId, role::Role},
13
};
14
15
/// Represents a member of an organisation
16
#[derive(Debug, Clone, Serialize, Deserialize)]
17
pub struct Member {
18
    /// The user's Cognito sub (unique identifier)
19
    pub user_id: UserId,
20
    /// The organisation this membership belongs to
21
    pub organisation_id: OrganisationId,
22
    /// The member's email address
23
    pub email: String,
24
    /// The member's display name (optional)
25
    #[serde(skip_serializing_if = "Option::is_none")]
26
    pub name: Option<String>,
27
    /// The member's role in this organisation
28
    pub role: Role,
29
    /// Who invited this member (null if they are the org creator)
30
    #[serde(skip_serializing_if = "Option::is_none")]
31
    pub invited_by: Option<UserId>,
32
    /// When the member joined the organisation
33
    pub joined_at: DateTime<Utc>,
34
}
35
36
impl Member {
37
    /// Create a new member (e.g., when accepting an invitation)
38
110
    pub fn new(
39
110
        user_id: UserId,
40
110
        organisation_id: OrganisationId,
41
110
        email: String,
42
110
        name: Option<String>,
43
110
        role: Role,
44
110
        invited_by: Option<UserId>,
45
110
    ) -> Self {
46
110
        Self::new_with_clock(
47
110
            &SystemClock,
48
110
            user_id,
49
110
            organisation_id,
50
110
            email,
51
110
            name,
52
110
            role,
53
110
            invited_by,
54
        )
55
110
    }
56
57
110
    pub fn new_with_clock<C: Clock>(
58
110
        clock: &C,
59
110
        user_id: UserId,
60
110
        organisation_id: OrganisationId,
61
110
        email: String,
62
110
        name: Option<String>,
63
110
        role: Role,
64
110
        invited_by: Option<UserId>,
65
110
    ) -> Self {
66
110
        Self {
67
110
            user_id,
68
110
            organisation_id,
69
110
            email,
70
110
            name,
71
110
            role,
72
110
            invited_by,
73
110
            joined_at: clock.now(),
74
110
        }
75
110
    }
76
77
    /// Create the owner member when creating a new organisation
78
30
    pub fn new_owner(
79
30
        user_id: UserId,
80
30
        organisation_id: OrganisationId,
81
30
        email: String,
82
30
        name: Option<String>,
83
30
    ) -> Self {
84
30
        Self::new_owner_with_clock(&SystemClock, user_id, organisation_id, email, name)
85
30
    }
86
87
30
    pub fn new_owner_with_clock<C: Clock>(
88
30
        clock: &C,
89
30
        user_id: UserId,
90
30
        organisation_id: OrganisationId,
91
30
        email: String,
92
30
        name: Option<String>,
93
30
    ) -> Self {
94
30
        Self {
95
30
            user_id,
96
30
            organisation_id,
97
30
            email,
98
30
            name,
99
30
            role: Role::Admin,
100
30
            invited_by: None,
101
30
            joined_at: clock.now(),
102
30
        }
103
30
    }
104
}
105
106
/// Simplified member view for API responses
107
#[derive(Debug, Clone, Serialize, Deserialize)]
108
pub struct MemberView {
109
    pub user_id: UserId,
110
    pub email: String,
111
    #[serde(skip_serializing_if = "Option::is_none")]
112
    pub name: Option<String>,
113
    pub role: Role,
114
    pub joined_at: DateTime<Utc>,
115
}
116
117
impl From<&Member> for MemberView {
118
2
    fn from(member: &Member) -> Self {
119
2
        Self {
120
2
            user_id: member.user_id.clone(),
121
2
            email: member.email.clone(),
122
2
            name: member.name.clone(),
123
2
            role: member.role,
124
2
            joined_at: member.joined_at,
125
2
        }
126
2
    }
127
}
128
129
impl TryFrom<&Member> for HashMap<String, AttributeValue> {
130
    type Error = ApiError;
131
132
1
    fn try_from(member: &Member) -> Result<Self, Self::Error> {
133
1
        let item: HashMap<String, AttributeValue> =
134
1
            to_item(member).map_err(|e| ApiError::InvalidRequest(
e0
.
to_string0
()))
?0
;
135
1
        Ok(insert_entity_type(item, "Member"))
136
1
    }
137
}
138
139
impl TryFrom<&HashMap<String, AttributeValue>> for Member {
140
    type Error = ApiError;
141
142
2
    fn try_from(value: &HashMap<String, AttributeValue>) -> Result<Self, Self::Error> {
143
        Ok(Member {
144
2
            user_id: string_or_error(value, "user_id")
?0
.try_into()
?0
,
145
2
            organisation_id: string_or_error(value, "organisation_id")
?0
.try_into()
?0
,
146
2
            email: string_or_error(value, "email")
?0
,
147
2
            name: get_string(value, "name"),
148
2
            role: string_or_error(value, "role")
?0
149
2
                .parse()
150
2
                .map_err(|e: String| ApiError::InvalidRequest(
e1
))
?1
,
151
1
            invited_by: get_string(value, "invited_by")
152
1
                .map(|id| id.try_into())
153
1
                .transpose()
?0
,
154
1
            joined_at: time_or_error(value, "joined_at")
?0
,
155
        })
156
2
    }
157
}
158
159
impl TryFrom<HashMap<String, AttributeValue>> for Member {
160
    type Error = ApiError;
161
162
0
    fn try_from(value: HashMap<String, AttributeValue>) -> Result<Self, Self::Error> {
163
0
        Member::try_from(&value)
164
0
    }
165
}
166
167
/// Request to add an existing user as a member
168
#[derive(Debug, Clone, Serialize, Deserialize)]
169
pub struct AddMemberRequest {
170
    /// The user ID (Cognito sub) to add
171
    pub user_id: String,
172
    /// The email of the user
173
    pub email: String,
174
    /// Optional display name
175
    #[serde(skip_serializing_if = "Option::is_none")]
176
    pub name: Option<String>,
177
    /// The role to assign
178
    pub role: Role,
179
}
180
181
/// Request to update a member's role
182
#[derive(Debug, Clone, Serialize, Deserialize)]
183
pub struct UpdateMemberRoleRequest {
184
    pub role: Role,
185
}
186
187
#[cfg(test)]
188
mod tests {
189
    use super::*;
190
    use crate::test_support::must_ok;
191
192
3
    fn user_id(seed: &str) -> UserId {
193
3
        must_ok(seed.to_string().try_into())
194
3
    }
195
196
2
    fn org_id(seed: &str) -> OrganisationId {
197
2
        must_ok(seed.to_string().try_into())
198
2
    }
199
200
    #[allure_rs::allure_parent_suite("tenet-aws")]
201
    #[allure_rs::allure_test]
202
    #[test]
203
    fn test_member_new_owner_sets_admin_defaults() {
204
        let owner = Member::new_owner(
205
            user_id("550e8400-e29b-41d4-a716-446655440000"),
206
            org_id("550e8400-e29b-41d4-a716-446655440001"),
207
            "owner@example.com".to_string(),
208
            Some("Owner".to_string()),
209
        );
210
211
        assert_eq!(owner.role, Role::Admin);
212
        assert_eq!(owner.invited_by, None);
213
        assert_eq!(owner.email, "owner@example.com");
214
    }
215
216
    #[allure_rs::allure_parent_suite("tenet-aws")]
217
    #[allure_rs::allure_test]
218
    #[test]
219
    fn test_member_roundtrip_item_and_view() {
220
        let member = Member::new(
221
            user_id("550e8400-e29b-41d4-a716-446655440000"),
222
            org_id("550e8400-e29b-41d4-a716-446655440001"),
223
            "member@example.com".to_string(),
224
            Some("Member".to_string()),
225
            Role::User,
226
            Some(user_id("550e8400-e29b-41d4-a716-446655440002")),
227
        );
228
229
        let item: HashMap<String, AttributeValue> = must_ok((&member).try_into());
230
        let decoded = must_ok(Member::try_from(&item));
231
        let view = MemberView::from(&decoded);
232
233
        assert_eq!(decoded.user_id, member.user_id);
234
        assert_eq!(decoded.organisation_id, member.organisation_id);
235
        assert_eq!(decoded.role, Role::User);
236
        assert_eq!(view.email, "member@example.com");
237
        assert_eq!(view.name.as_deref(), Some("Member"));
238
    }
239
240
    #[allure_rs::allure_parent_suite("tenet-aws")]
241
    #[allure_rs::allure_test]
242
    #[test]
243
    fn test_member_try_from_rejects_invalid_role() {
244
        let mut item: HashMap<String, AttributeValue> = HashMap::new();
245
        item.insert(
246
            "user_id".to_string(),
247
            AttributeValue::S("550e8400-e29b-41d4-a716-446655440000".to_string()),
248
        );
249
        item.insert(
250
            "organisation_id".to_string(),
251
            AttributeValue::S("550e8400-e29b-41d4-a716-446655440001".to_string()),
252
        );
253
        item.insert(
254
            "email".to_string(),
255
            AttributeValue::S("member@example.com".to_string()),
256
        );
257
        item.insert(
258
            "role".to_string(),
259
            AttributeValue::S("super-admin".to_string()),
260
        );
261
        item.insert(
262
            "joined_at".to_string(),
263
            AttributeValue::S(chrono::Utc::now().to_rfc3339()),
264
        );
265
266
        let err = Member::try_from(&item).expect_err("invalid role should fail decoding");
267
        assert!(
268
            err.to_string().contains("Invalid role"),
269
            "unexpected error: {err}"
270
        );
271
    }
272
}