Skip to main content

max / goingson

Share a project into a group, from the described screen The picker and the write both wanted the network, which is why neither existed. A handler cannot await: the picker needed group_list to name its options, and share_project awaited list_groups to confirm the user is in the group before stamping a scope. That check is worth making, since a scope the engine holds no key for routes the whole subtree into a changelog that goes nowhere. synckit 0.9.0 removed the reason rather than the check. The sync loop had always fetched the group list and thrown the names away; it writes them down now, so both halves read a local table. known_groups() fills the select, directory::is_member answers the check, and share_project_local is the synchronous core the async command became a wrapper around. group_create writes its own group into the directory on the way past. Without it the one case that would be wrong is the obvious one: make a group, share a project into it before any cycle has run, and be told you are not a member of a group you just created. The picker is withheld when the directory is empty rather than drawn with no options. An empty directory means this device has not synced since groups existed, which is a different fact from having no groups. AppState ensures the directory tables at startup. They come with the sync DDL, but the project screen reads them whether or not sync was ever configured, and an app that has never been signed in has run none of that. Six tests, including that sharing reaches an inherited child and that a group this device does not know is refused.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-24 18:33 UTC
Signed with PGP, not checked
Commit: 1a6c68fece85f0d5c3f80e20cdcc10323e7d8a9f
Parent: 2a0069b
6 files changed, +335 insertions, -60 deletions
M Cargo.lock +19 -19
@@ -1387,7 +1387,7 @@
1387 1387 "libc",
1388 1388 "option-ext",
1389 1389 "redox_users",
1390 - "windows-sys 0.61.2",
1390 + "windows-sys 0.59.0",
1391 1391 ]
1392 1392
1393 1393 [[package]]
@@ -1628,7 +1628,7 @@
1628 1628 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
1629 1629 dependencies = [
1630 1630 "libc",
1631 - "windows-sys 0.61.2",
1631 + "windows-sys 0.59.0",
1632 1632 ]
1633 1633
1634 1634 [[package]]
@@ -3639,7 +3639,7 @@
3639 3639 "png 0.18.1",
3640 3640 "serde",
3641 3641 "thiserror 2.0.20",
3642 - "windows-sys 0.61.2",
3642 + "windows-sys 0.60.2",
3643 3643 ]
3644 3644
3645 3645 [[package]]
@@ -3772,7 +3772,7 @@
3772 3772 source = "registry+https://github.com/rust-lang/crates.io-index"
3773 3773 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
3774 3774 dependencies = [
3775 - "windows-sys 0.61.2",
3775 + "windows-sys 0.59.0",
3776 3776 ]
3777 3777
3778 3778 [[package]]
@@ -3880,7 +3880,7 @@
3880 3880 source = "registry+https://github.com/rust-lang/crates.io-index"
3881 3881 checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
3882 3882 dependencies = [
3883 - "proc-macro-crate 3.5.0",
3883 + "proc-macro-crate 1.3.1",
3884 3884 "proc-macro2",
3885 3885 "quote",
3886 3886 "syn 2.0.119",
@@ -5195,7 +5195,7 @@
5195 5195 "errno",
5196 5196 "libc",
5197 5197 "linux-raw-sys",
5198 - "windows-sys 0.61.2",
5198 + "windows-sys 0.59.0",
5199 5199 ]
5200 5200
5201 5201 [[package]]
@@ -5252,7 +5252,7 @@
5252 5252 "security-framework",
5253 5253 "security-framework-sys",
5254 5254 "webpki-root-certs",
5255 - "windows-sys 0.61.2",
5255 + "windows-sys 0.59.0",
5256 5256 ]
5257 5257
5258 5258 [[package]]
@@ -5792,7 +5792,7 @@
5792 5792 checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
5793 5793 dependencies = [
5794 5794 "libc",
5795 - "windows-sys 0.61.2",
5795 + "windows-sys 0.60.2",
5796 5796 ]
5797 5797
5798 5798 [[package]]
@@ -5991,7 +5991,7 @@
5991 5991
5992 5992 [[package]]
5993 5993 name = "synckit-client"
5994 - version = "0.8.1"
5994 + version = "0.9.0"
5995 5995 dependencies = [
5996 5996 "apple-native-keyring-store",
5997 5997 "argon2",
@@ -6564,10 +6564,10 @@
6564 6564 checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
6565 6565 dependencies = [
6566 6566 "fastrand",
6567 - "getrandom 0.4.3",
6567 + "getrandom 0.3.4",
6568 6568 "once_cell",
6569 6569 "rustix",
6570 - "windows-sys 0.61.2",
6570 + "windows-sys 0.59.0",
6571 6571 ]
6572 6572
6573 6573 [[package]]
@@ -6589,7 +6589,7 @@
6589 6589 "parking_lot",
6590 6590 "rustix",
6591 6591 "signal-hook",
6592 - "windows-sys 0.61.2",
6592 + "windows-sys 0.60.2",
6593 6593 ]
6594 6594
6595 6595 [[package]]
@@ -7071,7 +7071,7 @@
7071 7071 "png 0.18.1",
7072 7072 "serde",
7073 7073 "thiserror 2.0.20",
7074 - "windows-sys 0.61.2",
7074 + "windows-sys 0.60.2",
7075 7075 ]
7076 7076
7077 7077 [[package]]
@@ -7106,7 +7106,7 @@
7106 7106 dependencies = [
7107 7107 "memoffset",
7108 7108 "tempfile",
7109 - "windows-sys 0.61.2",
7109 + "windows-sys 0.60.2",
7110 7110 ]
7111 7111
7112 7112 [[package]]
@@ -7636,7 +7636,7 @@
7636 7636 source = "registry+https://github.com/rust-lang/crates.io-index"
7637 7637 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
7638 7638 dependencies = [
7639 - "windows-sys 0.61.2",
7639 + "windows-sys 0.59.0",
7640 7640 ]
7641 7641
7642 7642 [[package]]
@@ -8493,10 +8493,6 @@
8493 8493 "winnow 1.0.4",
8494 8494 ]
8495 8495
8496 - [[patch.unused]]
8497 - name = "ops-status"
8498 - version = "0.1.0"
8499 -
8500 8496 [[patch.unused]]
8501 8497 name = "quasi-axum"
8502 8498 version = "0.56.0"
@@ -8512,3 +8508,7 @@
8512 8508 [[patch.unused]]
8513 8509 name = "quasi-store"
8514 8510 version = "0.1.0"
8511 +
8512 + [[patch.unused]]
8513 + name = "ops-status"
8514 + version = "0.1.0"
@@ -42,7 +42,7 @@
42 42 goingson-core = { workspace = true }
43 43 painhours = { workspace = true }
44 44 goingson-db-sqlite = { workspace = true }
45 - synckit-client = { git = "https://makenot.work/git/max/synckit.git", version = "0.8" }
45 + synckit-client = { git = "https://makenot.work/git/max/synckit.git", version = "0.9" }
46 46 synckit-config = { git = "https://makenot.work/git/max/synckit.git", version = "0.2" }
47 47
48 48 # The quasi stack, which is the frontend. Git URLs redirected to the
@@ -222,6 +222,22 @@
222 222 warn!("SyncStore group migration failed (will retry next launch): {e}");
223 223 }
224 224
225 + // The group directory (synckit 0.9.0). Its tables come with the sync DDL
226 + // above, but the described project screen reads them whether or not sync
227 + // was ever configured, and an app that has never been signed in has run
228 + // none of that. Two no-op statements on a device that has, and the
229 + // difference between an empty directory and a missing table on one that
230 + // has not. Non-fatal: every read degrades to "this device knows no
231 + // groups", which is what the screen then says.
232 + match db.conn() {
233 + Ok(conn) => {
234 + if let Err(e) = synckit_client::store::directory::ensure_tables(&conn) {
235 + warn!("could not create the group directory tables: {e}");
236 + }
237 + }
238 + Err(e) => warn!("could not open a connection for the group directory: {e}"),
239 + }
240 +
225 241 // Config (step 6): regenerate triggers so an already-migrated device gains
226 242 // the user_config config triggers. No-op on a fresh install (the cutover
227 243 // already generated them) and once applied. Non-fatal.
@@ -13,6 +13,7 @@
13 13 use goingson_db_sqlite::Db;
14 14 use rusqlite::params_from_iter;
15 15 use serde::Serialize;
16 + use synckit_client::store::directory;
16 17 use synckit_client::{GroupId, InvitationId, SyncKitClient, UserId, identity::IdentityPublicKey};
17 18 use tauri::State;
18 19 use tracing::instrument;
@@ -55,6 +56,30 @@
55 56 .create_group(&name)
56 57 .await
57 58 .map_api_err("Failed to create group", ApiError::external_service)?;
59 +
60 + // Into the directory on the way past, so the group is usable before the next
61 + // sync cycle. Without this, making a group and immediately sharing a project
62 + // into it fails the membership check in `share_project_local`, which reads
63 + // the directory: the group is real on the server and unknown to this device.
64 + //
65 + // Additive rather than a replace, because the sync loop's write is the
66 + // authoritative one and this is filling in ahead of it. The creator is the
67 + // admin by construction.
68 + {
69 + let mut conn = state.db.conn()?;
70 + let known = directory::KnownGroup {
71 + id: group.id,
72 + name: group.name.clone(),
73 + gck_version: group.gck_version,
74 + is_admin: true,
75 + };
76 + if let Err(error) = directory::add_group(&mut conn, &known) {
77 + // Not fatal: the group exists, and the next cycle writes the whole
78 + // directory anyway. Sharing into it before then is what degrades.
79 + tracing::warn!(%error, "could not record the new group in the directory");
80 + }
81 + }
82 +
58 83 Ok(GroupDto {
59 84 id: group.id.to_string(),
60 85 name: group.name,
@@ -401,16 +426,22 @@
401 426 // Project scope write path
402 427
403 428 /// Share a whole project into a group: stamp `group_id` across its entire subtree.
404 - #[tauri::command]
405 - #[instrument(skip_all)]
406 - pub async fn share_project(
407 - state: State<'_, Arc<AppState>>,
408 - project_id: String,
409 - group_id: String,
410 - ) -> Result<bool, ApiError> {
429 + ///
430 + /// The synchronous core, so a described screen can call it. Everything this does
431 + /// is local, which is the change synckit 0.9.0 bought: the membership check was
432 + /// `client.list_groups().await` and is now a read of `sync_groups`, the directory
433 + /// the sync loop writes down each cycle.
434 + ///
435 + /// See [`crate::quasi::projects`] for the screen and for why the picker beside
436 + /// it needed the same directory.
437 + pub(crate) fn share_project_local(
438 + state: &AppState,
439 + project_id: &str,
440 + group_id: &str,
441 + ) -> Result<(), ApiError> {
411 442 // Validate the id shape even though the column is plain TEXT.
412 - let group = parse_uuid("group id", &group_id)?;
413 - let pid = parse_uuid("project id", &project_id)?;
443 + let group = parse_uuid("group id", group_id)?;
444 + let pid = parse_uuid("project id", project_id)?;
414 445
415 446 // Resolve the parent first, the way every other command does. Without this a
416 447 // wrong or stale project id stamped zero rows and still reported success, so
@@ -422,22 +453,38 @@
422 453
423 454 // And confirm the caller is actually in the group. Stamping a scope the sync
424 455 // engine holds no key for routes the whole subtree into a changelog scope that
425 - // goes nowhere, and unshare_project is the only way back — which the user has
456 + // goes nowhere, and unshare_project is the only way back, which the user has
426 457 // no reason to run, having been told the share worked.
427 - let client = require_client(&state)?;
428 - let groups = client
429 - .list_groups()
430 - .await
431 - .map_api_err("Failed to list groups", ApiError::external_service)?;
432 - if !groups.iter().any(|g| g.id == GroupId::from(group)) {
458 + //
459 + // Answered from the directory rather than from the server. The two agree as
460 + // of the last sync, and `group_create` writes its own group in on the way
461 + // past, so the case that would otherwise be wrong (share into a group made
462 + // seconds ago, before any cycle has run) is covered where it arises.
463 + let conn = state.db.conn()?;
464 + if !directory::is_member(&conn, GroupId::from(group))
465 + .map_err(|e| ApiError::internal(format!("Failed to read the group directory: {e}")))?
466 + {
433 467 return Err(ApiError::validation(
434 468 "groupId",
435 469 "You are not a member of that group",
436 470 ));
437 471 }
472 + drop(conn);
438 473
439 - set_project_scope(&state.db, DESKTOP_USER_ID, &project_id, Some(&group_id))
474 + set_project_scope(&state.db, DESKTOP_USER_ID, project_id, Some(group_id))
440 475 .map_api_err("Failed to share project", ApiError::internal)?;
476 + Ok(())
477 + }
478 +
479 + /// Share a whole project into a group: stamp `group_id` across its entire subtree.
480 + #[tauri::command]
481 + #[instrument(skip_all)]
482 + pub async fn share_project(
483 + state: State<'_, Arc<AppState>>,
484 + project_id: String,
485 + group_id: String,
486 + ) -> Result<bool, ApiError> {
487 + share_project_local(&state, &project_id, &group_id)?;
441 488 Ok(true)
442 489 }
443 490
@@ -41,29 +41,35 @@
41 41 //! under, or acting resets the view. [`filtered`] is that, applied to the
42 42 //! detail address, the create form and both writes.
43 43 //!
44 - //! # Sharing: half of it is here, and the half that is not is not this screen's
44 + //! # Sharing, and the thing that had to exist before it could be described
45 45 //!
46 - //! A project's scope is a local column, so the mark and the way out are both
47 - //! sayable. A row carries a `Shared` badge when `group_id` is set, and the detail
48 - //! pane offers "Move back to personal", which is
49 - //! [`crate::commands::group::set_project_scope`] with `None`.
46 + //! A row carries a `Shared` badge when `group_id` is set. A personal project
47 + //! offers a picker over the user's groups; a shared one offers the way back.
50 48 //!
51 - //! **Sharing INTO a group is not offered, and the reason is not this screen.**
52 - //! The control would be a picker over the groups the user belongs to, and
53 - //! `commands::group::group_list` answers that by awaiting the SyncKit client. A
54 - //! `quasi_router::Handler` is `fn(&S, Request) -> Result<Response, RouteError>`,
55 - //! so a handler cannot await, and there is no local table of groups to read
56 - //! instead: membership lives on the server.
49 + //! **Neither the picker nor the write was describable until synckit 0.9.0**, and
50 + //! the reason is worth keeping because it is the general shape. A handler is
51 + //! `fn(&S, Request) -> Result<Response, RouteError>`, so it cannot await, and
52 + //! both halves of sharing wanted the network: the picker needed `group_list` to
53 + //! name the options, and `share_project` awaited `list_groups` to confirm the
54 + //! user is actually in the group before stamping a scope. That check is worth
55 + //! making, since a scope the engine holds no key for routes the whole subtree
56 + //! into a changelog that goes nowhere.
57 57 //!
58 - //! This is the same gap that keeps Settings > Sharing undescribed, recorded in
59 - //! [`super`]'s table as "its reads are remote, so there is no local state to draw
60 - //! a section from" and filed as quasicoherent `82273265`. Two screens now want
61 - //! the same thing, which is worth knowing when that task is picked up.
58 + //! What resolved it was not a new capability. The sync loop had always asked the
59 + //! server which groups to sync, once per cycle, and always thrown the names away.
60 + //! synckit writes that answer down now (`sync_groups`, and
61 + //! `synckit_client::store::directory`), so both halves read a local table:
62 + //! [`known_groups`] for the options, `directory::is_member` for the check. The
63 + //! remote read still happens, on a schedule, outside the request loop, which is
64 + //! where a description can live with it.
62 65 //!
63 - //! Offering only the way out is deliberate rather than an oversight. A project
64 - //! shared from another device shows as shared and can be moved back, which is
65 - //! the reversal path; what is missing is the way in, and a control that could
66 - //! not populate its own options would be worse than none.
66 + //! quasicoherent `82273265` is where that argument was settled; this screen and
67 + //! Settings > Sharing were its two consumers.
68 + //!
69 + //! The picker is withheld when the directory is empty rather than drawn with no
70 + //! options. An empty directory means this device has not synced since groups
71 + //! existed, which is a different fact from having no groups, and a control that
72 + //! cannot populate itself is worse than none.
67 73
68 74 // Handlers take their request by value because `quasi_router::Handler` is a
69 75 // plain `fn(&S, Request)` pointer, so the signature is the router's and not a
@@ -409,10 +415,38 @@
409 415 slot = slot.with(Node::text(&project.description));
410 416 }
411 417
412 - // Unshare, and deliberately not share. See the module header: moving a project
413 - // back to personal scope is a local write and this is the whole of it, while
414 - // sharing needs the group list, which is a remote read a described handler
415 - // cannot make.
418 + // Share, offered only on a personal project and only when this device knows
419 + // of a group to offer. Both halves read the directory synckit writes each
420 + // cycle; see the module header for why that had to exist first.
421 + if project.group_id.is_none() {
422 + let groups = known_groups(state)?;
423 + if !groups.is_empty() {
424 + slot = slot.with(Node::Form {
425 + action: filtered(
426 + Action::post(format!("/projects/{}/share", project.id)),
427 + shared_only,
428 + show_retired,
429 + ),
430 + submit: "Share into a group".to_owned(),
431 + fields: vec![
432 + Field::select(
433 + "group_id",
434 + "Group",
435 + groups
436 + .iter()
437 + .map(|group| Choice::new(group.id.to_string(), &group.name))
438 + .collect(),
439 + )
440 + .required()
441 + .hint(
442 + "Everything in the project goes with it: its tasks, events, \
443 + milestones and attachments.",
444 + ),
445 + ],
446 + });
447 + }
448 + }
449 +
416 450 if project.group_id.is_some() {
417 451 slot = slot.with(Node::text(
418 452 "Shared into a group. Its tasks, events, milestones and attachments \
@@ -679,6 +713,64 @@
679 713 wrote(state, flag(&request, "shared"), flag(&request, "retired"))
680 714 }
681 715
716 + /// The groups this device knows the user belongs to, by name.
717 + ///
718 + /// `synckit_client::store::directory`, read through goingson's own pool. The
719 + /// directory is written by the sync loop each cycle out of an answer it was
720 + /// already fetching, which is what makes a group nameable from a synchronous
721 + /// handler at all. An empty answer means this device has not synced since groups
722 + /// existed, not that the user has none, so a screen offers no picker rather than
723 + /// claiming there is nothing to share into.
724 + fn known_groups(
725 + state: &AppState,
726 + ) -> Result<Vec<synckit_client::store::sync::KnownGroup>, RouteError> {
727 + let conn = state
728 + .db
729 + .conn()
730 + .map_err(|error| RouteError::internal(error.to_string()))?;
731 + synckit_client::store::directory::groups(&conn)
732 + .map_err(|error| RouteError::internal(error.to_string()))
733 + }
734 +
735 + /// Share a project and its whole subtree into a group.
736 + ///
737 + /// The write is [`crate::commands::group::share_project_local`], which is
738 + /// synchronous because synckit 0.9.0 made its membership check a local read of
739 + /// the directory. Before that it was `client.list_groups().await` and this route
740 + /// could not have existed.
741 + fn share(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
742 + let id = project_id(&request)?;
743 + let group = request
744 + .payload
745 + .get("group_id")
746 + .unwrap_or_default()
747 + .trim()
748 + .to_owned();
749 +
750 + // A select offers a fixed set, so a value that is not one of them did not
751 + // come from the form. The refusal below covers both that and a group this
752 + // device does not know it belongs to, which is the same answer to the user.
753 + if let Err(error) = crate::commands::group::share_project_local(state, &id.to_string(), &group)
754 + {
755 + return Ok(Response::from(screen(
756 + state,
757 + flag(&request, "shared"),
758 + flag(&request, "retired"),
759 + )?)
760 + .toast(makeover_layout::Tone::Danger, error.to_string()));
761 + }
762 +
763 + Ok(Response::from(screen(
764 + state,
765 + flag(&request, "shared"),
766 + flag(&request, "retired"),
767 + )?)
768 + .toast(
769 + makeover_layout::Tone::Success,
770 + "Shared. Everything in the project went with it.",
771 + ))
772 + }
773 +
682 774 /// Move a project and its whole subtree back to personal scope.
683 775 ///
684 776 /// The write is [`crate::commands::group::set_project_scope`] with `None`, which
@@ -717,6 +809,7 @@
717 809 .get("/projects/{id}", detail)
718 810 .post("/projects", create)
719 811 .post("/projects/{id}/delete", remove)
812 + .post("/projects/{id}/share", share)
720 813 .post("/projects/{id}/unshare", unshare);
721 814 dashboard::routes(router)
722 815 }
@@ -737,6 +737,125 @@
737 737 .unwrap()
738 738 }
739 739
740 + /// Put a group in the directory the way the sync loop does, so a described
741 + /// handler can read it. That the loop writes this is synckit's test; what is
742 + /// under test here is a screen reading it.
743 + fn known_group(state: &AppState, id: u128, name: &str) {
744 + let mut conn = state.db.conn().unwrap();
745 + // The sync DDL runs when a `SyncStore` is built, which a test state does not
746 + // do. The directory's own DDL is separable for exactly this reason, so what
747 + // is under test is a screen reading the table rather than the table's
748 + // absence, which `a_device_that_knows_no_groups_is_offered_no_picker` covers.
749 + synckit_client::store::directory::ensure_tables(&conn).unwrap();
750 + synckit_client::store::directory::add_group(
751 + &mut conn,
752 + &synckit_client::store::directory::KnownGroup {
753 + id: synckit_client::GroupId::new(uuid::Uuid::from_u128(id)),
754 + name: name.to_owned(),
755 + gck_version: 1,
756 + is_admin: true,
757 + },
758 + )
759 + .unwrap();
760 + }
761 +
762 + fn pane(state: &AppState, project: &goingson_core::Project) -> String {
763 + let Outcome::Fragment { node, .. } =
764 + answer(state, &format!("/projects/{}", project.id), Params::new()).outcome
765 + else {
766 + panic!("the detail pane answers with a fragment");
767 + };
768 + quasi_webview::Webview::new().fragment(&node)
769 + }
770 +
771 + /// An empty directory means this device has not synced since groups existed,
772 + /// not that the user has no groups, so the control is withheld rather than
773 + /// drawn with nothing in it.
774 + #[tokio::test]
775 + async fn a_device_that_knows_no_groups_is_offered_no_picker() {
776 + let state = state().await;
777 + let personal = add(&state, "Mine alone", ProjectStatus::Active);
778 + let shown = pane(&state, &personal);
779 + assert!(!shown.contains("Share into a group"), "{shown}");
780 + }
781 +
782 + #[tokio::test]
783 + async fn a_personal_project_offers_the_groups_this_device_knows() {
784 + let state = state().await;
785 + let personal = add(&state, "Mine alone", ProjectStatus::Active);
786 + known_group(&state, 1, "The Firm");
787 +
788 + let shown = pane(&state, &personal);
789 + assert!(shown.contains("Share into a group"), "{shown}");
790 + assert!(shown.contains("The Firm"), "{shown}");
791 + assert!(
792 + shown.contains(&format!("/projects/{}/share", personal.id)),
793 + "{shown}"
794 + );
795 + }
796 +
797 + /// One project is in one scope, so a shared project is offered the way out and
798 + /// not a second way in.
799 + #[tokio::test]
800 + async fn a_shared_project_is_not_offered_the_picker_again() {
801 + let state = state().await;
802 + let shared = add(&state, "Shared work", ProjectStatus::Active);
803 + known_group(&state, 1, "The Firm");
804 + share_into(&state, &shared, "00000000-0000-0000-0000-000000000001");
805 +
806 + let shown = pane(&state, &shared);
807 + assert!(!shown.contains("Share into a group"), "{shown}");
808 + assert!(shown.contains("Move back to personal"), "{shown}");
809 + }
810 +
811 + #[tokio::test]
812 + async fn sharing_stamps_the_scope_across_the_subtree() {
813 + let state = state().await;
814 + let project = add(&state, "Mine alone", ProjectStatus::Active);
815 + known_group(&state, 1, "The Firm");
816 + let group = "00000000-0000-0000-0000-000000000001";
817 +
818 + let task = state
819 + .tasks
820 + .create(
821 + DESKTOP_USER_ID,
822 + goingson_core::NewTask::builder("Inside it")
823 + .project_id(project.id)
824 + .build(),
825 + )
826 + .unwrap();
827 +
828 + let mut params = Params::new();
829 + params.insert("group_id".to_owned(), group.to_owned());
830 + post(&state, &format!("/projects/{}/share", project.id), params);
831 +
832 + assert_eq!(scope_of(&state, &project).as_deref(), Some(group));
833 + assert_eq!(
834 + scope_of_task(&state, &task).as_deref(),
835 + Some(group),
836 + "and everything in it went too"
837 + );
838 + }
839 +
840 + /// The check `share_project` used to make against the server, made against the
841 + /// directory. Stamping a scope the engine holds no key for routes the whole
842 + /// subtree into a changelog that goes nowhere.
843 + #[tokio::test]
844 + async fn sharing_into_a_group_this_device_does_not_know_is_refused() {
845 + let state = state().await;
846 + let project = add(&state, "Mine alone", ProjectStatus::Active);
847 + known_group(&state, 1, "The Firm");
848 +
849 + let mut params = Params::new();
850 + params.insert(
851 + "group_id".to_owned(),
852 + "00000000-0000-0000-0000-0000000000ff".to_owned(),
853 + );
854 + post(&state, &format!("/projects/{}/share", project.id), params);
855 +
856 + assert!(scope_of(&state, &project).is_none(), "nothing was stamped");
857 + }
858 +
740 859 /// A stale id stamps zero rows and would otherwise report success, which is
741 860 /// `share_project`'s own recorded lesson asked of the way back out.
742 861 #[tokio::test]