1use kcode_k1_groups_domain::{
2 ALL_MODELS, ALL_USERS, Group, GroupId, GroupMemberships, GroupName, GroupRevision, GroupRole,
3 LOCAL_MODELS, ModelId, TxId, UserId,
4};
5use kcode_k1_groups_projection_test_contract as projection_contract;
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8pub struct ConcurrencyObservation {
9 pub both_submissions_completed: bool,
10 pub revoked_actor_rejected: bool,
11 pub final_role: Option<GroupRole>,
12}
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub struct MalformedObservation {
16 pub canonical_callback_failed: bool,
17 pub facade_unavailable: bool,
18 pub fresh_replay_failed: bool,
19}
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub struct ReorgObservation {
23 pub old_facade_unavailable: bool,
24 pub projection_cleared: bool,
25 pub fresh_open_empty: bool,
26}
27
28pub trait DriverAdapter {
29 fn create(&self, owner: UserId, name: GroupName) -> Result<GroupRevision, String>;
30 fn rename(
31 &self,
32 actor: UserId,
33 group: GroupId,
34 name: GroupName,
35 ) -> Result<GroupRevision, String>;
36 fn set_user_role(
37 &self,
38 actor: UserId,
39 group: GroupId,
40 user: UserId,
41 role: Option<GroupRole>,
42 ) -> Result<GroupRevision, String>;
43 fn set_model_membership(
44 &self,
45 actor: UserId,
46 group: GroupId,
47 model: ModelId,
48 present: bool,
49 ) -> Result<GroupRevision, String>;
50 fn get(&self, group: GroupId) -> Result<Option<Group>, String>;
51 fn groups_for_user(&self, user: UserId) -> Result<Vec<GroupId>, String>;
52 fn groups_for_model(&self, model: ModelId) -> Result<Vec<GroupId>, String>;
53 fn memberships(&self, user: UserId, model: ModelId) -> Result<GroupMemberships, String>;
54 fn restart(&mut self) -> Result<(), String>;
55 fn concurrency_observation(&mut self) -> Result<ConcurrencyObservation, String>;
56 fn malformed_observation(&mut self) -> Result<MalformedObservation, String>;
57 fn reorg_observation(&mut self) -> Result<ReorgObservation, String>;
58}
59
60pub fn user(index: usize) -> UserId {
61 projection_contract::user(index)
62}
63
64pub fn model(index: usize) -> ModelId {
65 projection_contract::model(index)
66}
67
68#[derive(Clone, Debug, Eq, PartialEq)]
69struct GroupView {
70 id: GroupId,
71 name: GroupName,
72 revision: GroupRevision,
73 users: Vec<(UserId, GroupRole)>,
74 models: Vec<ModelId>,
75}
76
77#[derive(Clone, Debug, Eq, PartialEq)]
78struct MembershipView {
79 revision: Option<TxId>,
80 user_groups: Vec<GroupId>,
81 model_groups: Vec<GroupId>,
82 shared_groups: Vec<GroupId>,
83}
84
85#[derive(Clone, Debug, Eq, PartialEq)]
86struct Snapshot {
87 group: GroupView,
88 user_groups: Vec<GroupId>,
89 model_groups: Vec<GroupId>,
90 memberships: MembershipView,
91}
92
93fn fixture_name(value: &str) -> Result<GroupName, String> {
94 GroupName::new(value.to_owned()).map_err(|error| format!("fixture name {value:?}: {error}"))
95}
96
97fn adapter_result<T>(result: Result<T, String>, context: &str) -> Result<T, String> {
98 result.map_err(|error| format!("{context}: adapter error: {error}"))
99}
100
101fn changed(
102 result: Result<GroupRevision, String>,
103 group: GroupId,
104 previous: Option<&GroupRevision>,
105 context: &str,
106) -> Result<GroupRevision, String> {
107 let revision = adapter_result(result, context)?;
108 if revision.group_id() != group {
109 return Err(format!("{context}: revision belonged to another group"));
110 }
111 if previous == Some(&revision) {
112 return Err(format!("{context}: expected a new revision"));
113 }
114 Ok(revision)
115}
116
117fn unchanged(
118 result: Result<GroupRevision, String>,
119 expected: &GroupRevision,
120 context: &str,
121) -> Result<(), String> {
122 let actual = adapter_result(result, context)?;
123 exact(&actual, expected, context)
124}
125
126fn rejected(
127 result: Result<GroupRevision, String>,
128 expected: &str,
129 context: &str,
130) -> Result<(), String> {
131 match result {
132 Err(reason) if reason == expected => Ok(()),
133 Err(reason) => Err(format!("{context}: expected {expected:?}, got {reason:?}")),
134 Ok(revision) => Err(format!("{context}: expected rejection, got {revision:?}")),
135 }
136}
137
138fn exact<T: Eq + std::fmt::Debug>(actual: &T, expected: &T, context: &str) -> Result<(), String> {
139 if actual == expected {
140 Ok(())
141 } else {
142 Err(format!("{context}: expected {expected:?}, got {actual:?}"))
143 }
144}
145
146fn ordered<T: Ord>(mut values: Vec<T>) -> Vec<T> {
147 values.sort();
148 values
149}
150
151fn group_view(group: &Group) -> GroupView {
152 let mut users: Vec<_> = group
153 .users()
154 .iter()
155 .map(|entry| (entry.user_id(), entry.role()))
156 .collect();
157 users.sort_by_key(|(id, role)| (*id.as_tx_id().as_bytes(), *role));
158 GroupView {
159 id: group.id(),
160 name: group.name().clone(),
161 revision: group.revision().clone(),
162 users,
163 models: ordered(group.models().to_vec()),
164 }
165}
166
167fn membership_view(value: &GroupMemberships) -> MembershipView {
168 MembershipView {
169 revision: value.revision(),
170 user_groups: ordered(value.user_groups().to_vec()),
171 model_groups: ordered(value.model_groups().to_vec()),
172 shared_groups: ordered(value.shared_groups().to_vec()),
173 }
174}
175
176fn snapshot<A: DriverAdapter>(
177 adapter: &A,
178 group: GroupId,
179 user: UserId,
180 model: ModelId,
181 context: &str,
182) -> Result<Snapshot, String> {
183 let group = match adapter.get(group) {
184 Ok(Some(value)) => value,
185 Ok(None) => return Err(format!("{context}: group was absent")),
186 Err(error) => return Err(format!("{context}: adapter error: {error}")),
187 };
188 Ok(Snapshot {
189 group: group_view(&group),
190 user_groups: ordered(adapter_result(adapter.groups_for_user(user), context)?),
191 model_groups: ordered(adapter_result(adapter.groups_for_model(model), context)?),
192 memberships: membership_view(&adapter_result(adapter.memberships(user, model), context)?),
193 })
194}
195
196fn same_group_queries(before: &Snapshot, after: &Snapshot, context: &str) -> Result<(), String> {
197 exact(&after.group, &before.group, context)?;
198 exact(&after.user_groups, &before.user_groups, context)?;
199 exact(&after.model_groups, &before.model_groups, context)?;
200 exact(
201 &after.memberships.user_groups,
202 &before.memberships.user_groups,
203 context,
204 )?;
205 exact(
206 &after.memberships.model_groups,
207 &before.memberships.model_groups,
208 context,
209 )?;
210 exact(
211 &after.memberships.shared_groups,
212 &before.memberships.shared_groups,
213 context,
214 )
215}
216
217pub fn verify<A: DriverAdapter>(adapter: &mut A) -> Result<(), String> {
218 use GroupRole::{Admin, Owner, User};
219
220 let owner = user(1);
221 let admin = user(2);
222 let member = user(3);
223 let temporary = user(4);
224 let outsider = user(5);
225 let removed_model = model(1);
226 let shared_model = model(2);
227 let original_name = fixture_name("named group")?;
228 let renamed_name = fixture_name("renamed group")?;
229
230 let created = adapter_result(adapter.create(owner, original_name.clone()), "create")?;
231 let group = created.group_id();
232 if group.txid() != created.txid() {
233 return Err("create: group identity did not equal its revision transaction".to_owned());
234 }
235 let created_snapshot = snapshot(adapter, group, owner, removed_model, "create queries")?;
236 exact(&created_snapshot.group.revision, &created, "create queries")?;
237 exact(
238 &created_snapshot.group,
239 &GroupView {
240 id: group,
241 name: original_name.clone(),
242 revision: created.clone(),
243 users: vec![(owner, Owner)],
244 models: vec![],
245 },
246 "create queries",
247 )?;
248
249 let renamed = changed(
250 adapter.rename(owner, group, renamed_name.clone()),
251 group,
252 Some(&created),
253 "rename",
254 )?;
255 unchanged(
256 adapter.rename(owner, group, renamed_name.clone()),
257 &renamed,
258 "equal rename",
259 )?;
260 let after_equal_rename =
261 snapshot(adapter, group, owner, removed_model, "equal rename queries")?;
262 exact(
263 &after_equal_rename.group.revision,
264 &renamed,
265 "equal rename queries",
266 )?;
267 rejected(
268 adapter.rename(outsider, group, original_name.clone()),
269 "actor is not authorized",
270 "non-owner rename",
271 )?;
272 let after_rejected_rename = snapshot(
273 adapter,
274 group,
275 owner,
276 removed_model,
277 "non-owner rename queries",
278 )?;
279 same_group_queries(
280 &after_equal_rename,
281 &after_rejected_rename,
282 "non-owner rename queries",
283 )?;
284
285 let admin_revision = changed(
286 adapter.set_user_role(owner, group, admin, Some(Admin)),
287 group,
288 Some(&renamed),
289 "add admin",
290 )?;
291 let member_revision = changed(
292 adapter.set_user_role(owner, group, member, Some(User)),
293 group,
294 Some(&admin_revision),
295 "add user",
296 )?;
297 let temporary_revision = changed(
298 adapter.set_user_role(admin, group, temporary, Some(User)),
299 group,
300 Some(&member_revision),
301 "admin add",
302 )?;
303 let removed_revision = changed(
304 adapter.set_user_role(admin, group, temporary, None),
305 group,
306 Some(&temporary_revision),
307 "admin remove",
308 )?;
309 rejected(
310 adapter.set_user_role(admin, group, member, Some(Admin)),
311 "administrator transition is not permitted",
312 "admin elevate",
313 )?;
314 rejected(
315 adapter.set_user_role(owner, group, owner, None),
316 "final owner cannot be removed or demoted",
317 "final owner",
318 )?;
319
320 let model_revision = changed(
321 adapter.set_model_membership(owner, group, removed_model, true),
322 group,
323 Some(&removed_revision),
324 "model add",
325 )?;
326 unchanged(
327 adapter.set_model_membership(owner, group, removed_model, true),
328 &model_revision,
329 "model equal",
330 )?;
331 let after_equal_model = snapshot(adapter, group, member, removed_model, "model equal queries")?;
332 exact(
333 &after_equal_model.group.revision,
334 &model_revision,
335 "model equal queries",
336 )?;
337 let removed_model_revision = changed(
338 adapter.set_model_membership(owner, group, removed_model, false),
339 group,
340 Some(&model_revision),
341 "model remove",
342 )?;
343 rejected(
344 adapter.set_model_membership(admin, group, removed_model, true),
345 "actor is not authorized",
346 "model non-owner",
347 )?;
348 let final_revision = changed(
349 adapter.set_model_membership(owner, group, shared_model, true),
350 group,
351 Some(&removed_model_revision),
352 "shared model add",
353 )?;
354
355 let normal = snapshot(adapter, group, member, shared_model, "normal queries")?;
356 let expected_normal = Snapshot {
357 group: GroupView {
358 id: group,
359 name: renamed_name,
360 revision: final_revision.clone(),
361 users: vec![(owner, Owner), (admin, Admin), (member, User)],
362 models: vec![shared_model],
363 },
364 user_groups: ordered(vec![ALL_USERS, group]),
365 model_groups: ordered(vec![ALL_MODELS, group]),
366 memberships: MembershipView {
367 revision: Some(final_revision.txid()),
368 user_groups: ordered(vec![ALL_USERS, group]),
369 model_groups: ordered(vec![ALL_MODELS, group]),
370 shared_groups: vec![group],
371 },
372 };
373 exact(&normal, &expected_normal, "normal queries")?;
374
375 for (context, sentinel) in [
376 ("all-users rename", ALL_USERS),
377 ("all-models role", ALL_MODELS),
378 ("local-models model", LOCAL_MODELS),
379 ] {
380 let result = if sentinel == ALL_USERS {
381 adapter.rename(owner, sentinel, original_name.clone())
382 } else if sentinel == ALL_MODELS {
383 adapter.set_user_role(owner, sentinel, member, None)
384 } else {
385 adapter.set_model_membership(owner, sentinel, shared_model, false)
386 };
387 rejected(result, "sentinel groups are immutable", context)?;
388 let after = snapshot(adapter, group, member, shared_model, context)?;
389 same_group_queries(&normal, &after, context)?;
390 }
391
392 let before_restart = snapshot(adapter, group, member, shared_model, "before restart")?;
393 if before_restart.memberships.revision.is_none() {
394 return Err("before restart: missing membership revision".to_owned());
395 }
396 same_group_queries(&normal, &before_restart, "before restart")?;
397 adapter_result(adapter.restart(), "restart")?;
398 let after_restart = snapshot(adapter, group, member, shared_model, "after restart")?;
399 exact(&after_restart, &before_restart, "after restart")?;
400
401 exact(
402 &adapter_result(adapter.concurrency_observation(), "concurrency observation")?,
403 &ConcurrencyObservation {
404 both_submissions_completed: true,
405 revoked_actor_rejected: true,
406 final_role: None,
407 },
408 "concurrency observation",
409 )?;
410 exact(
411 &adapter_result(adapter.reorg_observation(), "reorg observation")?,
412 &ReorgObservation {
413 old_facade_unavailable: true,
414 projection_cleared: true,
415 fresh_open_empty: true,
416 },
417 "reorg observation",
418 )?;
419 exact(
420 &adapter_result(adapter.malformed_observation(), "malformed observation")?,
421 &MalformedObservation {
422 canonical_callback_failed: true,
423 facade_unavailable: true,
424 fresh_replay_failed: true,
425 },
426 "malformed observation",
427 )
428}
429
430#[cfg(test)]
431mod tests {
432 use super::{model, user};
433 use std::hint::black_box;
434 use std::time::{Duration, Instant};
435
436 #[test]
437 fn helpers_match_contract_and_budget() {
438 let index = 0x0102_0304usize;
439 let mut user_bytes = [0; 12];
440 user_bytes[4..].copy_from_slice(&(index as u64).to_be_bytes());
441 assert_eq!(user(index).as_tx_id().as_bytes(), &user_bytes);
442 let mut model_bytes = [0; 32];
443 model_bytes[24..].copy_from_slice(&(index as u64).to_be_bytes());
444 assert_eq!(model(index).as_bytes(), &model_bytes);
445 let started = Instant::now();
446 for index in 0..100_000 {
447 black_box((user(index), model(index)));
448 }
449 assert!(started.elapsed() < Duration::from_secs(5));
450 }
451}