| 17 |
17 |
|
use tracing::instrument;
|
| 18 |
18 |
|
use uuid::Uuid;
|
| 19 |
19 |
|
|
| 20 |
|
- |
use super::{ApiError, ResultApiError};
|
| 21 |
|
- |
use crate::state::AppState;
|
|
20 |
+ |
use super::{ApiError, OptionNotFound, ResultApiError};
|
|
21 |
+ |
use crate::state::{AppState, DESKTOP_USER_ID};
|
| 22 |
22 |
|
|
| 23 |
23 |
|
/// A group as surfaced to the frontend. `SyncGroup` is deserialize-only and
|
| 24 |
24 |
|
/// `#[non_exhaustive]`, so map to this stable DTO for the Tauri boundary.
|
| 174 |
174 |
|
group_id: String,
|
| 175 |
175 |
|
) -> Result<bool, ApiError> {
|
| 176 |
176 |
|
// Validate the id shape even though the column is plain TEXT.
|
| 177 |
|
- |
parse_uuid("group id", &group_id)?;
|
| 178 |
|
- |
set_project_scope(&state.pool, &project_id, Some(&group_id))
|
|
177 |
+ |
let group = parse_uuid("group id", &group_id)?;
|
|
178 |
+ |
let pid = parse_uuid("project id", &project_id)?;
|
|
179 |
+ |
|
|
180 |
+ |
// Resolve the parent first, the way every other command does. Without this a
|
|
181 |
+ |
// wrong or stale project id stamped zero rows and still reported success, so
|
|
182 |
+ |
// the user believed a project was shared when nothing was.
|
|
183 |
+ |
state
|
|
184 |
+ |
.projects
|
|
185 |
+ |
.get_by_id(pid.into(), DESKTOP_USER_ID)
|
|
186 |
+ |
.await?
|
|
187 |
+ |
.or_not_found("project", pid)?;
|
|
188 |
+ |
|
|
189 |
+ |
// And confirm the caller is actually in the group. Stamping a scope the sync
|
|
190 |
+ |
// engine holds no key for routes the whole subtree into a changelog scope that
|
|
191 |
+ |
// goes nowhere, and unshare_project is the only way back — which the user has
|
|
192 |
+ |
// no reason to run, having been told the share worked.
|
|
193 |
+ |
let client = require_client(&state)?;
|
|
194 |
+ |
let groups = client
|
|
195 |
+ |
.list_groups()
|
|
196 |
+ |
.await
|
|
197 |
+ |
.map_api_err("Failed to list groups", ApiError::external_service)?;
|
|
198 |
+ |
if !groups.iter().any(|g| g.id == GroupId::from(group)) {
|
|
199 |
+ |
return Err(ApiError::validation(
|
|
200 |
+ |
"groupId",
|
|
201 |
+ |
"You are not a member of that group",
|
|
202 |
+ |
));
|
|
203 |
+ |
}
|
|
204 |
+ |
|
|
205 |
+ |
set_project_scope(&state.pool, DESKTOP_USER_ID, &project_id, Some(&group_id))
|
| 179 |
206 |
|
.await
|
| 180 |
207 |
|
.map_api_err("Failed to share project", ApiError::internal)?;
|
| 181 |
208 |
|
Ok(true)
|
| 188 |
215 |
|
state: State<'_, Arc<AppState>>,
|
| 189 |
216 |
|
project_id: String,
|
| 190 |
217 |
|
) -> Result<bool, ApiError> {
|
| 191 |
|
- |
set_project_scope(&state.pool, &project_id, None)
|
|
218 |
+ |
let pid = parse_uuid("project id", &project_id)?;
|
|
219 |
+ |
state
|
|
220 |
+ |
.projects
|
|
221 |
+ |
.get_by_id(pid.into(), DESKTOP_USER_ID)
|
|
222 |
+ |
.await?
|
|
223 |
+ |
.or_not_found("project", pid)?;
|
|
224 |
+ |
set_project_scope(&state.pool, DESKTOP_USER_ID, &project_id, None)
|
| 192 |
225 |
|
.await
|
| 193 |
226 |
|
.map_api_err("Failed to unshare project", ApiError::internal)?;
|
| 194 |
227 |
|
Ok(true)
|
| 205 |
238 |
|
/// this never changes, so statement order is irrelevant.
|
| 206 |
239 |
|
async fn set_project_scope(
|
| 207 |
240 |
|
pool: &SqlitePool,
|
|
241 |
+ |
user_id: goingson_core::UserId,
|
| 208 |
242 |
|
project_id: &str,
|
| 209 |
243 |
|
group_id: Option<&str>,
|
| 210 |
244 |
|
) -> Result<(), sqlx::Error> {
|
| 211 |
|
- |
// `WHERE task_id IN (tasks of this project)`.
|
| 212 |
|
- |
const BY_TASK: &str = "SELECT id FROM tasks WHERE project_id = ?";
|
|
245 |
+ |
// `WHERE task_id IN (tasks of this project)`. Carries the owner filter too, so
|
|
246 |
+ |
// reaching a row through its task cannot skip the check reaching it directly
|
|
247 |
+ |
// would apply.
|
|
248 |
+ |
const BY_TASK: &str = "SELECT id FROM tasks WHERE project_id = ? AND user_id = ?";
|
|
249 |
+ |
|
|
250 |
+ |
// Each statement is paired with how many (project_id, user_id) pairs it binds
|
|
251 |
+ |
// after `group_id`, because the arms differ: the direct tables take one, and
|
|
252 |
+ |
// attachments takes two — one for the rows it owns through the project, one for
|
|
253 |
+ |
// the rows it owns through a task. Inferring that from the SQL by prefix match
|
|
254 |
+ |
// is how the attachment arm silently went unstamped.
|
|
255 |
+ |
let statements: [(String, usize); 9] = [
|
|
256 |
+ |
(
|
|
257 |
+ |
"UPDATE projects SET group_id = ? WHERE id = ? AND user_id = ?".to_string(),
|
|
258 |
+ |
1,
|
|
259 |
+ |
),
|
|
260 |
+ |
(
|
|
261 |
+ |
"UPDATE tasks SET group_id = ? WHERE project_id = ? AND user_id = ?".to_string(),
|
|
262 |
+ |
1,
|
|
263 |
+ |
),
|
|
264 |
+ |
(
|
|
265 |
+ |
"UPDATE milestones SET group_id = ? WHERE project_id = ? AND user_id = ?".to_string(),
|
|
266 |
+ |
1,
|
|
267 |
+ |
),
|
|
268 |
+ |
(
|
|
269 |
+ |
"UPDATE events SET group_id = ? WHERE project_id = ? AND user_id = ?".to_string(),
|
|
270 |
+ |
1,
|
|
271 |
+ |
),
|
|
272 |
+ |
(
|
|
273 |
+ |
format!("UPDATE subtasks SET group_id = ? WHERE task_id IN ({BY_TASK})"),
|
|
274 |
+ |
1,
|
|
275 |
+ |
),
|
|
276 |
+ |
(
|
|
277 |
+ |
format!("UPDATE annotations SET group_id = ? WHERE task_id IN ({BY_TASK})"),
|
|
278 |
+ |
1,
|
|
279 |
+ |
),
|
|
280 |
+ |
(
|
|
281 |
+ |
format!("UPDATE task_status_tokens SET group_id = ? WHERE task_id IN ({BY_TASK})"),
|
|
282 |
+ |
1,
|
|
283 |
+ |
),
|
|
284 |
+ |
(
|
|
285 |
+ |
format!("UPDATE time_sessions SET group_id = ? WHERE task_id IN ({BY_TASK})"),
|
|
286 |
+ |
1,
|
|
287 |
+ |
),
|
|
288 |
+ |
(
|
|
289 |
+ |
// Parenthesised: `A AND B OR C` binds as `(A AND B) OR C`, which would
|
|
290 |
+ |
// let the task arm through without the owner check.
|
|
291 |
+ |
format!(
|
|
292 |
+ |
"UPDATE attachments SET group_id = ? \
|
|
293 |
+ |
WHERE (project_id = ? AND user_id = ?) OR task_id IN ({BY_TASK})"
|
|
294 |
+ |
),
|
|
295 |
+ |
2,
|
|
296 |
+ |
),
|
|
297 |
+ |
];
|
| 213 |
298 |
|
|
| 214 |
299 |
|
let mut tx = pool.begin().await?;
|
| 215 |
|
- |
for sql in [
|
| 216 |
|
- |
"UPDATE projects SET group_id = ? WHERE id = ?".to_string(),
|
| 217 |
|
- |
"UPDATE tasks SET group_id = ? WHERE project_id = ?".to_string(),
|
| 218 |
|
- |
"UPDATE milestones SET group_id = ? WHERE project_id = ?".to_string(),
|
| 219 |
|
- |
"UPDATE events SET group_id = ? WHERE project_id = ?".to_string(),
|
| 220 |
|
- |
format!("UPDATE subtasks SET group_id = ? WHERE task_id IN ({BY_TASK})"),
|
| 221 |
|
- |
format!("UPDATE annotations SET group_id = ? WHERE task_id IN ({BY_TASK})"),
|
| 222 |
|
- |
format!("UPDATE task_status_tokens SET group_id = ? WHERE task_id IN ({BY_TASK})"),
|
| 223 |
|
- |
format!("UPDATE time_sessions SET group_id = ? WHERE task_id IN ({BY_TASK})"),
|
| 224 |
|
- |
format!(
|
| 225 |
|
- |
"UPDATE attachments SET group_id = ? WHERE project_id = ? OR task_id IN ({BY_TASK})"
|
| 226 |
|
- |
),
|
| 227 |
|
- |
// attachments binds project_id twice (direct owner + task subquery).
|
| 228 |
|
- |
] {
|
| 229 |
|
- |
let attachments = sql.starts_with("UPDATE attachments");
|
| 230 |
|
- |
let mut q = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
|
| 231 |
|
- |
.bind(group_id)
|
| 232 |
|
- |
.bind(project_id);
|
| 233 |
|
- |
if attachments {
|
| 234 |
|
- |
q = q.bind(project_id);
|
|
300 |
+ |
for (sql, pairs) in statements {
|
|
301 |
+ |
let mut q = sqlx::query(sqlx::AssertSqlSafe(sql.as_str())).bind(group_id);
|
|
302 |
+ |
for _ in 0..pairs {
|
|
303 |
+ |
q = q.bind(project_id).bind(user_id.to_string());
|
| 235 |
304 |
|
}
|
| 236 |
305 |
|
q.execute(&mut *tx).await?;
|
| 237 |
306 |
|
}
|
| 243 |
312 |
|
mod tests {
|
| 244 |
313 |
|
use super::set_project_scope;
|
| 245 |
314 |
|
use sqlx::sqlite::SqlitePoolOptions;
|
|
315 |
+ |
use uuid::Uuid;
|
|
316 |
+ |
|
|
317 |
+ |
/// The owner every seeded row belongs to.
|
|
318 |
+ |
fn owner() -> goingson_core::UserId {
|
|
319 |
+ |
goingson_core::UserId::from(
|
|
320 |
+ |
Uuid::parse_str("0f0f0f0f-0f0f-4f0f-8f0f-0f0f0f0f0f0f").unwrap(),
|
|
321 |
+ |
)
|
|
322 |
+ |
}
|
|
323 |
+ |
|
|
324 |
+ |
/// A different user, to prove the cascade's `user_id` predicate actually bites.
|
|
325 |
+ |
fn stranger() -> goingson_core::UserId {
|
|
326 |
+ |
goingson_core::UserId::from(
|
|
327 |
+ |
Uuid::parse_str("0e0e0e0e-0e0e-4e0e-8e0e-0e0e0e0e0e0e").unwrap(),
|
|
328 |
+ |
)
|
|
329 |
+ |
}
|
| 246 |
330 |
|
|
| 247 |
331 |
|
/// Build an in-memory DB with just the columns the cascade touches, seed a
|
| 248 |
332 |
|
/// project subtree plus an unrelated project, and return the pool.
|
| 252 |
336 |
|
.await
|
| 253 |
337 |
|
.unwrap();
|
| 254 |
338 |
|
sqlx::query(
|
| 255 |
|
- |
"CREATE TABLE projects (id TEXT PRIMARY KEY, group_id TEXT);
|
| 256 |
|
- |
CREATE TABLE tasks (id TEXT PRIMARY KEY, project_id TEXT, group_id TEXT);
|
| 257 |
|
- |
CREATE TABLE milestones (id TEXT PRIMARY KEY, project_id TEXT, group_id TEXT);
|
| 258 |
|
- |
CREATE TABLE events (id TEXT PRIMARY KEY, project_id TEXT, group_id TEXT);
|
|
339 |
+ |
"CREATE TABLE projects (id TEXT PRIMARY KEY, user_id TEXT, group_id TEXT);
|
|
340 |
+ |
CREATE TABLE tasks (id TEXT PRIMARY KEY, project_id TEXT, user_id TEXT, group_id TEXT);
|
|
341 |
+ |
CREATE TABLE milestones (id TEXT PRIMARY KEY, project_id TEXT, user_id TEXT, group_id TEXT);
|
|
342 |
+ |
CREATE TABLE events (id TEXT PRIMARY KEY, project_id TEXT, user_id TEXT, group_id TEXT);
|
| 259 |
343 |
|
CREATE TABLE subtasks (id TEXT PRIMARY KEY, task_id TEXT, group_id TEXT);
|
| 260 |
344 |
|
CREATE TABLE annotations (id TEXT PRIMARY KEY, task_id TEXT, group_id TEXT);
|
| 261 |
345 |
|
CREATE TABLE task_status_tokens (id TEXT PRIMARY KEY, task_id TEXT, group_id TEXT);
|
| 262 |
346 |
|
CREATE TABLE time_sessions (id TEXT PRIMARY KEY, task_id TEXT, group_id TEXT);
|
| 263 |
|
- |
CREATE TABLE attachments (id TEXT PRIMARY KEY, project_id TEXT, task_id TEXT, group_id TEXT);",
|
|
347 |
+ |
CREATE TABLE attachments (id TEXT PRIMARY KEY, project_id TEXT, task_id TEXT, user_id TEXT, group_id TEXT);",
|
| 264 |
348 |
|
)
|
| 265 |
349 |
|
.execute(&pool)
|
| 266 |
350 |
|
.await
|
| 268 |
352 |
|
|
| 269 |
353 |
|
// Project P1 with a task T1 and children; an unrelated project P2/T2.
|
| 270 |
354 |
|
sqlx::query(
|
| 271 |
|
- |
"INSERT INTO projects (id) VALUES ('P1'), ('P2');
|
| 272 |
|
- |
INSERT INTO tasks (id, project_id) VALUES ('T1', 'P1'), ('T2', 'P2');
|
| 273 |
|
- |
INSERT INTO milestones (id, project_id) VALUES ('M1', 'P1');
|
| 274 |
|
- |
INSERT INTO events (id, project_id) VALUES ('E1', 'P1');
|
|
355 |
+ |
"INSERT INTO projects (id, user_id) VALUES ('P1', ?1), ('P2', ?1);
|
|
356 |
+ |
INSERT INTO tasks (id, project_id, user_id) VALUES ('T1', 'P1', ?1), ('T2', 'P2', ?1);
|
|
357 |
+ |
INSERT INTO milestones (id, project_id, user_id) VALUES ('M1', 'P1', ?1);
|
|
358 |
+ |
INSERT INTO events (id, project_id, user_id) VALUES ('E1', 'P1', ?1);
|
| 275 |
359 |
|
INSERT INTO subtasks (id, task_id) VALUES ('S1', 'T1'), ('S2', 'T2');
|
| 276 |
360 |
|
INSERT INTO annotations (id, task_id) VALUES ('A1', 'T1');
|
| 277 |
361 |
|
INSERT INTO task_status_tokens (id, task_id) VALUES ('K1', 'T1');
|
| 278 |
362 |
|
INSERT INTO time_sessions (id, task_id) VALUES ('TS1', 'T1');
|
| 279 |
|
- |
INSERT INTO attachments (id, project_id, task_id) VALUES ('AT1', 'P1', NULL), ('AT2', NULL, 'T1'), ('AT3', 'P2', NULL);",
|
|
363 |
+ |
INSERT INTO attachments (id, project_id, task_id, user_id) VALUES ('AT1', 'P1', NULL, ?1), ('AT2', NULL, 'T1', ?1), ('AT3', 'P2', NULL, ?1);",
|
| 280 |
364 |
|
)
|
|
365 |
+ |
.bind(owner().to_string())
|
| 281 |
366 |
|
.execute(&pool)
|
| 282 |
367 |
|
.await
|
| 283 |
368 |
|
.unwrap();
|
| 315 |
400 |
|
#[tokio::test]
|
| 316 |
401 |
|
async fn share_stamps_the_whole_subtree_and_nothing_else() {
|
| 317 |
402 |
|
let pool = seed().await;
|
| 318 |
|
- |
set_project_scope(&pool, "P1", Some("G")).await.unwrap();
|
|
403 |
+ |
set_project_scope(&pool, owner(), "P1", Some("G"))
|
|
404 |
+ |
.await
|
|
405 |
+ |
.unwrap();
|
| 319 |
406 |
|
|
| 320 |
407 |
|
for (table, id, gid) in group_ids(&pool).await {
|
| 321 |
408 |
|
// Everything under P1 (and P1's attachments AT1/AT2) is stamped; P2's
|
| 330 |
417 |
|
}
|
| 331 |
418 |
|
}
|
| 332 |
419 |
|
|
|
420 |
+ |
#[tokio::test]
|
|
421 |
+ |
async fn another_users_project_is_untouched() {
|
|
422 |
+ |
let pool = seed().await;
|
|
423 |
+ |
set_project_scope(&pool, stranger(), "P1", Some("G"))
|
|
424 |
+ |
.await
|
|
425 |
+ |
.unwrap();
|
|
426 |
+ |
|
|
427 |
+ |
// The cascade is the widest write in the codebase; it must key on the owner
|
|
428 |
+ |
// like every other write, not on the project id alone.
|
|
429 |
+ |
for (table, id, gid) in group_ids(&pool).await {
|
|
430 |
+ |
assert_eq!(
|
|
431 |
+ |
gid, None,
|
|
432 |
+ |
"{table}:{id} must stay personal for another user"
|
|
433 |
+ |
);
|
|
434 |
+ |
}
|
|
435 |
+ |
}
|
|
436 |
+ |
|
| 333 |
437 |
|
#[tokio::test]
|
| 334 |
438 |
|
async fn unshare_clears_the_whole_subtree() {
|
| 335 |
439 |
|
let pool = seed().await;
|
| 336 |
|
- |
set_project_scope(&pool, "P1", Some("G")).await.unwrap();
|
| 337 |
|
- |
set_project_scope(&pool, "P1", None).await.unwrap();
|
|
440 |
+ |
set_project_scope(&pool, owner(), "P1", Some("G"))
|
|
441 |
+ |
.await
|
|
442 |
+ |
.unwrap();
|
|
443 |
+ |
set_project_scope(&pool, owner(), "P1", None).await.unwrap();
|
| 338 |
444 |
|
|
| 339 |
445 |
|
for (table, id, gid) in group_ids(&pool).await {
|
| 340 |
446 |
|
assert_eq!(gid, None, "{table}:{id} should be personal after unshare");
|