Add macos route and some other tests I guess

This commit is contained in:
Ethan O'Brien
2026-08-28 21:40:26 -05:00
parent 2ceff8ffc6
commit 971aa32361
9 changed files with 235 additions and 14 deletions
+9
View File
@@ -660,6 +660,9 @@ pub fn purge_song(music_id: i64) {
// exists (browse renders an uploader name for every row). Same steps as the
// owner's own delete, blob GC included
pub fn purge_owner(uid: i64) {
if uid <= 0 {
return;
}
if disabled() {
return;
}
@@ -715,6 +718,12 @@ pub fn sweep_blobs() {
}
}
// Nothing referenced means the catalog is empty (or gone): a sweep would then delete
// every blob on disk, so it is skipped until the catalog has rows again
if referenced.is_empty() {
println!("Custom 3DMV blob sweep: catalog empty, skipped");
return;
}
// No directory means nothing was ever uploaded
let Ok(entries) = fs::read_dir(get_data_path("custom_3dmv/blobs")) else {
return;
+3
View File
@@ -1545,6 +1545,9 @@ pub fn delete_character(uid: i64, master_character_id: i64) -> Result<(), String
// serving a dangling master_character_id, which the client throws on. Those stay,
// ownerless, which is the same trade delete_character already makes
pub fn purge_owner(uid: i64) {
if uid <= 0 {
return;
}
if disabled() {
return;
}
+126
View File
@@ -243,6 +243,12 @@ pub fn sweep_audio() {
}
}
// Nothing referenced means the catalog is empty (or gone): a sweep would then delete
// every ogg on disk, so it is skipped until the catalog has rows again
if referenced.is_empty() {
println!("Custom song audio sweep: catalog empty, skipped");
return;
}
// No directory means nothing was ever uploaded
let Ok(entries) = fs::read_dir(get_data_path("custom_songs/audio")) else {
return;
@@ -1334,6 +1340,9 @@ async fn delete(req: HttpRequest, body: String) -> HttpResponse {
// the owner's own delete does, including the cross-feature MV cascade and the
// content-addressed audio GC
pub fn purge_owner(uid: i64) {
if uid <= 0 {
return;
}
if disabled() {
return;
}
@@ -2724,6 +2733,123 @@ mod tests {
assert!(crate::database::custom_card::get_character(character_id).is_none(), "the character survived the purge");
}
// A purge is scoped to ONE owner: nothing another account uploaded, nor an
// ogg two accounts share, may go with it - and a degenerate uid, a broken
// catalog or a disabled feature is a no-op, never a wildcard
#[test]
fn purging_one_account_leaves_other_accounts_uploads_alone() {
let _lock = crate::runtime::lock_test_data_path();
use crate::database::custom_card as card_db;
use crate::database::custom_3dmv as mv_db;
let victim = userdata::get_acc("custom-content-purge-victim")["user"]["id"].as_i64().unwrap();
let bystander = userdata::get_acc("custom-content-purge-bystander")["user"]["id"].as_i64().unwrap();
assert_ne!(victim, bystander);
for uid in [victim, bystander] {
purge_owner(uid);
crate::router::custom_card::purge_owner(uid);
crate::router::custom_3dmv::purge_owner(uid);
}
// One song each, plus one song each with byte-identical audio so the two
// owners share a single content-addressed ogg
let v_song = create_song(victim, &song_fields("Purge Victim", 401.0)).unwrap();
let b_song = create_song(bystander, &song_fields("Purge Bystander", 409.0)).unwrap();
let v_shared = create_song(victim, &song_fields("Purge Victim Shared", 419.0)).unwrap();
let b_shared = create_song(bystander, &song_fields("Purge Bystander Shared", 419.0)).unwrap();
let b_play = database::get_song(b_song).unwrap()["sound"]["play"]["md5"].to_string();
let shared_play = database::get_song(b_shared).unwrap()["sound"]["play"]["md5"].to_string();
assert_eq!(database::get_song(v_shared).unwrap()["sound"]["play"]["md5"].to_string(), shared_play);
// Cards and characters criss-crossed between the two owners
let b_char = card_db::next_character_id();
card_db::insert_character(b_char, bystander, &object!{ "master_character_id": b_char, "name": "B" }).unwrap();
let b_card = card_db::next_card_id();
card_db::insert_card(b_card, b_char, bystander, &object!{ "master_card_id": b_card, "rarity": 1 }, true, true).unwrap();
let v_card_on_b_char = card_db::next_card_id();
card_db::insert_card(v_card_on_b_char, b_char, victim, &object!{ "master_card_id": v_card_on_b_char, "rarity": 1 }, true, true).unwrap();
let v_char = card_db::next_character_id();
card_db::insert_character(v_char, victim, &object!{ "master_character_id": v_char, "name": "V" }).unwrap();
let b_card_on_v_char = card_db::next_card_id();
card_db::insert_card(b_card_on_v_char, v_char, bystander, &object!{ "master_card_id": b_card_on_v_char, "rarity": 1 }, true, true).unwrap();
let b_mv = mv_db::next_mv_id();
mv_db::insert_mv(b_mv, b_song, bystander, &object!{ "mv_id": b_mv, "music_id": b_song, "files": [] }, true).unwrap();
userdata::delete_account(victim);
assert!(database::get_song(v_song).is_none(), "the victim's song survived");
assert!(database::get_song(v_shared).is_none(), "the victim's shared-audio song survived");
assert!(card_db::get_card(v_card_on_b_char).is_none(), "the victim's card survived");
assert!(database::get_song(b_song).is_some(), "the bystander's song was deleted");
assert!(database::get_song(b_shared).is_some(), "the bystander's shared-audio song was deleted");
assert!(fs::metadata(get_data_path(&format!("custom_songs/{}", b_song))).is_ok(), "the bystander's song directory was deleted");
assert!(fs::read(audio_file_path(&b_play)).is_ok(), "the bystander's ogg was unlinked");
assert!(fs::read(audio_file_path(&shared_play)).is_ok(), "the cross-owner shared ogg was unlinked");
assert!(card_db::get_card(b_card).is_some(), "the bystander's card was deleted");
assert!(card_db::get_character(b_char).is_some(), "the bystander's character was deleted");
assert!(card_db::get_card(b_card_on_v_char).is_some(), "the bystander's card on the victim's character was deleted");
assert!(card_db::get_character(v_char).is_some(), "a character another account's card still uses was deleted");
assert!(mv_db::get_mv(b_mv).is_some(), "the bystander's MV was deleted");
purge_owner(0);
crate::router::custom_card::purge_owner(0);
crate::router::custom_3dmv::purge_owner(0);
purge_owner(-1);
userdata::delete_account(0);
with_songs_table_broken(|| purge_owner(bystander));
crate::runtime::set_enable_custom_songs(false);
crate::runtime::set_enable_custom_cards(false);
crate::runtime::set_enable_custom_3dmv(false);
purge_owner(bystander);
crate::router::custom_card::purge_owner(bystander);
crate::router::custom_3dmv::purge_owner(bystander);
crate::runtime::set_enable_custom_songs(true);
crate::runtime::set_enable_custom_cards(true);
crate::runtime::set_enable_custom_3dmv(true);
assert!(database::get_song(b_song).is_some(), "a degenerate, broken-catalog or feature-off purge deleted a song");
assert!(fs::read(audio_file_path(&b_play)).is_ok(), "a degenerate, broken-catalog or feature-off purge unlinked a live ogg");
assert!(card_db::get_card(b_card).is_some(), "a degenerate or feature-off purge deleted a card");
assert!(card_db::get_character(b_char).is_some(), "a degenerate or feature-off purge deleted a character");
assert!(mv_db::get_mv(b_mv).is_some(), "a degenerate or feature-off purge deleted an MV");
// Leave nothing behind: other tests assume an empty obtainable-card pool
purge_owner(bystander);
crate::router::custom_card::purge_owner(bystander);
crate::router::custom_3dmv::purge_owner(bystander);
}
// The pruning get_acc runs on every login must read an unreadable or blank
// catalog as "unknown", never as "every custom song is dead", and the startup
// sweep must never unlink files when the catalog references nothing
#[test]
fn an_unreadable_or_blank_catalog_never_prunes_or_sweeps() {
let _lock = crate::runtime::lock_test_data_path();
let uid = userdata::get_acc("custom-content-blank-catalog")["user"]["id"].as_i64().unwrap();
purge_owner(uid);
let music_id = create_song(uid, &song_fields("Blank Catalog", 383.0)).unwrap();
let play = database::get_song(music_id).unwrap()["sound"]["play"]["md5"].to_string();
let live_ogg = audio_file_path(&play);
let unreadable = with_songs_table_broken(|| database::dead_music_ids(&array![music_id]));
assert!(unreadable.is_none(), "an unreadable catalog reported dead songs");
with_songs_table_broken(|| sweep_audio());
assert!(fs::read(&live_ogg).is_ok(), "a sweep over an unreadable catalog unlinked a live ogg");
let conn = rusqlite::Connection::open(database::test_db_path()).unwrap();
conn.execute("ALTER TABLE songs RENAME TO songs_parked", ()).unwrap();
conn.execute("CREATE TABLE songs (music_id BIGINT NOT NULL PRIMARY KEY, owner_id BIGINT NOT NULL, song TEXT NOT NULL, visibility TEXT NOT NULL DEFAULT 'public', downloads_disabled INT NOT NULL DEFAULT 0)", ()).unwrap();
let blank = database::dead_music_ids(&array![music_id]);
sweep_audio();
let survived = fs::read(&live_ogg).is_ok();
conn.execute("DROP TABLE songs", ()).unwrap();
conn.execute("ALTER TABLE songs_parked RENAME TO songs", ()).unwrap();
assert!(blank.is_none(), "a blank catalog reported every song dead");
assert!(survived, "a sweep over a blank catalog unlinked a live ogg");
assert_eq!(database::dead_music_ids(&array![music_id]).unwrap().len(), 0);
purge_owner(uid);
}
// D1/D6/D7: the whole upload route, end to end - a real multipart body, a real
// session, the expansion of a real package, and the create running on the
+34 -2
View File
@@ -49,6 +49,10 @@ static ASSET_VERSIONS: &[AssetVersion] = &[
AssetVersion { region: "JP", platform: "Windows", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "b8d0e7edcb63f5bdd28817a597772ca6", latest: true },
AssetVersion { region: "JP", platform: "Android", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "d12cecc5695da7f81f8873a3ff93752e", latest: true },
AssetVersion { region: "JP", platform: "iOS", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "7c1f61ee68ac84c82dd397a162629142", latest: true },
// Linux / macOS players (StandaloneLinux64 / StandaloneOSX lanes): no baked hash, the
// running build's hash is supplied with --linux-asset-hash / --mac-asset-hash
AssetVersion { region: "JP", platform: "Linux", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "", latest: true },
AssetVersion { region: "JP", platform: "Mac", version: "01a71b00f63e4dba92117ac7e60070a6", hash: "", latest: true },
//AssetVersion { region: "JP", platform: "WebGL", version: "4c921d2443335e574a82e04ec9ea243c", hash: "e1ff7c74b20c8d216507972b6f24b9df", latest: true },
];
@@ -68,6 +72,8 @@ impl AssetVersion {
let ov = args.asset_version.as_str();
let oh = match (self.region, self.platform) {
("JP", "Windows") => args.windows_asset_hash.as_str(),
("JP", "Linux") => args.linux_asset_hash.as_str(),
("JP", "Mac") => args.mac_asset_hash.as_str(),
("JP", "Android") => args.jp_android_asset_hash.as_str(),
("JP", "iOS") => args.jp_ios_asset_hash.as_str(),
("GL", "Android") => args.en_android_asset_hash.as_str(),
@@ -89,7 +95,7 @@ fn valid_hashes(asset_version: &str, platform: &str) -> Vec<String> {
if entry.platform != platform {
continue;
}
if entry.version == asset_version {
if entry.version == asset_version && !entry.hash.is_empty() {
out.push(entry.stock_hash());
}
if entry.latest {
@@ -116,7 +122,7 @@ fn preferred_hash(asset_version: &str, platform: &str) -> Option<String> {
}
}
}
if entry.version == asset_version && stock.is_none() {
if entry.version == asset_version && stock.is_none() && !entry.hash.is_empty() {
stock = Some(entry.stock_hash());
}
}
@@ -169,6 +175,8 @@ pub fn parse_platform(header: &str) -> &str {
"iphone" => "iOS",
"windows" => "Windows",
"windowsplayer" => "Windows",
"linuxplayer" => "Linux",
"osxplayer" => "Mac",
"webglplayer" => "WebGL",
"editor" => "Editor",
"windowseditor" => "Editor",
@@ -471,3 +479,27 @@ pub(crate) fn get_cards(arr: JsonValue, user: &JsonValue) -> JsonValue {
}
rv
}
#[cfg(test)]
mod platform_tests {
use super::*;
#[test]
fn standalone_player_platforms_are_known() {
assert_eq!(parse_platform("WindowsPlayer"), "Windows");
assert_eq!(parse_platform("LinuxPlayer"), "Linux");
assert_eq!(parse_platform("OSXPlayer"), "Mac");
assert_eq!(parse_platform("OSXEditor"), "Editor");
}
// Linux / macOS have no baked hash: without the CLI override the platform has no valid
// hash at all (never an empty string presented as one)
#[test]
fn unhashed_platforms_answer_nothing_without_an_override() {
let latest = ASSET_VERSIONS.iter().find(|e| e.latest && e.platform == "Windows").unwrap().version;
assert_eq!(preferred_hash(latest, "Mac"), None);
assert_eq!(preferred_hash(latest, "Linux"), None);
assert!(valid_hashes(latest, "Mac").is_empty());
assert!(preferred_hash(latest, "Windows").is_some());
}
}
+6 -2
View File
@@ -268,7 +268,9 @@ fn remove_deleted_custom_songs(user: &mut JsonValue) -> bool {
if candidates.is_empty() {
return false;
}
let dead = custom_song::dead_music_ids(&candidates);
let Some(dead) = custom_song::dead_music_ids(&candidates) else {
return false;
};
if dead.is_empty() {
return false;
}
@@ -301,7 +303,9 @@ fn remove_deleted_custom_cards(user: &mut JsonValue) -> bool {
if candidates.is_empty() {
return false;
}
let dead = custom_card::dead_card_ids(&candidates);
let Some(dead) = custom_card::dead_card_ids(&candidates) else {
return false;
};
if dead.is_empty() {
return false;
}