Skip to content

Commit 9ae17a6

Browse files
committed
fix: scope playlist and subscription group changes to their owners
1 parent cfcea1c commit 9ae17a6

5 files changed

Lines changed: 136 additions & 21 deletions

File tree

src/database.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,6 @@ pub mod video;
1414
pub mod watch_history;
1515

1616
type DbError = diesel::result::Error;
17+
18+
#[cfg(all(test, feature = "sqlite"))]
19+
mod ownership_tests;

src/database/ownership_tests.rs

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
use diesel::connection::SimpleConnection;
2+
use diesel_async::AsyncConnection;
3+
use diesel_migrations::MigrationHarness;
4+
5+
use super::{playlist, subscription_groups};
6+
use crate::models::SubscriptionGroup;
7+
use crate::{DbConnection, MIGRATIONS};
8+
9+
async fn connection() -> DbConnection {
10+
let mut conn = DbConnection::establish(":memory:").await.unwrap();
11+
conn.spawn_blocking(|conn| {
12+
conn.run_pending_migrations(MIGRATIONS).unwrap();
13+
conn.batch_execute(
14+
"PRAGMA foreign_keys = ON;
15+
INSERT INTO account (id, name_hash, password_hash)
16+
VALUES ('owner', 'owner-hash', 'password'), ('other', 'other-hash', 'password');
17+
INSERT INTO channel (id, name, verified) VALUES ('channel', 'Channel', FALSE);
18+
INSERT INTO video (id, title, upload_date, thumbnail_url, duration, uploader_id)
19+
VALUES ('video', 'Video', 0, 'https://i.ytimg.com/vi/video/default.jpg', 60, 'channel');
20+
INSERT INTO playlist (id, account_id, title, description)
21+
VALUES ('favorites', 'owner', 'Favorites', ''), ('favorites', 'other', 'Favorites', '');
22+
INSERT INTO playlist_video_member (account_id, playlist_id, video_id)
23+
VALUES ('owner', 'favorites', 'video'), ('other', 'favorites', 'video');
24+
INSERT INTO subscription_group (id, account_id, title)
25+
VALUES ('group', 'owner', 'Original');
26+
INSERT INTO subscription_group_member (subscription_group_id, channel_id)
27+
VALUES ('group', 'channel');",
28+
)?;
29+
Ok(())
30+
})
31+
.await
32+
.unwrap();
33+
conn
34+
}
35+
36+
#[actix_rt::test]
37+
async fn deleting_a_playlist_preserves_other_accounts_with_the_same_id() {
38+
let mut conn = connection().await;
39+
playlist::delete_playlist_by_id(&mut conn, "favorites", "owner")
40+
.await
41+
.unwrap();
42+
43+
assert!(
44+
playlist::get_playlist_by_id(&mut conn, "favorites", "owner")
45+
.await
46+
.unwrap()
47+
.is_none()
48+
);
49+
assert_eq!(
50+
playlist::get_playlist_video_count(&mut conn, "favorites", "owner")
51+
.await
52+
.unwrap(),
53+
0
54+
);
55+
let (_, videos) = playlist::get_playlist_by_id_with_videos(&mut conn, "favorites", "other")
56+
.await
57+
.unwrap()
58+
.expect("the other account's playlist must remain");
59+
assert_eq!(videos.len(), 1);
60+
}
61+
62+
#[actix_rt::test]
63+
async fn updating_a_group_requires_ownership_and_preserves_members() {
64+
let mut conn = connection().await;
65+
let result = subscription_groups::update_existing_subscription_group(
66+
&mut conn,
67+
SubscriptionGroup {
68+
id: "group".into(),
69+
account_id: "other".into(),
70+
title: "Stolen".into(),
71+
},
72+
)
73+
.await;
74+
assert!(matches!(result, Err(diesel::result::Error::NotFound)));
75+
76+
let group = subscription_groups::update_existing_subscription_group(
77+
&mut conn,
78+
SubscriptionGroup {
79+
id: "group".into(),
80+
account_id: "owner".into(),
81+
title: "Renamed".into(),
82+
},
83+
)
84+
.await
85+
.unwrap();
86+
assert_eq!(group.title, "Renamed");
87+
assert_eq!(group.account_id, "owner");
88+
let groups = subscription_groups::get_subscription_groups_by_account_id(&mut conn, "owner")
89+
.await
90+
.unwrap();
91+
assert_eq!(groups[0].1.len(), 1);
92+
assert!(
93+
subscription_groups::get_subscription_groups_by_account_id(&mut conn, "other")
94+
.await
95+
.unwrap()
96+
.is_empty()
97+
);
98+
}
99+
100+
#[actix_rt::test]
101+
async fn deleting_a_group_only_removes_its_owners_members() {
102+
let mut conn = connection().await;
103+
subscription_groups::delete_subscription_group_by_id(&mut conn, "group", "other")
104+
.await
105+
.unwrap();
106+
let groups = subscription_groups::get_subscription_groups_by_account_id(&mut conn, "owner")
107+
.await
108+
.unwrap();
109+
assert_eq!(groups.len(), 1);
110+
assert_eq!(groups[0].1.len(), 1);
111+
112+
subscription_groups::delete_subscription_group_by_id(&mut conn, "group", "owner")
113+
.await
114+
.unwrap();
115+
assert!(
116+
subscription_groups::get_subscription_groups_by_account_id(&mut conn, "owner")
117+
.await
118+
.unwrap()
119+
.is_empty()
120+
);
121+
assert!(
122+
subscription_groups::get_subscription_group_channels_by_id(&mut conn, "group")
123+
.await
124+
.unwrap()
125+
.is_empty()
126+
);
127+
}

