From f7b4b782bf6089284869a79ecb075ef8c868e57d Mon Sep 17 00:00:00 2001 From: Wyatt Verchere Date: Wed, 20 Dec 2023 14:27:57 -0800 Subject: [PATCH] Organization ownership (#796) * organization changes * changes * fixes failing test * version changes * removed printlns * add_team_member comes pre-accepted * no notification on force accept * fixes tests * merge fixes --- ...747c60aca09862d39590431ac12f6ad585aab.json | 23 ++ ...54abf0f67f225622935802379de495dce18b.json} | 8 +- ...1525ed4f616d17bb3aa76430e95492caa5c74.json | 22 ++ ...7d8cd467e589504c6e754f1f6836203946590.json | 22 ++ ...0d83da45214984f8a2ec48f3f1343a28240e.json} | 7 +- ...7703fe59b218c851aaee02ba89f30385a315.json} | 6 +- ...19cf7caf9a0ebc53b0232c6107b2995453c14.json | 23 ++ ...ae92ebe04b0d4d1fe1762208167cee23b645.json} | 5 +- ...3feb0a3eb432ed2b9d3be0e7a0bc6b2421cdd.json | 22 ++ ...5768180e8d4fc103239806d2da7ea2540e5d.json} | 7 +- ...d9bb356455b6c0b87cdf1b43fa576ccc5bef5.json | 14 + ...1667baa7bb9157f58a5017e710d09fd295eb0.json | 14 + ...8b259e0226e7dac16c635927ca74abc78cea9.json | 23 -- .../20231213103100_enforces-owner-unique.sql | 10 + src/auth/checks.rs | 43 +- src/database/models/ids.rs | 3 - src/database/models/loader_fields.rs | 6 - src/database/models/organization_item.rs | 22 -- src/database/models/team_item.rs | 28 +- src/routes/v2/teams.rs | 1 + src/routes/v3/organizations.rs | 173 +++++++- src/routes/v3/teams.rs | 140 +++++-- src/routes/v3/version_creation.rs | 2 + src/routes/v3/version_file.rs | 1 + src/routes/v3/versions.rs | 2 + tests/common/api_v3/organization.rs | 6 +- tests/common/api_v3/team.rs | 10 + tests/common/permissions.rs | 6 +- tests/organizations.rs | 373 +++++++++++++++++- tests/scopes.rs | 10 +- tests/teams.rs | 3 +- 31 files changed, 910 insertions(+), 125 deletions(-) create mode 100644 .sqlx/query-1997511538affe27f229df10ddc747c60aca09862d39590431ac12f6ad585aab.json rename .sqlx/{query-6ed8a0eadaa72fafc49538ed9be33c9621a763d2d4e1fbd1f541be50b48db4d2.json => query-23829a50afd2a4fd1b2d1770d2f854abf0f67f225622935802379de495dce18b.json} (51%) create mode 100644 .sqlx/query-26c8f1dbb233bfcdc555344e9d41525ed4f616d17bb3aa76430e95492caa5c74.json create mode 100644 .sqlx/query-2b097a9a1b24b9648d3558e348c7d8cd467e589504c6e754f1f6836203946590.json rename .sqlx/{query-2e3ce3eafee2cd110085a94b122884fe25591aa6f48256abbb6c8d973efe932e.json => query-389a48e2e5dec4370013f3cada400d83da45214984f8a2ec48f3f1343a28240e.json} (87%) rename .sqlx/{query-957d0b3f6ad7d20f54548b05e82935cd18adc723f819fd071d8c97ec3885381a.json => query-7c88d4f3a4342676773ae0c90ec17703fe59b218c851aaee02ba89f30385a315.json} (50%) create mode 100644 .sqlx/query-7f039929345f6421ab7de7a75ac19cf7caf9a0ebc53b0232c6107b2995453c14.json rename .sqlx/{query-cb82bb6e22690fd5fee18bbc2975503371814ef1cbf95f32c195bfe7542b2b20.json => query-8d6166509c910b6400efc1d1398aae92ebe04b0d4d1fe1762208167cee23b645.json} (61%) create mode 100644 .sqlx/query-d3c5adda017df70a88983baa82e3feb0a3eb432ed2b9d3be0e7a0bc6b2421cdd.json rename .sqlx/{query-740c4343d7357af6820e28a3e1f165cbbc3f967c4dfbeeb13a0c63f78e072895.json => query-ebdcc29fc24bd31514ccdf0202a35768180e8d4fc103239806d2da7ea2540e5d.json} (88%) create mode 100644 .sqlx/query-ee6b35a83723e0b753bd30819bad9bb356455b6c0b87cdf1b43fa576ccc5bef5.json create mode 100644 .sqlx/query-f40153d1516fe93e4179c14d00e1667baa7bb9157f58a5017e710d09fd295eb0.json delete mode 100644 .sqlx/query-fa1b92b15cc108fa046998f789c8b259e0226e7dac16c635927ca74abc78cea9.json create mode 100644 migrations/20231213103100_enforces-owner-unique.sql diff --git a/.sqlx/query-1997511538affe27f229df10ddc747c60aca09862d39590431ac12f6ad585aab.json b/.sqlx/query-1997511538affe27f229df10ddc747c60aca09862d39590431ac12f6ad585aab.json new file mode 100644 index 000000000..635fbd408 --- /dev/null +++ b/.sqlx/query-1997511538affe27f229df10ddc747c60aca09862d39590431ac12f6ad585aab.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(\n SELECT 1 FROM mods m \n INNER JOIN organizations o ON m.organization_id = o.id\n INNER JOIN team_members tm ON tm.team_id = o.team_id AND user_id = $2 \n WHERE m.id = $1\n )", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "1997511538affe27f229df10ddc747c60aca09862d39590431ac12f6ad585aab" +} diff --git a/.sqlx/query-6ed8a0eadaa72fafc49538ed9be33c9621a763d2d4e1fbd1f541be50b48db4d2.json b/.sqlx/query-23829a50afd2a4fd1b2d1770d2f854abf0f67f225622935802379de495dce18b.json similarity index 51% rename from .sqlx/query-6ed8a0eadaa72fafc49538ed9be33c9621a763d2d4e1fbd1f541be50b48db4d2.json rename to .sqlx/query-23829a50afd2a4fd1b2d1770d2f854abf0f67f225622935802379de495dce18b.json index 8b1c27b1f..8f77eadb0 100644 --- a/.sqlx/query-6ed8a0eadaa72fafc49538ed9be33c9621a763d2d4e1fbd1f541be50b48db4d2.json +++ b/.sqlx/query-23829a50afd2a4fd1b2d1770d2f854abf0f67f225622935802379de495dce18b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT m.id id, m.team_id team_id FROM team_members tm\n INNER JOIN mods m ON m.team_id = tm.team_id\n LEFT JOIN organizations o ON o.team_id = tm.team_id\n WHERE (tm.team_id = ANY($1) or o.id = ANY($2)) AND tm.user_id = $3\n ", + "query": "\n SELECT m.id id, m.team_id team_id FROM team_members tm\n INNER JOIN mods m ON m.team_id = tm.team_id\n WHERE tm.team_id = ANY($1) AND tm.user_id = $3\n\n UNION\n\n SELECT m.id id, m.team_id team_id FROM team_members tm\n INNER JOIN organizations o ON o.team_id = tm.team_id\n INNER JOIN mods m ON m.organization_id = o.id\n WHERE o.id = ANY($2) AND tm.user_id = $3\n ", "describe": { "columns": [ { @@ -22,9 +22,9 @@ ] }, "nullable": [ - false, - false + null, + null ] }, - "hash": "6ed8a0eadaa72fafc49538ed9be33c9621a763d2d4e1fbd1f541be50b48db4d2" + "hash": "23829a50afd2a4fd1b2d1770d2f854abf0f67f225622935802379de495dce18b" } diff --git a/.sqlx/query-26c8f1dbb233bfcdc555344e9d41525ed4f616d17bb3aa76430e95492caa5c74.json b/.sqlx/query-26c8f1dbb233bfcdc555344e9d41525ed4f616d17bb3aa76430e95492caa5c74.json new file mode 100644 index 000000000..69fc76be5 --- /dev/null +++ b/.sqlx/query-26c8f1dbb233bfcdc555344e9d41525ed4f616d17bb3aa76430e95492caa5c74.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT m.team_id FROM organizations o\n INNER JOIN mods m ON m.organization_id = o.id\n WHERE o.id = $1 AND $1 IS NOT NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "team_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "26c8f1dbb233bfcdc555344e9d41525ed4f616d17bb3aa76430e95492caa5c74" +} diff --git a/.sqlx/query-2b097a9a1b24b9648d3558e348c7d8cd467e589504c6e754f1f6836203946590.json b/.sqlx/query-2b097a9a1b24b9648d3558e348c7d8cd467e589504c6e754f1f6836203946590.json new file mode 100644 index 000000000..1ccb38543 --- /dev/null +++ b/.sqlx/query-2b097a9a1b24b9648d3558e348c7d8cd467e589504c6e754f1f6836203946590.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT u.id \n FROM team_members\n INNER JOIN users u ON u.id = team_members.user_id\n WHERE team_id = $1 AND is_owner = TRUE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "2b097a9a1b24b9648d3558e348c7d8cd467e589504c6e754f1f6836203946590" +} diff --git a/.sqlx/query-2e3ce3eafee2cd110085a94b122884fe25591aa6f48256abbb6c8d973efe932e.json b/.sqlx/query-389a48e2e5dec4370013f3cada400d83da45214984f8a2ec48f3f1343a28240e.json similarity index 87% rename from .sqlx/query-2e3ce3eafee2cd110085a94b122884fe25591aa6f48256abbb6c8d973efe932e.json rename to .sqlx/query-389a48e2e5dec4370013f3cada400d83da45214984f8a2ec48f3f1343a28240e.json index 3580feb9e..31403a772 100644 --- a/.sqlx/query-2e3ce3eafee2cd110085a94b122884fe25591aa6f48256abbb6c8d973efe932e.json +++ b/.sqlx/query-389a48e2e5dec4370013f3cada400d83da45214984f8a2ec48f3f1343a28240e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.is_owner, tm.permissions, tm.organization_permissions, tm.accepted, tm.payouts_split, tm.ordering\n FROM organizations o\n INNER JOIN team_members tm ON tm.team_id = o.team_id AND user_id = $2 AND accepted = TRUE\n WHERE o.id = $1\n ", + "query": "\n SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.is_owner, tm.permissions, tm.organization_permissions, tm.accepted, tm.payouts_split, tm.ordering\n FROM organizations o\n INNER JOIN team_members tm ON tm.team_id = o.team_id AND user_id = $2 AND accepted = ANY($3)\n WHERE o.id = $1\n ", "describe": { "columns": [ { @@ -57,7 +57,8 @@ "parameters": { "Left": [ "Int8", - "Int8" + "Int8", + "BoolArray" ] }, "nullable": [ @@ -73,5 +74,5 @@ false ] }, - "hash": "2e3ce3eafee2cd110085a94b122884fe25591aa6f48256abbb6c8d973efe932e" + "hash": "389a48e2e5dec4370013f3cada400d83da45214984f8a2ec48f3f1343a28240e" } diff --git a/.sqlx/query-957d0b3f6ad7d20f54548b05e82935cd18adc723f819fd071d8c97ec3885381a.json b/.sqlx/query-7c88d4f3a4342676773ae0c90ec17703fe59b218c851aaee02ba89f30385a315.json similarity index 50% rename from .sqlx/query-957d0b3f6ad7d20f54548b05e82935cd18adc723f819fd071d8c97ec3885381a.json rename to .sqlx/query-7c88d4f3a4342676773ae0c90ec17703fe59b218c851aaee02ba89f30385a315.json index 668a65f1f..7c9a68b52 100644 --- a/.sqlx/query-957d0b3f6ad7d20f54548b05e82935cd18adc723f819fd071d8c97ec3885381a.json +++ b/.sqlx/query-7c88d4f3a4342676773ae0c90ec17703fe59b218c851aaee02ba89f30385a315.json @@ -1,11 +1,11 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT m.id\n FROM mods m\n WHERE m.organization_id = $1\n ", + "query": "\n SELECT user_id FROM team_members\n WHERE team_id = $1 AND is_owner = TRUE\n ", "describe": { "columns": [ { "ordinal": 0, - "name": "id", + "name": "user_id", "type_info": "Int8" } ], @@ -18,5 +18,5 @@ false ] }, - "hash": "957d0b3f6ad7d20f54548b05e82935cd18adc723f819fd071d8c97ec3885381a" + "hash": "7c88d4f3a4342676773ae0c90ec17703fe59b218c851aaee02ba89f30385a315" } diff --git a/.sqlx/query-7f039929345f6421ab7de7a75ac19cf7caf9a0ebc53b0232c6107b2995453c14.json b/.sqlx/query-7f039929345f6421ab7de7a75ac19cf7caf9a0ebc53b0232c6107b2995453c14.json new file mode 100644 index 000000000..3dad0e1c2 --- /dev/null +++ b/.sqlx/query-7f039929345f6421ab7de7a75ac19cf7caf9a0ebc53b0232c6107b2995453c14.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(\n SELECT 1 FROM mods m \n INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 \n WHERE m.id = $1\n )", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "7f039929345f6421ab7de7a75ac19cf7caf9a0ebc53b0232c6107b2995453c14" +} diff --git a/.sqlx/query-cb82bb6e22690fd5fee18bbc2975503371814ef1cbf95f32c195bfe7542b2b20.json b/.sqlx/query-8d6166509c910b6400efc1d1398aae92ebe04b0d4d1fe1762208167cee23b645.json similarity index 61% rename from .sqlx/query-cb82bb6e22690fd5fee18bbc2975503371814ef1cbf95f32c195bfe7542b2b20.json rename to .sqlx/query-8d6166509c910b6400efc1d1398aae92ebe04b0d4d1fe1762208167cee23b645.json index fef6fe522..036866616 100644 --- a/.sqlx/query-cb82bb6e22690fd5fee18bbc2975503371814ef1cbf95f32c195bfe7542b2b20.json +++ b/.sqlx/query-8d6166509c910b6400efc1d1398aae92ebe04b0d4d1fe1762208167cee23b645.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO team_members (\n id, team_id, user_id, role, permissions, organization_permissions, accepted\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7\n )\n ", + "query": "\n INSERT INTO team_members (\n id, team_id, user_id, role, permissions, organization_permissions, is_owner, accepted\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8\n )\n ", "describe": { "columns": [], "parameters": { @@ -11,10 +11,11 @@ "Varchar", "Int8", "Int8", + "Bool", "Bool" ] }, "nullable": [] }, - "hash": "cb82bb6e22690fd5fee18bbc2975503371814ef1cbf95f32c195bfe7542b2b20" + "hash": "8d6166509c910b6400efc1d1398aae92ebe04b0d4d1fe1762208167cee23b645" } diff --git a/.sqlx/query-d3c5adda017df70a88983baa82e3feb0a3eb432ed2b9d3be0e7a0bc6b2421cdd.json b/.sqlx/query-d3c5adda017df70a88983baa82e3feb0a3eb432ed2b9d3be0e7a0bc6b2421cdd.json new file mode 100644 index 000000000..dd6dbd78a --- /dev/null +++ b/.sqlx/query-d3c5adda017df70a88983baa82e3feb0a3eb432ed2b9d3be0e7a0bc6b2421cdd.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT t.id FROM organizations o\n INNER JOIN mods m ON m.organization_id = o.id\n INNER JOIN teams t ON t.id = m.team_id\n WHERE o.id = $1 AND $1 IS NOT NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "d3c5adda017df70a88983baa82e3feb0a3eb432ed2b9d3be0e7a0bc6b2421cdd" +} diff --git a/.sqlx/query-740c4343d7357af6820e28a3e1f165cbbc3f967c4dfbeeb13a0c63f78e072895.json b/.sqlx/query-ebdcc29fc24bd31514ccdf0202a35768180e8d4fc103239806d2da7ea2540e5d.json similarity index 88% rename from .sqlx/query-740c4343d7357af6820e28a3e1f165cbbc3f967c4dfbeeb13a0c63f78e072895.json rename to .sqlx/query-ebdcc29fc24bd31514ccdf0202a35768180e8d4fc103239806d2da7ea2540e5d.json index 82e5ec338..e9788a309 100644 --- a/.sqlx/query-740c4343d7357af6820e28a3e1f165cbbc3f967c4dfbeeb13a0c63f78e072895.json +++ b/.sqlx/query-ebdcc29fc24bd31514ccdf0202a35768180e8d4fc103239806d2da7ea2540e5d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.is_owner, tm.permissions, tm.organization_permissions, tm.accepted, tm.payouts_split, tm.ordering\n FROM mods m\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 AND accepted = TRUE\n WHERE m.id = $1\n ", + "query": "\n SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.is_owner, tm.permissions, tm.organization_permissions, tm.accepted, tm.payouts_split, tm.ordering\n FROM mods m\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 AND accepted = ANY($3)\n WHERE m.id = $1\n ", "describe": { "columns": [ { @@ -57,7 +57,8 @@ "parameters": { "Left": [ "Int8", - "Int8" + "Int8", + "BoolArray" ] }, "nullable": [ @@ -73,5 +74,5 @@ false ] }, - "hash": "740c4343d7357af6820e28a3e1f165cbbc3f967c4dfbeeb13a0c63f78e072895" + "hash": "ebdcc29fc24bd31514ccdf0202a35768180e8d4fc103239806d2da7ea2540e5d" } diff --git a/.sqlx/query-ee6b35a83723e0b753bd30819bad9bb356455b6c0b87cdf1b43fa576ccc5bef5.json b/.sqlx/query-ee6b35a83723e0b753bd30819bad9bb356455b6c0b87cdf1b43fa576ccc5bef5.json new file mode 100644 index 000000000..d80246019 --- /dev/null +++ b/.sqlx/query-ee6b35a83723e0b753bd30819bad9bb356455b6c0b87cdf1b43fa576ccc5bef5.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE team_members\n SET is_owner = FALSE\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "ee6b35a83723e0b753bd30819bad9bb356455b6c0b87cdf1b43fa576ccc5bef5" +} diff --git a/.sqlx/query-f40153d1516fe93e4179c14d00e1667baa7bb9157f58a5017e710d09fd295eb0.json b/.sqlx/query-f40153d1516fe93e4179c14d00e1667baa7bb9157f58a5017e710d09fd295eb0.json new file mode 100644 index 000000000..c1d945241 --- /dev/null +++ b/.sqlx/query-f40153d1516fe93e4179c14d00e1667baa7bb9157f58a5017e710d09fd295eb0.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE team_members\n SET \n is_owner = TRUE,\n accepted = TRUE,\n permissions = $1,\n organization_permissions = NULL,\n role = 'Inherited Owner'\n WHERE (id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "f40153d1516fe93e4179c14d00e1667baa7bb9157f58a5017e710d09fd295eb0" +} diff --git a/.sqlx/query-fa1b92b15cc108fa046998f789c8b259e0226e7dac16c635927ca74abc78cea9.json b/.sqlx/query-fa1b92b15cc108fa046998f789c8b259e0226e7dac16c635927ca74abc78cea9.json deleted file mode 100644 index bcc6250bc..000000000 --- a/.sqlx/query-fa1b92b15cc108fa046998f789c8b259e0226e7dac16c635927ca74abc78cea9.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT EXISTS(SELECT 1 FROM mods m INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 WHERE m.id = $1)", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "exists", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Int8", - "Int8" - ] - }, - "nullable": [ - null - ] - }, - "hash": "fa1b92b15cc108fa046998f789c8b259e0226e7dac16c635927ca74abc78cea9" -} diff --git a/migrations/20231213103100_enforces-owner-unique.sql b/migrations/20231213103100_enforces-owner-unique.sql new file mode 100644 index 000000000..f02a03522 --- /dev/null +++ b/migrations/20231213103100_enforces-owner-unique.sql @@ -0,0 +1,10 @@ +-- Enforces that there can only be one owner per team +CREATE UNIQUE INDEX idx_one_owner_per_team +ON team_members (team_id) +WHERE is_owner = TRUE; + +-- Enforces one team_member per user/team +CREATE UNIQUE INDEX idx_unique_user_team +ON team_members (user_id, team_id); + + diff --git a/src/auth/checks.rs b/src/auth/checks.rs index 207335151..e066bb407 100644 --- a/src/auth/checks.rs +++ b/src/auth/checks.rs @@ -109,8 +109,14 @@ pub async fn filter_authorized_projects( " SELECT m.id id, m.team_id team_id FROM team_members tm INNER JOIN mods m ON m.team_id = tm.team_id - LEFT JOIN organizations o ON o.team_id = tm.team_id - WHERE (tm.team_id = ANY($1) or o.id = ANY($2)) AND tm.user_id = $3 + WHERE tm.team_id = ANY($1) AND tm.user_id = $3 + + UNION + + SELECT m.id id, m.team_id team_id FROM team_members tm + INNER JOIN organizations o ON o.team_id = tm.team_id + INNER JOIN mods m ON m.organization_id = o.id + WHERE o.id = ANY($2) AND tm.user_id = $3 ", &check_projects .iter() @@ -126,7 +132,8 @@ pub async fn filter_authorized_projects( .try_for_each(|e| { if let Some(row) = e.right() { check_projects.retain(|x| { - let bool = x.inner.id.0 == row.id && x.inner.team_id.0 == row.team_id; + let bool = + Some(x.inner.id.0) == row.id && Some(x.inner.team_id.0) == row.team_id; if bool { return_projects.push(x.clone().into()); @@ -160,15 +167,35 @@ pub async fn is_authorized_version( let user_id: models::ids::UserId = user.id.into(); let version_exists = sqlx::query!( - "SELECT EXISTS(SELECT 1 FROM mods m INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 WHERE m.id = $1)", + "SELECT EXISTS( + SELECT 1 FROM mods m + INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 + WHERE m.id = $1 + )", version_data.project_id as database::models::ids::ProjectId, user_id as database::models::ids::UserId, ) - .fetch_one(&***pool) - .await? - .exists; + .fetch_one(&***pool) + .await? + .exists; - authorized = version_exists.unwrap_or(false); + let version_organization_exists = sqlx::query!( + "SELECT EXISTS( + SELECT 1 FROM mods m + INNER JOIN organizations o ON m.organization_id = o.id + INNER JOIN team_members tm ON tm.team_id = o.team_id AND user_id = $2 + WHERE m.id = $1 + )", + version_data.project_id as database::models::ids::ProjectId, + user_id as database::models::ids::UserId, + ) + .fetch_one(&***pool) + .await? + .exists; + + authorized = version_exists + .or(version_organization_exists) + .unwrap_or(false); } } } diff --git a/src/database/models/ids.rs b/src/database/models/ids.rs index 87b35ab99..d7e4a97a8 100644 --- a/src/database/models/ids.rs +++ b/src/database/models/ids.rs @@ -219,9 +219,6 @@ pub struct ProjectTypeId(pub i32); pub struct StatusId(pub i32); #[derive(Copy, Clone, Debug, Type, Serialize, Deserialize)] #[sqlx(transparent)] -pub struct SideTypeId(pub i32); -#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize)] -#[sqlx(transparent)] pub struct GameId(pub i32); #[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, PartialEq, Eq, Hash)] #[sqlx(transparent)] diff --git a/src/database/models/loader_fields.rs b/src/database/models/loader_fields.rs index 6bf20cba7..ee5529b69 100644 --- a/src/database/models/loader_fields.rs +++ b/src/database/models/loader_fields.rs @@ -339,12 +339,6 @@ pub struct QueryLoaderFieldEnumValue { pub metadata: Option, } -#[derive(Clone, Serialize, Deserialize, Debug)] -pub struct SideType { - pub id: SideTypeId, - pub name: String, -} - impl LoaderField { pub async fn get_field<'a, E>( field: &str, diff --git a/src/database/models/organization_item.rs b/src/database/models/organization_item.rs index a2d47eace..1c5647203 100644 --- a/src/database/models/organization_item.rs +++ b/src/database/models/organization_item.rs @@ -256,31 +256,9 @@ impl Organization { transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, redis: &RedisPool, ) -> Result, super::DatabaseError> { - use futures::TryStreamExt; - let organization = Self::get_id(id, &mut **transaction, redis).await?; if let Some(organization) = organization { - let projects: Vec = sqlx::query!( - " - SELECT m.id - FROM mods m - WHERE m.organization_id = $1 - ", - id as OrganizationId, - ) - .fetch_many(&mut **transaction) - .try_filter_map(|e| async { Ok(e.right().map(|m| ProjectId(m.id))) }) - .try_collect::>() - .await?; - - for project_id in projects { - let _result = - super::project_item::Project::remove(project_id, transaction, redis).await?; - } - - Organization::clear_cache(id, Some(organization.name), redis).await?; - sqlx::query!( " DELETE FROM organizations diff --git a/src/database/models/team_item.rs b/src/database/models/team_item.rs index 704d229a5..5b1acd339 100644 --- a/src/database/models/team_item.rs +++ b/src/database/models/team_item.rs @@ -412,10 +412,10 @@ impl TeamMember { sqlx::query!( " INSERT INTO team_members ( - id, team_id, user_id, role, permissions, organization_permissions, accepted + id, team_id, user_id, role, permissions, organization_permissions, is_owner, accepted ) VALUES ( - $1, $2, $3, $4, $5, $6, $7 + $1, $2, $3, $4, $5, $6, $7, $8 ) ", self.id as TeamMemberId, @@ -424,6 +424,7 @@ impl TeamMember { self.role, self.permissions.bits() as i64, self.organization_permissions.map(|p| p.bits() as i64), + self.is_owner, self.accepted, ) .execute(&mut **transaction) @@ -576,20 +577,28 @@ impl TeamMember { pub async fn get_from_user_id_project<'a, 'b, E>( id: ProjectId, user_id: UserId, + allow_pending: bool, executor: E, ) -> Result, super::DatabaseError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { + let accepted = if allow_pending { + vec![true, false] + } else { + vec![true] + }; + let result = sqlx::query!( " SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.is_owner, tm.permissions, tm.organization_permissions, tm.accepted, tm.payouts_split, tm.ordering FROM mods m - INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 AND accepted = TRUE + INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 AND accepted = ANY($3) WHERE m.id = $1 ", id as ProjectId, - user_id as UserId + user_id as UserId, + &accepted ) .fetch_optional(executor) .await?; @@ -618,20 +627,27 @@ impl TeamMember { pub async fn get_from_user_id_organization<'a, 'b, E>( id: OrganizationId, user_id: UserId, + allow_pending: bool, executor: E, ) -> Result, super::DatabaseError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { + let accepted = if allow_pending { + vec![true, false] + } else { + vec![true] + }; let result = sqlx::query!( " SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.is_owner, tm.permissions, tm.organization_permissions, tm.accepted, tm.payouts_split, tm.ordering FROM organizations o - INNER JOIN team_members tm ON tm.team_id = o.team_id AND user_id = $2 AND accepted = TRUE + INNER JOIN team_members tm ON tm.team_id = o.team_id AND user_id = $2 AND accepted = ANY($3) WHERE o.id = $1 ", id as OrganizationId, - user_id as UserId + user_id as UserId, + &accepted ) .fetch_optional(executor) .await?; diff --git a/src/routes/v2/teams.rs b/src/routes/v2/teams.rs index e6dfe2b7d..6351cc131 100644 --- a/src/routes/v2/teams.rs +++ b/src/routes/v2/teams.rs @@ -27,6 +27,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { // including the team members of the project's team, but // also the members of the organization's team if the project is associated with an organization // (Unlike team_members_get_project, which only returns the members of the project's team) +// They can be differentiated by the "organization_permissions" field being null or not #[get("{id}/members")] pub async fn team_members_get_project( req: HttpRequest, diff --git a/src/routes/v3/organizations.rs b/src/routes/v3/organizations.rs index 885c7e547..a49217a9b 100644 --- a/src/routes/v3/organizations.rs +++ b/src/routes/v3/organizations.rs @@ -8,6 +8,7 @@ use crate::database::models::{generate_organization_id, team_item, Organization} use crate::database::redis::RedisPool; use crate::file_hosting::FileHost; use crate::models::ids::base62_impl::parse_base62; +use crate::models::ids::UserId; use crate::models::organizations::OrganizationId; use crate::models::pats::Scopes; use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; @@ -17,6 +18,7 @@ use crate::util::routes::read_from_payload; use crate::util::validate::validation_errors_to_string; use crate::{database, models}; use actix_web::{web, HttpRequest, HttpResponse}; +use futures::TryStreamExt; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use sqlx::PgPool; @@ -65,7 +67,6 @@ pub async fn organization_projects_get( .ok(); let possible_organization_id: Option = parse_base62(&info).ok(); - use futures::TryStreamExt; let project_ids = sqlx::query!( " @@ -503,6 +504,7 @@ pub async fn organization_delete( let team_member = database::models::TeamMember::get_from_user_id_organization( organization.id, user.id.into(), + false, &**pool, ) .await @@ -522,7 +524,55 @@ pub async fn organization_delete( } } + let owner_id = sqlx::query!( + " + SELECT user_id FROM team_members + WHERE team_id = $1 AND is_owner = TRUE + ", + organization.team_id as database::models::ids::TeamId + ) + .fetch_one(&**pool) + .await? + .user_id; + let owner_id = database::models::ids::UserId(owner_id); + let mut transaction = pool.begin().await?; + + // Handle projects- every project that is in this organization needs to have its owner changed the organization owner + // Now, no project should have an owner if it is in an organization, and also + // the owner of an organization should not be a team member in any project + let organization_project_teams = sqlx::query!( + " + SELECT t.id FROM organizations o + INNER JOIN mods m ON m.organization_id = o.id + INNER JOIN teams t ON t.id = m.team_id + WHERE o.id = $1 AND $1 IS NOT NULL + ", + organization.id as database::models::ids::OrganizationId + ) + .fetch_many(&mut *transaction) + .try_filter_map(|e| async { Ok(e.right().map(|c| crate::database::models::TeamId(c.id))) }) + .try_collect::>() + .await?; + + for organization_project_team in organization_project_teams.iter() { + let new_id = + crate::database::models::ids::generate_team_member_id(&mut transaction).await?; + let member = TeamMember { + id: new_id, + team_id: *organization_project_team, + user_id: owner_id, + role: "Inherited Owner".to_string(), + is_owner: true, + permissions: ProjectPermissions::all(), + organization_permissions: None, + accepted: true, + payouts_split: Decimal::ZERO, + ordering: 0, + }; + member.insert(&mut transaction).await?; + } + // Safely remove the organization let result = database::models::Organization::remove(organization.id, &mut transaction, &redis).await?; @@ -531,6 +581,10 @@ pub async fn organization_delete( database::models::Organization::clear_cache(organization.id, Some(organization.name), &redis) .await?; + for team_id in organization_project_teams { + database::models::TeamMember::clear_cache(team_id, &redis).await?; + } + if result.is_some() { Ok(HttpResponse::NoContent().body("")) } else { @@ -581,6 +635,7 @@ pub async fn organization_projects_add( let project_team_member = database::models::TeamMember::get_from_user_id_project( project_item.inner.id, current_user.id.into(), + false, &**pool, ) .await? @@ -589,6 +644,7 @@ pub async fn organization_projects_add( let organization_team_member = database::models::TeamMember::get_from_user_id_organization( organization.id, current_user.id.into(), + false, &**pool, ) .await? @@ -622,6 +678,47 @@ pub async fn organization_projects_add( .execute(&mut *transaction) .await?; + // The former owner is no longer an owner (as it is now 'owned' by the organization, 'given' to them) + // The former owner is still a member of the project, but not an owner + // When later removed from the organization, the project will be owned by whoever is specified as the new owner there + if !current_user.role.is_admin() { + let team_member_id = project_team_member.id; + sqlx::query!( + " + UPDATE team_members + SET is_owner = FALSE + WHERE id = $1 + ", + team_member_id as database::models::ids::TeamMemberId + ) + .execute(&mut *transaction) + .await?; + } + + // The owner of the organization, should be removed as a member of the project, if they are + // (As it is an organization project now, and they should not have more specific permissions) + let organization_owner_user_id = sqlx::query!( + " + SELECT u.id + FROM team_members + INNER JOIN users u ON u.id = team_members.user_id + WHERE team_id = $1 AND is_owner = TRUE + ", + organization.team_id as database::models::ids::TeamId + ) + .fetch_one(&mut *transaction) + .await?; + let organization_owner_user_id = + database::models::ids::UserId(organization_owner_user_id.id); + + // If the owner of the organization is a member of the project, remove them + database::models::TeamMember::delete( + project_item.inner.team_id, + organization_owner_user_id, + &mut transaction, + ) + .await?; + transaction.commit().await?; database::models::TeamMember::clear_cache(project_item.inner.team_id, &redis).await?; @@ -640,10 +737,18 @@ pub async fn organization_projects_add( Ok(HttpResponse::Ok().finish()) } +#[derive(Deserialize)] +pub struct OrganizationProjectRemoval { + // A new owner must be supplied for the project. + // That user must be a member of the organization, but not necessarily a member of the project. + pub new_owner: UserId, +} + pub async fn organization_projects_remove( req: HttpRequest, info: web::Path<(String, String)>, pool: web::Data, + data: web::Json, redis: web::Data, session_queue: web::Data, ) -> Result { @@ -683,6 +788,7 @@ pub async fn organization_projects_remove( let organization_team_member = database::models::TeamMember::get_from_user_id_organization( organization.id, current_user.id.into(), + false, &**pool, ) .await? @@ -696,7 +802,72 @@ pub async fn organization_projects_remove( ) .unwrap_or_default(); if permissions.contains(OrganizationPermissions::REMOVE_PROJECT) { + // Now that permissions are confirmed, we confirm the veracity of the new user as an org member + database::models::TeamMember::get_from_user_id_organization( + organization.id, + data.new_owner.into(), + false, + &**pool, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified user is not a member of this organization!".to_string(), + ) + })?; + + // Then, we get the team member of the project and that user (if it exists) + // We use the team member get directly + let new_owner = database::models::TeamMember::get_from_user_id_project( + project_item.inner.id, + data.new_owner.into(), + true, + &**pool, + ) + .await?; + let mut transaction = pool.begin().await?; + + // If the user is not a member of the project, we add them + let new_owner = match new_owner { + Some(new_owner) => new_owner, + None => { + let new_id = + crate::database::models::ids::generate_team_member_id(&mut transaction).await?; + let member = TeamMember { + id: new_id, + team_id: project_item.inner.team_id, + user_id: data.new_owner.into(), + role: "Inherited Owner".to_string(), + is_owner: false, + permissions: ProjectPermissions::all(), + organization_permissions: None, + accepted: true, + payouts_split: Decimal::ZERO, + ordering: 0, + }; + member.insert(&mut transaction).await?; + member + } + }; + + // Set the new owner to fit owner + sqlx::query!( + " + UPDATE team_members + SET + is_owner = TRUE, + accepted = TRUE, + permissions = $1, + organization_permissions = NULL, + role = 'Inherited Owner' + WHERE (id = $1) + ", + new_owner.id as database::models::ids::TeamMemberId + ) + .execute(&mut *transaction) + .await?; + sqlx::query!( " UPDATE mods diff --git a/src/routes/v3/teams.rs b/src/routes/v3/teams.rs index 338d97b17..6fdf0934d 100644 --- a/src/routes/v3/teams.rs +++ b/src/routes/v3/teams.rs @@ -36,6 +36,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { // including the team members of the project's team, but // also the members of the organization's team if the project is associated with an organization // (Unlike team_members_get_project, which only returns the members of the project's team) +// They can be differentiated by the "organization_permissions" field being null or not pub async fn team_members_get_project( req: HttpRequest, info: web::Path<(String,)>, @@ -495,9 +496,39 @@ pub async fn add_team_member( )); } } - crate::database::models::User::get_id(new_member.user_id.into(), &**pool, &redis) - .await? - .ok_or_else(|| ApiError::InvalidInput("An invalid User ID specified".to_string()))?; + let new_user = + crate::database::models::User::get_id(new_member.user_id.into(), &**pool, &redis) + .await? + .ok_or_else(|| ApiError::InvalidInput("An invalid User ID specified".to_string()))?; + + let mut force_accepted = false; + if let TeamAssociationId::Project(pid) = team_association { + // We cannot add the owner to a project team in their own org + let organization = + Organization::get_associated_organization_project_id(pid, &**pool).await?; + let new_user_organization_team_member = if let Some(organization) = &organization { + TeamMember::get_from_user_id(organization.team_id, new_user.id, &**pool).await? + } else { + None + }; + if new_user_organization_team_member + .as_ref() + .map(|tm| tm.is_owner) + .unwrap_or(false) + { + return Err(ApiError::InvalidInput( + "You cannot add the owner of an organization to a project team owned by that organization".to_string(), + )); + } + + // In the case of adding a user that is in an org, to a project that is owned by that same org, + // the user is automatically accepted into that project. + // That is because the user is part of the org, and project teame-membership in an org can also be used to reduce permissions + // (Which should not be a deniable action by that user) + if new_user_organization_team_member.is_some() { + force_accepted = true; + } + } let new_id = crate::database::models::ids::generate_team_member_id(&mut transaction).await?; TeamMember { @@ -508,37 +539,40 @@ pub async fn add_team_member( is_owner: false, // Cannot just create an owner permissions: new_member.permissions, organization_permissions: new_member.organization_permissions, - accepted: false, + accepted: force_accepted, payouts_split: new_member.payouts_split, ordering: new_member.ordering, } .insert(&mut transaction) .await?; - match team_association { - TeamAssociationId::Project(pid) => { - NotificationBuilder { - body: NotificationBody::TeamInvite { - project_id: pid.into(), - team_id: team_id.into(), - invited_by: current_user.id, - role: new_member.role.clone(), - }, + // If the user has an opportunity to accept the invite, send a notification + if !force_accepted { + match team_association { + TeamAssociationId::Project(pid) => { + NotificationBuilder { + body: NotificationBody::TeamInvite { + project_id: pid.into(), + team_id: team_id.into(), + invited_by: current_user.id, + role: new_member.role.clone(), + }, + } + .insert(new_member.user_id.into(), &mut transaction, &redis) + .await?; } - .insert(new_member.user_id.into(), &mut transaction, &redis) - .await?; - } - TeamAssociationId::Organization(oid) => { - NotificationBuilder { - body: NotificationBody::OrganizationInvite { - organization_id: oid.into(), - team_id: team_id.into(), - invited_by: current_user.id, - role: new_member.role.clone(), - }, + TeamAssociationId::Organization(oid) => { + NotificationBuilder { + body: NotificationBody::OrganizationInvite { + organization_id: oid.into(), + team_id: team_id.into(), + invited_by: current_user.id, + role: new_member.role.clone(), + }, + } + .insert(new_member.user_id.into(), &mut transaction, &redis) + .await?; } - .insert(new_member.user_id.into(), &mut transaction, &redis) - .await?; } } @@ -593,7 +627,9 @@ pub async fn edit_team_member( let mut transaction = pool.begin().await?; - if edit_member_db.is_owner && edit_member.permissions.is_some() { + if edit_member_db.is_owner + && (edit_member.permissions.is_some() || edit_member.organization_permissions.is_some()) + { return Err(ApiError::InvalidInput( "The owner's permission's in a team cannot be edited".to_string(), )); @@ -723,8 +759,9 @@ pub async fn transfer_ownership( // Forbid transferring ownership of a project team that is owned by an organization // These are owned by the organization owner, and must be removed from the organization first - let pid = Team::get_association(id.into(), &**pool).await?; - if let Some(TeamAssociationId::Project(pid)) = pid { + // There shouldnt be an ownr on these projects in these cases, but just in case. + let team_association_id = Team::get_association(id.into(), &**pool).await?; + if let Some(TeamAssociationId::Project(pid)) = team_association_id { let result = Project::get_id(pid, &**pool, &redis).await?; if let Some(project_item) = result { if project_item.inner.organization_id.is_some() { @@ -785,7 +822,14 @@ pub async fn transfer_ownership( id.into(), new_owner.user_id.into(), Some(ProjectPermissions::all()), - Some(OrganizationPermissions::all()), + if matches!( + team_association_id, + Some(TeamAssociationId::Organization(_)) + ) { + Some(OrganizationPermissions::all()) + } else { + None + }, None, None, None, @@ -795,8 +839,44 @@ pub async fn transfer_ownership( ) .await?; + let project_teams_edited = + if let Some(TeamAssociationId::Organization(oid)) = team_association_id { + // The owner of ALL projects that this organization owns, if applicable, should be removed as members of the project, + // if they are members of those projects. + // (As they are the org owners for them, and they should not have more specific permissions) + + // First, get team id for every project owned by this organization + let team_ids = sqlx::query!( + " + SELECT m.team_id FROM organizations o + INNER JOIN mods m ON m.organization_id = o.id + WHERE o.id = $1 AND $1 IS NOT NULL + ", + oid.0 as i64 + ) + .fetch_all(&mut *transaction) + .await?; + + let team_ids: Vec = team_ids + .into_iter() + .map(|x| TeamId(x.team_id as u64).into()) + .collect(); + + // If the owner of the organization is a member of the project, remove them + for team_id in team_ids.iter() { + TeamMember::delete(*team_id, new_owner.user_id.into(), &mut transaction).await?; + } + + team_ids + } else { + vec![] + }; + transaction.commit().await?; TeamMember::clear_cache(id.into(), &redis).await?; + for team_id in project_teams_edited { + TeamMember::clear_cache(team_id, &redis).await?; + } Ok(HttpResponse::NoContent().body("")) } diff --git a/src/routes/v3/version_creation.rs b/src/routes/v3/version_creation.rs index 62661bf98..11294975d 100644 --- a/src/routes/v3/version_creation.rs +++ b/src/routes/v3/version_creation.rs @@ -217,6 +217,7 @@ async fn version_create_inner( let team_member = models::TeamMember::get_from_user_id_project( project_id, user.id.into(), + false, &mut **transaction, ) .await?; @@ -609,6 +610,7 @@ async fn upload_file_to_version_inner( let team_member = models::TeamMember::get_from_user_id_project( version.inner.project_id, user.id.into(), + false, &mut **transaction, ) .await?; diff --git a/src/routes/v3/version_file.rs b/src/routes/v3/version_file.rs index c6b9d6ee6..3b5b15e6b 100644 --- a/src/routes/v3/version_file.rs +++ b/src/routes/v3/version_file.rs @@ -586,6 +586,7 @@ pub async fn delete_file( database::models::TeamMember::get_from_user_id_organization( organization.id, user.id.into(), + false, &**pool, ) .await diff --git a/src/routes/v3/versions.rs b/src/routes/v3/versions.rs index 3d53d24fe..8b65b2ef1 100644 --- a/src/routes/v3/versions.rs +++ b/src/routes/v3/versions.rs @@ -273,6 +273,7 @@ pub async fn version_edit_helper( let team_member = database::models::TeamMember::get_from_user_id_project( version_item.inner.project_id, user.id.into(), + false, &**pool, ) .await?; @@ -855,6 +856,7 @@ pub async fn version_delete( let team_member = database::models::TeamMember::get_from_user_id_project( version.inner.project_id, user.id.into(), + false, &**pool, ) .await diff --git a/tests/common/api_v3/organization.rs b/tests/common/api_v3/organization.rs index 45e66ef22..94956afff 100644 --- a/tests/common/api_v3/organization.rs +++ b/tests/common/api_v3/organization.rs @@ -3,7 +3,7 @@ use actix_web::{ test::{self, TestRequest}, }; use bytes::Bytes; -use labrinth::models::{organizations::Organization, v3::projects::Project}; +use labrinth::models::{organizations::Organization, users::UserId, v3::projects::Project}; use serde_json::json; use crate::common::api_common::{request_data::ImageData, Api, AppendsOptionalPat}; @@ -162,12 +162,16 @@ impl ApiV3 { &self, id_or_title: &str, project_id_or_slug: &str, + new_owner_user_id: UserId, pat: Option<&str>, ) -> ServiceResponse { let req = test::TestRequest::delete() .uri(&format!( "/v3/organization/{id_or_title}/projects/{project_id_or_slug}" )) + .set_json(json!({ + "new_owner": new_owner_user_id, + })) .append_pat(pat) .to_request(); diff --git a/tests/common/api_v3/team.rs b/tests/common/api_v3/team.rs index bbc6002b5..ca2d1dffc 100644 --- a/tests/common/api_v3/team.rs +++ b/tests/common/api_v3/team.rs @@ -37,6 +37,16 @@ impl ApiV3 { assert_eq!(resp.status(), 200); test::read_body_json(resp).await } + + pub async fn get_project_members_deserialized( + &self, + project_id: &str, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_project_members(project_id, pat).await; + assert_eq!(resp.status(), 200); + test::read_body_json(resp).await + } } #[async_trait(?Send)] diff --git a/tests/common/permissions.rs b/tests/common/permissions.rs index 39c3a7371..435456ca9 100644 --- a/tests/common/permissions.rs +++ b/tests/common/permissions.rs @@ -1051,8 +1051,10 @@ async fn add_user_to_team( assert!(resp.status().is_success()); // Accept invitation - let resp = setup_api.join_team(team_id, user_pat).await; - assert!(resp.status().is_success()); + setup_api.join_team(team_id, user_pat).await; + // This does not check if the join request was successful, + // as the join is not always needed- an org project + in-org invite + // will automatically go through. } async fn modify_user_team_permissions( diff --git a/tests/organizations.rs b/tests/organizations.rs index 191ae99f2..1e071e4de 100644 --- a/tests/organizations.rs +++ b/tests/organizations.rs @@ -1,7 +1,10 @@ use crate::common::{ - api_common::ApiTeams, - database::{generate_random_name, ADMIN_USER_PAT, MOD_USER_ID, MOD_USER_PAT, USER_USER_ID}, - dummy_data::DummyImage, + api_common::{ApiProject, ApiTeams}, + database::{ + generate_random_name, ADMIN_USER_PAT, ENEMY_USER_ID_PARSED, ENEMY_USER_PAT, + FRIEND_USER_ID_PARSED, MOD_USER_ID, MOD_USER_PAT, USER_USER_ID, USER_USER_ID_PARSED, + }, + dummy_data::{DummyImage, DummyOrganizationZeta, DummyProjectAlpha, DummyProjectBeta}, }; use common::{ api_v3::ApiV3, @@ -9,7 +12,10 @@ use common::{ environment::{with_test_environment, with_test_environment_all, TestEnvironment}, permissions::{PermissionsTest, PermissionsTestContext}, }; -use labrinth::models::teams::{OrganizationPermissions, ProjectPermissions}; +use labrinth::models::{ + teams::{OrganizationPermissions, ProjectPermissions}, + users::UserId, +}; use serde_json::json; mod common; @@ -249,7 +255,12 @@ async fn add_remove_organization_projects() { // Remove project from organization let resp = test_env .api - .organization_remove_project(zeta_organization_id, alpha, USER_USER_PAT) + .organization_remove_project( + zeta_organization_id, + alpha, + UserId(USER_USER_ID_PARSED as u64), + USER_USER_PAT, + ) .await; assert_eq!(resp.status(), 200); @@ -264,6 +275,357 @@ async fn add_remove_organization_projects() { .await; } +// Like above, but specifically regarding ownership transferring +#[actix_rt::test] +async fn add_remove_organization_project_ownership_to_user() { + with_test_environment(None, |test_env: TestEnvironment| async move { + let DummyProjectAlpha { + project_id: alpha_project_id, + team_id: alpha_team_id, + .. + } = &test_env.dummy.project_alpha; + let DummyProjectBeta { + project_id: beta_project_id, + team_id: beta_team_id, + .. + } = &test_env.dummy.project_beta; + let DummyOrganizationZeta { + organization_id: zeta_organization_id, + team_id: zeta_team_id, + .. + } = &test_env.dummy.organization_zeta; + + // Add friend to alpha, beta, and zeta + for (team, organization) in [ + (alpha_team_id, false), + (beta_team_id, false), + (zeta_team_id, true), + ] { + let org_permissions = if organization { + Some(OrganizationPermissions::all()) + } else { + None + }; + let resp = test_env + .api + .add_user_to_team( + team, + FRIEND_USER_ID, + Some(ProjectPermissions::all()), + org_permissions, + USER_USER_PAT, + ) + .await; + assert_eq!(resp.status(), 204); + + // Accept invites + let resp = test_env.api.join_team(team, FRIEND_USER_PAT).await; + assert_eq!(resp.status(), 204); + } + + // For each team, confirm there are two members, but only one owner of the project, and it is USER_USER_ID + for team in [alpha_team_id, beta_team_id, zeta_team_id] { + let members = test_env + .api + .get_team_members_deserialized(team, USER_USER_PAT) + .await; + assert_eq!(members.len(), 2); + let user_member = members.iter().filter(|m| m.is_owner).collect::>(); + assert_eq!(user_member.len(), 1); + assert_eq!(user_member[0].user.id.to_string(), USER_USER_ID); + } + + // Transfer ownership of beta project to FRIEND + let resp = test_env + .api + .transfer_team_ownership(beta_team_id, FRIEND_USER_ID, USER_USER_PAT) + .await; + assert_eq!(resp.status(), 204); + + // Confirm there are still two users, but now FRIEND_USER_ID is the owner + let members = test_env + .api + .get_team_members_deserialized(beta_team_id, USER_USER_PAT) + .await; + assert_eq!(members.len(), 2); + let user_member = members.iter().filter(|m| m.is_owner).collect::>(); + assert_eq!(user_member.len(), 1); + assert_eq!(user_member[0].user.id.to_string(), FRIEND_USER_ID); + + // Add alpha, beta to zeta organization + for (project_id, pat) in [ + (alpha_project_id, USER_USER_PAT), + (beta_project_id, FRIEND_USER_PAT), + ] { + let resp = test_env + .api + .organization_add_project(zeta_organization_id, project_id, pat) + .await; + assert_eq!(resp.status(), 200); + + // Get and confirm it has been added + let project = test_env.api.get_project_deserialized(project_id, pat).await; + assert_eq!( + &project.organization.unwrap().to_string(), + zeta_organization_id + ); + } + + // Both alpha and beta project should have: + // - 1 member, FRIEND_USER_ID + // - No owner. + // -> For alpha, user was removed as owner when it was added to the organization + // -> For beta, user was removed as owner when ownership was transferred to friend + // then friend was removed as owner when it was added to the organization + // -> In both cases, user was removed entirely as a team_member as it is now the owner of the organization + for team_id in [alpha_team_id, beta_team_id] { + let members = test_env + .api + .get_team_members_deserialized(team_id, USER_USER_PAT) + .await; + assert_eq!(members.len(), 1); + assert_eq!(members[0].user.id.to_string(), FRIEND_USER_ID); + let user_member = members.iter().filter(|m| m.is_owner).collect::>(); + assert_eq!(user_member.len(), 0); + } + + // Transfer ownership of zeta organization to FRIEND + let resp = test_env + .api + .transfer_team_ownership(zeta_team_id, FRIEND_USER_ID, USER_USER_PAT) + .await; + assert_eq!(resp.status(), 204); + + // Confirm there are no members of the alpha project OR the beta project + // - Friend was removed as a member of these projects when ownership was transferred to them + for team_id in [alpha_team_id, beta_team_id] { + let members = test_env + .api + .get_team_members_deserialized(team_id, USER_USER_PAT) + .await; + assert!(members.is_empty()); + } + + // As user, cannot add friend to alpha project, as they are the org owner + let resp = test_env + .api + .add_user_to_team(alpha_team_id, FRIEND_USER_ID, None, None, USER_USER_PAT) + .await; + assert_eq!(resp.status(), 400); + + // As friend, can add user to alpha project, as they are not the org owner + let resp = test_env + .api + .add_user_to_team(alpha_team_id, USER_USER_ID, None, None, FRIEND_USER_PAT) + .await; + assert_eq!(resp.status(), 204); + + // At this point, friend owns the org + // Alpha member has user as a member, but not as an owner + // Neither project has an owner, as they are owned by the org + + // Remove project from organization with a user that is not an organization member + // This should fail as we cannot give a project to a user that is not a member of the organization + let resp = test_env + .api + .organization_remove_project( + zeta_organization_id, + alpha_project_id, + UserId(ENEMY_USER_ID_PARSED as u64), + USER_USER_PAT, + ) + .await; + assert_eq!(resp.status(), 400); + + // Remove project from organization with a user that is an organization member, and a project member + // This should succeed + let resp = test_env + .api + .organization_remove_project( + zeta_organization_id, + alpha_project_id, + UserId(USER_USER_ID_PARSED as u64), + USER_USER_PAT, + ) + .await; + assert_eq!(resp.status(), 200); + + // Remove project from organization with a user that is an organization member, but not a project member + // This should succeed + let resp = test_env + .api + .organization_remove_project( + zeta_organization_id, + beta_project_id, + UserId(USER_USER_ID_PARSED as u64), + USER_USER_PAT, + ) + .await; + assert_eq!(resp.status(), 200); + + // For each of alpha and beta, confirm: + // - There is one member of each project, the owner, USER_USER_ID + // - They no longer have an attached organization + for team_id in [alpha_team_id, beta_team_id] { + let members = test_env + .api + .get_team_members_deserialized(team_id, USER_USER_PAT) + .await; + assert_eq!(members.len(), 1); + let user_member = members.iter().filter(|m| m.is_owner).collect::>(); + assert_eq!(user_member.len(), 1); + assert_eq!(user_member[0].user.id.to_string(), USER_USER_ID); + } + + for project_id in [alpha_project_id, beta_project_id] { + let project = test_env + .api + .get_project_deserialized(project_id, USER_USER_PAT) + .await; + assert!(project.organization.is_none()); + } + }) + .await; +} + +#[actix_rt::test] +async fn delete_organization_means_all_projects_to_org_owner() { + with_test_environment(None, |test_env: TestEnvironment| async move { + let DummyProjectAlpha { + project_id: alpha_project_id, + team_id: alpha_team_id, + .. + } = &test_env.dummy.project_alpha; + let DummyProjectBeta { + project_id: beta_project_id, + team_id: beta_team_id, + .. + } = &test_env.dummy.project_beta; + let DummyOrganizationZeta { + organization_id: zeta_organization_id, + team_id: zeta_team_id, + .. + } = &test_env.dummy.organization_zeta; + + // Create random project from enemy, ensure it wont get affected + let (enemy_project, _) = test_env + .api + .add_public_project("enemy_project", None, None, ENEMY_USER_PAT) + .await; + + // Add FRIEND + let resp = test_env + .api + .add_user_to_team(zeta_team_id, FRIEND_USER_ID, None, None, USER_USER_PAT) + .await; + assert_eq!(resp.status(), 204); + + // Accept invite + let resp = test_env.api.join_team(zeta_team_id, FRIEND_USER_PAT).await; + assert_eq!(resp.status(), 204); + + // Confirm there is only one owner of the project, and it is USER_USER_ID + let members = test_env + .api + .get_team_members_deserialized(alpha_team_id, USER_USER_PAT) + .await; + let user_member = members.iter().filter(|m| m.is_owner).collect::>(); + assert_eq!(user_member.len(), 1); + assert_eq!(user_member[0].user.id.to_string(), USER_USER_ID); + + // Add alpha to zeta organization + let resp = test_env + .api + .organization_add_project(zeta_organization_id, alpha_project_id, USER_USER_PAT) + .await; + assert_eq!(resp.status(), 200); + + // Add beta to zeta organization + test_env + .api + .organization_add_project(zeta_organization_id, beta_project_id, USER_USER_PAT) + .await; + + // Add friend as a member of the beta project + let resp = test_env + .api + .add_user_to_team(beta_team_id, FRIEND_USER_ID, None, None, USER_USER_PAT) + .await; + assert_eq!(resp.status(), 204); + + // Try to accept invite + // This returns a failure, because since beta and FRIEND are in the organizations, + // they can be added to the project without an invite + let resp = test_env.api.join_team(beta_team_id, FRIEND_USER_PAT).await; + assert_eq!(resp.status(), 400); + + // Confirm there is NO owner of the project, as it is owned by the organization + let members = test_env + .api + .get_team_members_deserialized(alpha_team_id, USER_USER_PAT) + .await; + let user_member = members.iter().filter(|m| m.is_owner).collect::>(); + assert_eq!(user_member.len(), 0); + + // Transfer ownership of zeta organization to FRIEND + let resp = test_env + .api + .transfer_team_ownership(zeta_team_id, FRIEND_USER_ID, USER_USER_PAT) + .await; + assert_eq!(resp.status(), 204); + + // Confirm there is NO owner of the project, as it is owned by the organization + let members = test_env + .api + .get_team_members_deserialized(alpha_team_id, USER_USER_PAT) + .await; + let user_member = members.iter().filter(|m| m.is_owner).collect::>(); + assert_eq!(user_member.len(), 0); + + // Delete organization + let resp = test_env + .api + .delete_organization(zeta_organization_id, FRIEND_USER_PAT) + .await; + assert_eq!(resp.status(), 204); + + // Confirm there is only one owner of the alpha project, and it is now FRIEND_USER_ID + let members = test_env + .api + .get_team_members_deserialized(alpha_team_id, USER_USER_PAT) + .await; + let user_member = members.iter().filter(|m| m.is_owner).collect::>(); + assert_eq!(user_member.len(), 1); + assert_eq!(user_member[0].user.id.to_string(), FRIEND_USER_ID); + + // Confirm there is only one owner of the beta project, and it is now FRIEND_USER_ID + let members = test_env + .api + .get_team_members_deserialized(beta_team_id, USER_USER_PAT) + .await; + let user_member = members.iter().filter(|m| m.is_owner).collect::>(); + assert_eq!(user_member.len(), 1); + assert_eq!(user_member[0].user.id.to_string(), FRIEND_USER_ID); + + // Confirm there is only one member of the enemy project, and it is STILL ENEMY_USER_ID + let enemy_project = test_env + .api + .get_project_deserialized(&enemy_project.id.to_string(), ENEMY_USER_PAT) + .await; + let members = test_env + .api + .get_team_members_deserialized(&enemy_project.team_id.to_string(), ENEMY_USER_PAT) + .await; + let user_member = members.iter().filter(|m| m.is_owner).collect::>(); + assert_eq!(user_member.len(), 1); + assert_eq!( + user_member[0].user.id.to_string(), + ENEMY_USER_ID_PARSED.to_string() + ); + }) + .await; +} + #[actix_rt::test] async fn permissions_patch_organization() { with_test_environment(None, |test_env: TestEnvironment| async move { @@ -480,6 +842,7 @@ async fn permissions_add_remove_project() { api.organization_remove_project( &ctx.organization_id.unwrap(), alpha_project_id, + UserId(FRIEND_USER_ID_PARSED as u64), ctx.test_pat.as_deref(), ) .await diff --git a/tests/scopes.rs b/tests/scopes.rs index b10b9ff74..2b9b36134 100644 --- a/tests/scopes.rs +++ b/tests/scopes.rs @@ -14,6 +14,7 @@ use common::{database::*, scopes::ScopeTest}; use labrinth::models::ids::base62_impl::parse_base62; use labrinth::models::pats::Scopes; use labrinth::models::projects::ProjectId; +use labrinth::models::users::UserId; use serde_json::json; // For each scope, we (using test_scope): @@ -1185,8 +1186,13 @@ pub async fn organization_scopes() { // remove project (now that we've checked) let req_gen = |pat: Option| async move { - api.organization_remove_project(organization_id, beta_project_id, pat.as_deref()) - .await + api.organization_remove_project( + organization_id, + beta_project_id, + UserId(USER_USER_ID_PARSED as u64), + pat.as_deref(), + ) + .await }; ScopeTest::new(&test_env) .with_failure_scopes(Scopes::all() ^ Scopes::ORGANIZATION_WRITE) diff --git a/tests/teams.rs b/tests/teams.rs index 0980e18da..89b668848 100644 --- a/tests/teams.rs +++ b/tests/teams.rs @@ -287,11 +287,12 @@ async fn test_get_team_project_orgs() { // The team members route from teams (on a project's team): // - the members of the project team specifically // - not the ones from the organization + // - Remember: the owner of an org will not be included in the org's team members list let members = test_env .api .get_team_members_deserialized_common(alpha_team_id, FRIEND_USER_PAT) .await; - assert_eq!(members.len(), 1); + assert_eq!(members.len(), 0); // The team members route from project should show: // - the members of the project team including the ones from the organization