src/database/playlist.rs

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -60,19 +60,8 @@ pub async fn delete_playlist_by_id(
6060
playlist_id_: &str,
6161
account_id_: &str,
6262
) -> Result<(), DbError> {
63-
// delete linked videos first to ensure database integrity
64-
// TODO: use ON DELETE CASCADE
65-
diesel::delete(
66-
playlist_video_member.filter(
67-
playlist_id
68-
.eq(playlist_id_.to_string())
69-
.and(playlist_video_member_account_id.eq(account_id_)),
70-
),
71-
)
72-
.execute(conn)
73-
.await?;
74-
75-
diesel::delete(playlist.filter(id.eq(playlist_id_.to_string())))
63+
// The composite foreign key cascades only this account's video memberships.
64+
diesel::delete(playlist.filter(id.eq(playlist_id_).and(playlist_account_id.eq(account_id_))))
7665
.execute(conn)
7766
.await?;
7867

src/database/subscription_groups.rs

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ pub async fn update_existing_subscription_group(
8787
) -> Result<SubscriptionGroup, DbError> {
8888
diesel::update(subscription_group)
8989
.filter(id.eq(subscription_group_.id.clone()))
90-
.set(subscription_group_)
90+
.filter(account_id.eq(&subscription_group_.account_id))
91+
.set(title.eq(&subscription_group_.title))
9192
.returning(SubscriptionGroup::as_returning())
9293
.get_result(conn)
9394
.await
@@ -98,13 +99,7 @@ pub async fn delete_subscription_group_by_id(
9899
subscription_group_id_: &str,
99100
account_id_: &str,
100101
) -> Result<(), DbError> {
101-
// delete all linked channels first to ensure database integrity
102-
// TODO: use ON DELETE CASCADE
103-
diesel::delete(subscription_group_member)
104-
.filter(subscription_group_id.eq(subscription_group_id_))
105-
.execute(conn)
106-
.await?;
107-
102+
// Memberships cascade only after an owned group has been deleted.
108103
diesel::delete(subscription_group)
109104
.filter(
110105
id.eq(subscription_group_id_)

src/handlers/subscriptions.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@ async fn update_subscription_group(
295295

296296
match update_existing_subscription_group(&mut conn, subscription_group).await {
297297
Ok(group) => Ok(HttpResponse::Ok().json(group)),
298+
Err(diesel::result::Error::NotFound) => Err(HandlerError::SubscriptionGroupNotFound),
298299
Err(err) => Err(HandlerError::InternalDatabaseErrorWithContext(
299300
err.to_string(),
300301
)),

0 commit comments

Comments
 (0)