diff --git a/.sqlx/query-099625fec19d0a85e1e35d7e827782715c91b75062dbb1a65fb359983ca101bb.json b/.sqlx/query-099625fec19d0a85e1e35d7e827782715c91b75062dbb1a65fb359983ca101bb.json new file mode 100644 index 00000000..29acae22 --- /dev/null +++ b/.sqlx/query-099625fec19d0a85e1e35d7e827782715c91b75062dbb1a65fb359983ca101bb.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM assignments WHERE user_id=$1 AND project_id=$2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "099625fec19d0a85e1e35d7e827782715c91b75062dbb1a65fb359983ca101bb" +} diff --git a/.sqlx/query-130ee66533cd86f4eaa8298e02cd7908321eece3aa2b7094c78270538e0ea7c5.json b/.sqlx/query-130ee66533cd86f4eaa8298e02cd7908321eece3aa2b7094c78270538e0ea7c5.json new file mode 100644 index 00000000..4b3de1f5 --- /dev/null +++ b/.sqlx/query-130ee66533cd86f4eaa8298e02cd7908321eece3aa2b7094c78270538e0ea7c5.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM projects WHERE org_id=$1 AND id=ANY($2) ORDER BY id FOR SHARE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "UuidArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "130ee66533cd86f4eaa8298e02cd7908321eece3aa2b7094c78270538e0ea7c5" +} diff --git a/.sqlx/query-155a4bd2e00d51438a326a6bda1301cb49d15e3213a3ad9757eff9722c16206d.json b/.sqlx/query-155a4bd2e00d51438a326a6bda1301cb49d15e3213a3ad9757eff9722c16206d.json new file mode 100644 index 00000000..34212360 --- /dev/null +++ b/.sqlx/query-155a4bd2e00d51438a326a6bda1301cb49d15e3213a3ad9757eff9722c16206d.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM clients WHERE id=$1 FOR UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "155a4bd2e00d51438a326a6bda1301cb49d15e3213a3ad9757eff9722c16206d" +} diff --git a/.sqlx/query-196be57ca0f0fc090145eecd40438b3c24fc7eea278e651213029ab194912c47.json b/.sqlx/query-196be57ca0f0fc090145eecd40438b3c24fc7eea278e651213029ab194912c47.json new file mode 100644 index 00000000..02a298b0 --- /dev/null +++ b/.sqlx/query-196be57ca0f0fc090145eecd40438b3c24fc7eea278e651213029ab194912c47.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE clients SET address=repeat('x',40000) WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "196be57ca0f0fc090145eecd40438b3c24fc7eea278e651213029ab194912c47" +} diff --git a/.sqlx/query-25c9a91321bb7bd638315432a19a268a9c5ae68dce1f6c78caf7be47ebf021f6.json b/.sqlx/query-25c9a91321bb7bd638315432a19a268a9c5ae68dce1f6c78caf7be47ebf021f6.json new file mode 100644 index 00000000..41cfc598 --- /dev/null +++ b/.sqlx/query-25c9a91321bb7bd638315432a19a268a9c5ae68dce1f6c78caf7be47ebf021f6.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE time_entries SET notes=$2 WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "25c9a91321bb7bd638315432a19a268a9c5ae68dce1f6c78caf7be47ebf021f6" +} diff --git a/.sqlx/query-2889c0809ea46da16faadd7e187dd4666a7a3b3d41210adec82f431b4936c936.json b/.sqlx/query-2889c0809ea46da16faadd7e187dd4666a7a3b3d41210adec82f431b4936c936.json new file mode 100644 index 00000000..e374122c --- /dev/null +++ b/.sqlx/query-2889c0809ea46da16faadd7e187dd4666a7a3b3d41210adec82f431b4936c936.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM organizations WHERE id=$1 FOR NO KEY UPDATE NOWAIT", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "2889c0809ea46da16faadd7e187dd4666a7a3b3d41210adec82f431b4936c936" +} diff --git a/.sqlx/query-3295b63cf87c4f8f4ca74ac34445d6fe06500817261eb385270b15f1879c4931.json b/.sqlx/query-3295b63cf87c4f8f4ca74ac34445d6fe06500817261eb385270b15f1879c4931.json new file mode 100644 index 00000000..9d808c9c --- /dev/null +++ b/.sqlx/query-3295b63cf87c4f8f4ca74ac34445d6fe06500817261eb385270b15f1879c4931.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO projects (id,org_id,client_id,name,currency)\n SELECT id,$2,$3,'Additional','EUR' FROM unnest($1::uuid[]) id", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "3295b63cf87c4f8f4ca74ac34445d6fe06500817261eb385270b15f1879c4931" +} diff --git a/.sqlx/query-32fc50677ceb32135d0253a1a30f97c92e5a4d233dfa4715226420ab8c33b909.json b/.sqlx/query-32fc50677ceb32135d0253a1a30f97c92e5a4d233dfa4715226420ab8c33b909.json new file mode 100644 index 00000000..b06a0403 --- /dev/null +++ b/.sqlx/query-32fc50677ceb32135d0253a1a30f97c92e5a4d233dfa4715226420ab8c33b909.json @@ -0,0 +1,122 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH bounded AS MATERIALIZED (\n SELECT p.id, c.name client_name, p.code, p.name, p.project_type,\n p.currency, p.budget_kind, p.budget_amount_cents, p.budget_minutes, p.active\n FROM projects p JOIN clients c ON c.id=p.client_id\n JOIN project_read_access a ON a.project_id=p.id AND a.org_id=p.org_id\n WHERE p.org_id=$1 AND a.user_id=$2 AND a.can_view_progress AND CASE $3\n WHEN 'budgeted' THEN p.active AND p.budget_kind <> 'none'\n WHEN 'archived' THEN NOT p.active ELSE p.active END\n LIMIT $4::bigint + 1\n ), stats AS (\n SELECT COUNT(*) rows,\n COALESCE(SUM(octet_length(client_name)::bigint + COALESCE(octet_length(code),0)\n + octet_length(name) + octet_length(currency)),0)::bigint bytes,\n COALESCE(MAX(GREATEST(octet_length(client_name), COALESCE(octet_length(code),0),\n octet_length(name), octet_length(currency))),0) field_bytes\n FROM bounded\n )\n SELECT s.rows AS \"rows!\", s.bytes AS \"bytes!\", s.field_bytes AS \"field_bytes!\",\n b.id AS \"id?\", b.client_name AS \"client_name?\", b.code, b.name AS \"name?\",\n b.project_type AS \"project_type?: horae_core::types::ProjectType\",\n b.currency AS \"currency?\", b.budget_kind AS \"budget_kind?: horae_core::types::BudgetKind\",\n b.budget_amount_cents, b.budget_minutes, b.active AS \"active?\"\n FROM stats s LEFT JOIN bounded b ON s.rows <= $4 AND s.bytes <= $5 AND s.field_bytes <= $6\n ORDER BY b.client_name, b.name, b.id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "rows!", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "bytes!", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "field_bytes!", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "id?", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "client_name?", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "code", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "name?", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "project_type?: horae_core::types::ProjectType", + "type_info": { + "Custom": { + "name": "project_type", + "kind": { + "Enum": [ + "time_and_materials", + "fixed_fee", + "non_billable", + "retainer" + ] + } + } + } + }, + { + "ordinal": 8, + "name": "currency?", + "type_info": "Bpchar" + }, + { + "ordinal": 9, + "name": "budget_kind?: horae_core::types::BudgetKind", + "type_info": { + "Custom": { + "name": "budget_kind", + "kind": { + "Enum": [ + "none", + "amount", + "hours" + ] + } + } + } + }, + { + "ordinal": 10, + "name": "budget_amount_cents", + "type_info": "Int8" + }, + { + "ordinal": 11, + "name": "budget_minutes", + "type_info": "Int8" + }, + { + "ordinal": 12, + "name": "active?", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Int8", + "Int8", + "Int4" + ] + }, + "nullable": [ + null, + null, + null, + false, + false, + true, + false, + false, + false, + false, + true, + true, + false + ] + }, + "hash": "32fc50677ceb32135d0253a1a30f97c92e5a4d233dfa4715226420ab8c33b909" +} diff --git a/.sqlx/query-44181402d68849f74a43ab498665e83d52cdc1739e18b47c117c1a50648f9fa9.json b/.sqlx/query-44181402d68849f74a43ab498665e83d52cdc1739e18b47c117c1a50648f9fa9.json new file mode 100644 index 00000000..6ef5a92f --- /dev/null +++ b/.sqlx/query-44181402d68849f74a43ab498665e83d52cdc1739e18b47c117c1a50648f9fa9.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE projects SET name=repeat('界',10922) WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "44181402d68849f74a43ab498665e83d52cdc1739e18b47c117c1a50648f9fa9" +} diff --git a/.sqlx/query-445e2d36990a85362ec55efdcf7fc5fb7b879170dcd15fc6029fae448ae7394b.json b/.sqlx/query-445e2d36990a85362ec55efdcf7fc5fb7b879170dcd15fc6029fae448ae7394b.json new file mode 100644 index 00000000..74994004 --- /dev/null +++ b/.sqlx/query-445e2d36990a85362ec55efdcf7fc5fb7b879170dcd15fc6029fae448ae7394b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT access_revision FROM organizations WHERE id=$1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "access_revision", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "445e2d36990a85362ec55efdcf7fc5fb7b879170dcd15fc6029fae448ae7394b" +} diff --git a/.sqlx/query-5044450dc51716a93c4312e6abd7b0419fc4fc0f91322e4ef575258cab6f59ef.json b/.sqlx/query-5044450dc51716a93c4312e6abd7b0419fc4fc0f91322e4ef575258cab6f59ef.json new file mode 100644 index 00000000..c0818b55 --- /dev/null +++ b/.sqlx/query-5044450dc51716a93c4312e6abd7b0419fc4fc0f91322e4ef575258cab6f59ef.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "CREATE SCHEMA project_export_test", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "5044450dc51716a93c4312e6abd7b0419fc4fc0f91322e4ef575258cab6f59ef" +} diff --git a/.sqlx/query-53f78ed7fa12672a109edce589ede9105eedbed051ad4061fc93ba217e6cdcb8.json b/.sqlx/query-53f78ed7fa12672a109edce589ede9105eedbed051ad4061fc93ba217e6cdcb8.json new file mode 100644 index 00000000..a0a77eef --- /dev/null +++ b/.sqlx/query-53f78ed7fa12672a109edce589ede9105eedbed051ad4061fc93ba217e6cdcb8.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT set_config('lock_timeout', $1, false)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "set_config", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "53f78ed7fa12672a109edce589ede9105eedbed051ad4061fc93ba217e6cdcb8" +} diff --git a/.sqlx/query-590d103d4224cac6f2824a8691b423e5c738fad928a7cf9a556df3f0ffcb8d1e.json b/.sqlx/query-590d103d4224cac6f2824a8691b423e5c738fad928a7cf9a556df3f0ffcb8d1e.json new file mode 100644 index 00000000..7b7ea6d0 --- /dev/null +++ b/.sqlx/query-590d103d4224cac6f2824a8691b423e5c738fad928a7cf9a556df3f0ffcb8d1e.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO projects (id,org_id,client_id,name,currency) VALUES ($1,$2,$3,'New project','EUR')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "590d103d4224cac6f2824a8691b423e5c738fad928a7cf9a556df3f0ffcb8d1e" +} diff --git a/.sqlx/query-59d7b3ed0bfb441f300268930cc075b3f7e13428732da577cc8a1cb08bfee083.json b/.sqlx/query-59d7b3ed0bfb441f300268930cc075b3f7e13428732da577cc8a1cb08bfee083.json new file mode 100644 index 00000000..18b44c1b --- /dev/null +++ b/.sqlx/query-59d7b3ed0bfb441f300268930cc075b3f7e13428732da577cc8a1cb08bfee083.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT pid AS \"pid!\" FROM pg_stat_activity\n WHERE datname=current_database() AND $1=ANY(pg_blocking_pids(pid)) LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "pid!", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + true + ] + }, + "hash": "59d7b3ed0bfb441f300268930cc075b3f7e13428732da577cc8a1cb08bfee083" +} diff --git a/.sqlx/query-6288f37266d185d5985849f1bbda32a14e57561c674ac46940e84b633a7bc46d.json b/.sqlx/query-6288f37266d185d5985849f1bbda32a14e57561c674ac46940e84b633a7bc46d.json new file mode 100644 index 00000000..4523c124 --- /dev/null +++ b/.sqlx/query-6288f37266d185d5985849f1bbda32a14e57561c674ac46940e84b633a7bc46d.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO projects (id,org_id,client_id,name,currency) VALUES ($1,$2,$3,'Overflow','EUR')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "6288f37266d185d5985849f1bbda32a14e57561c674ac46940e84b633a7bc46d" +} diff --git a/.sqlx/query-642d52f4eadbeb313a1616e29a3dea6d97deb9a0b7e0c7c97bbef11ff9af187c.json b/.sqlx/query-642d52f4eadbeb313a1616e29a3dea6d97deb9a0b7e0c7c97bbef11ff9af187c.json new file mode 100644 index 00000000..23fe5323 --- /dev/null +++ b/.sqlx/query-642d52f4eadbeb313a1616e29a3dea6d97deb9a0b7e0c7c97bbef11ff9af187c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE projects SET name=repeat('x',40000),budget_kind='hours',budget_minutes=300 WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "642d52f4eadbeb313a1616e29a3dea6d97deb9a0b7e0c7c97bbef11ff9af187c" +} diff --git a/.sqlx/query-652a76d32871b05e2dbd7134ad6832d81434159db5176a051542274c3270310e.json b/.sqlx/query-652a76d32871b05e2dbd7134ad6832d81434159db5176a051542274c3270310e.json new file mode 100644 index 00000000..a49dca91 --- /dev/null +++ b/.sqlx/query-652a76d32871b05e2dbd7134ad6832d81434159db5176a051542274c3270310e.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE invoices SET notes=repeat('x',40000) WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "652a76d32871b05e2dbd7134ad6832d81434159db5176a051542274c3270310e" +} diff --git a/.sqlx/query-6dba2810dc06039e7b80e4e3fdc05fbab75005704f5bcfb44794fc7469fa3d3f.json b/.sqlx/query-6dba2810dc06039e7b80e4e3fdc05fbab75005704f5bcfb44794fc7469fa3d3f.json new file mode 100644 index 00000000..327c2f1a --- /dev/null +++ b/.sqlx/query-6dba2810dc06039e7b80e4e3fdc05fbab75005704f5bcfb44794fc7469fa3d3f.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO invoice_line_items (id,invoice_id,time_entry_id,description,minutes,rate_cents,amount_cents) VALUES ($1,$2,$3,'Private export note',60,1234,1234)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "6dba2810dc06039e7b80e4e3fdc05fbab75005704f5bcfb44794fc7469fa3d3f" +} diff --git a/.sqlx/query-6dec91212020ec75fa9348850f152eb73f94747bd32a6eaf67726258b2a1f57d.json b/.sqlx/query-6dec91212020ec75fa9348850f152eb73f94747bd32a6eaf67726258b2a1f57d.json new file mode 100644 index 00000000..059ebfc3 --- /dev/null +++ b/.sqlx/query-6dec91212020ec75fa9348850f152eb73f94747bd32a6eaf67726258b2a1f57d.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE projects SET name='Other tenant secret' WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "6dec91212020ec75fa9348850f152eb73f94747bd32a6eaf67726258b2a1f57d" +} diff --git a/.sqlx/query-717614d03bf82a999cdbb1a6e09e9b668322e9bff730466a7133c70e897f3547.json b/.sqlx/query-717614d03bf82a999cdbb1a6e09e9b668322e9bff730466a7133c70e897f3547.json deleted file mode 100644 index a475950d..00000000 --- a/.sqlx/query-717614d03bf82a999cdbb1a6e09e9b668322e9bff730466a7133c70e897f3547.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT COUNT(*) as \"rows!\",\n COALESCE(SUM(octet_length(client_name)::bigint + COALESCE(octet_length(code), 0)\n + octet_length(name) + octet_length(currency)), 0)::bigint as \"bytes!\",\n COALESCE(MAX(GREATEST(octet_length(client_name), COALESCE(octet_length(code), 0),\n octet_length(name), octet_length(currency))), 0) as \"field_bytes!\"\n FROM (SELECT c.name client_name, p.code, p.name, p.currency\n FROM projects p JOIN clients c ON c.id = p.client_id\n JOIN project_read_access access ON access.project_id = p.id AND access.org_id = p.org_id\n WHERE p.org_id = $1 AND access.user_id = $4 AND access.can_view_progress AND CASE $2\n WHEN 'budgeted' THEN p.active AND p.budget_kind <> 'none'\n WHEN 'archived' THEN NOT p.active ELSE p.active END\n LIMIT $3) bounded", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "rows!", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "bytes!", - "type_info": "Int8" - }, - { - "ordinal": 2, - "name": "field_bytes!", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Text", - "Int8", - "Uuid" - ] - }, - "nullable": [ - null, - null, - null - ] - }, - "hash": "717614d03bf82a999cdbb1a6e09e9b668322e9bff730466a7133c70e897f3547" -} diff --git a/.sqlx/query-7a40bfe0586ecd51eeb1e8372b8fbfdc97933624df15f58a1f1238d94e0779ac.json b/.sqlx/query-7a40bfe0586ecd51eeb1e8372b8fbfdc97933624df15f58a1f1238d94e0779ac.json new file mode 100644 index 00000000..9d681d73 --- /dev/null +++ b/.sqlx/query-7a40bfe0586ecd51eeb1e8372b8fbfdc97933624df15f58a1f1238d94e0779ac.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "SET search_path=project_export_test,public", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "7a40bfe0586ecd51eeb1e8372b8fbfdc97933624df15f58a1f1238d94e0779ac" +} diff --git a/.sqlx/query-7a934f1743be751c241638fb2483250435dd9ff7b9f322e007404204cbc8b373.json b/.sqlx/query-7a934f1743be751c241638fb2483250435dd9ff7b9f322e007404204cbc8b373.json new file mode 100644 index 00000000..f0adb531 --- /dev/null +++ b/.sqlx/query-7a934f1743be751c241638fb2483250435dd9ff7b9f322e007404204cbc8b373.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE assignments SET role='lead' WHERE project_id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "7a934f1743be751c241638fb2483250435dd9ff7b9f322e007404204cbc8b373" +} diff --git a/.sqlx/query-7fa1fa0173101e06ec2a92983d8ddea54b4cff97eb07128c9d54e18c09a9c8a6.json b/.sqlx/query-7fa1fa0173101e06ec2a92983d8ddea54b4cff97eb07128c9d54e18c09a9c8a6.json new file mode 100644 index 00000000..05c16f13 --- /dev/null +++ b/.sqlx/query-7fa1fa0173101e06ec2a92983d8ddea54b4cff97eb07128c9d54e18c09a9c8a6.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO projects (id,org_id,client_id,name,currency) VALUES ($1,$2,$3,'Second','EUR')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "7fa1fa0173101e06ec2a92983d8ddea54b4cff97eb07128c9d54e18c09a9c8a6" +} diff --git a/.sqlx/query-80b330f0ee312e31765f32a92b69b3d8938883df1ea694201e69089b2164341f.json b/.sqlx/query-80b330f0ee312e31765f32a92b69b3d8938883df1ea694201e69089b2164341f.json new file mode 100644 index 00000000..5fffcca3 --- /dev/null +++ b/.sqlx/query-80b330f0ee312e31765f32a92b69b3d8938883df1ea694201e69089b2164341f.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE organizations SET access_revision=access_revision+1 WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "80b330f0ee312e31765f32a92b69b3d8938883df1ea694201e69089b2164341f" +} diff --git a/.sqlx/query-85c3a82a7d1f8ecb6583be14b4838f807e9ca33c535a0114dc33986cd09989b9.json b/.sqlx/query-85c3a82a7d1f8ecb6583be14b4838f807e9ca33c535a0114dc33986cd09989b9.json new file mode 100644 index 00000000..5a7ed496 --- /dev/null +++ b/.sqlx/query-85c3a82a7d1f8ecb6583be14b4838f807e9ca33c535a0114dc33986cd09989b9.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE assignments SET role=$1 WHERE project_id=$2 AND user_id=$3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + { + "Custom": { + "name": "project_role", + "kind": { + "Enum": [ + "lead", + "freelancer", + "admin" + ] + } + } + }, + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "85c3a82a7d1f8ecb6583be14b4838f807e9ca33c535a0114dc33986cd09989b9" +} diff --git a/.sqlx/query-86ac8aa2719f8e17fe234836af8d8bc55ead301958cae60131b7e7737da910bb.json b/.sqlx/query-86ac8aa2719f8e17fe234836af8d8bc55ead301958cae60131b7e7737da910bb.json new file mode 100644 index 00000000..d98196b9 --- /dev/null +++ b/.sqlx/query-86ac8aa2719f8e17fe234836af8d8bc55ead301958cae60131b7e7737da910bb.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT pg_advisory_xact_lock(715015) AS \"lock!: ()\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "lock!: ()", + "type_info": "Void" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "86ac8aa2719f8e17fe234836af8d8bc55ead301958cae60131b7e7737da910bb" +} diff --git a/.sqlx/query-901934d5e79fe984b18d1756169b97956ba006a265f0ce9ae6c9ee096595d24f.json b/.sqlx/query-901934d5e79fe984b18d1756169b97956ba006a265f0ce9ae6c9ee096595d24f.json new file mode 100644 index 00000000..9908fe61 --- /dev/null +++ b/.sqlx/query-901934d5e79fe984b18d1756169b97956ba006a265f0ce9ae6c9ee096595d24f.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) AS \"count!\" FROM project_read_access\n WHERE org_id=$1 AND user_id=$2 AND project_id=ANY($3) AND can_view_progress", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "UuidArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "901934d5e79fe984b18d1756169b97956ba006a265f0ce9ae6c9ee096595d24f" +} diff --git a/.sqlx/query-965b129d7d81cdc3d3ca8391e47f0805f2e91e7fe0a45d9caa1081c0fc2dc495.json b/.sqlx/query-965b129d7d81cdc3d3ca8391e47f0805f2e91e7fe0a45d9caa1081c0fc2dc495.json new file mode 100644 index 00000000..89b94778 --- /dev/null +++ b/.sqlx/query-965b129d7d81cdc3d3ca8391e47f0805f2e91e7fe0a45d9caa1081c0fc2dc495.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO projects (id,org_id,client_id,name,currency)\n SELECT id,$2,$3,repeat('n',32767),'EUR' FROM unnest($1::uuid[]) id", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "UuidArray", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "965b129d7d81cdc3d3ca8391e47f0805f2e91e7fe0a45d9caa1081c0fc2dc495" +} diff --git a/.sqlx/query-9e03a7f1c2ffee76a82e906678b99277124127c305b0e95757ebb512daa774c6.json b/.sqlx/query-9e03a7f1c2ffee76a82e906678b99277124127c305b0e95757ebb512daa774c6.json new file mode 100644 index 00000000..d81ee1a0 --- /dev/null +++ b/.sqlx/query-9e03a7f1c2ffee76a82e906678b99277124127c305b0e95757ebb512daa774c6.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "CREATE FUNCTION project_export_test.pause() RETURNS boolean LANGUAGE plpgsql AS $$\n BEGIN PERFORM pg_advisory_xact_lock(715015); RETURN true; END $$", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "9e03a7f1c2ffee76a82e906678b99277124127c305b0e95757ebb512daa774c6" +} diff --git a/.sqlx/query-a50c777b6e48c702c9f49ab43695759dc41f75be4f287472d298c4b6b9cdda60.json b/.sqlx/query-a50c777b6e48c702c9f49ab43695759dc41f75be4f287472d298c4b6b9cdda60.json new file mode 100644 index 00000000..ff019c1a --- /dev/null +++ b/.sqlx/query-a50c777b6e48c702c9f49ab43695759dc41f75be4f287472d298c4b6b9cdda60.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT jsonb_build_object(\n 'invoice', (SELECT to_jsonb(i) FROM invoices i WHERE id=$1),\n 'lines', (SELECT jsonb_agg(to_jsonb(l) ORDER BY id) FROM invoice_line_items l WHERE invoice_id=$1),\n 'time', (SELECT jsonb_agg(to_jsonb(t) ORDER BY id) FROM time_entries t WHERE org_id=$2)\n ) AS \"snapshot!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "snapshot!", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "a50c777b6e48c702c9f49ab43695759dc41f75be4f287472d298c4b6b9cdda60" +} diff --git a/.sqlx/query-abc77b5701c214cf75a63f22c2cc7c532421874cccf4be74bbf3e5cbce415e8e.json b/.sqlx/query-abc77b5701c214cf75a63f22c2cc7c532421874cccf4be74bbf3e5cbce415e8e.json new file mode 100644 index 00000000..74579a91 --- /dev/null +++ b/.sqlx/query-abc77b5701c214cf75a63f22c2cc7c532421874cccf4be74bbf3e5cbce415e8e.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET org_role=$2 WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + { + "Custom": { + "name": "org_role", + "kind": { + "Enum": [ + "admin", + "manager", + "member" + ] + } + } + } + ] + }, + "nullable": [] + }, + "hash": "abc77b5701c214cf75a63f22c2cc7c532421874cccf4be74bbf3e5cbce415e8e" +} diff --git a/.sqlx/query-ac7cbccb7bfcc9bcf58105cf87ce63f6d44f07a3175af06e155d2d047e2db80c.json b/.sqlx/query-ac7cbccb7bfcc9bcf58105cf87ce63f6d44f07a3175af06e155d2d047e2db80c.json new file mode 100644 index 00000000..3cdb7a5f --- /dev/null +++ b/.sqlx/query-ac7cbccb7bfcc9bcf58105cf87ce63f6d44f07a3175af06e155d2d047e2db80c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM assignments WHERE project_id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "ac7cbccb7bfcc9bcf58105cf87ce63f6d44f07a3175af06e155d2d047e2db80c" +} diff --git a/.sqlx/query-ac89c32889ed4d6521b244d37968c87df8cb562789408b893f1b6f4f6df20be2.json b/.sqlx/query-ac89c32889ed4d6521b244d37968c87df8cb562789408b893f1b6f4f6df20be2.json new file mode 100644 index 00000000..99fedbec --- /dev/null +++ b/.sqlx/query-ac89c32889ed4d6521b244d37968c87df8cb562789408b893f1b6f4f6df20be2.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET org_role=$2, active=true WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + { + "Custom": { + "name": "org_role", + "kind": { + "Enum": [ + "admin", + "manager", + "member" + ] + } + } + } + ] + }, + "nullable": [] + }, + "hash": "ac89c32889ed4d6521b244d37968c87df8cb562789408b893f1b6f4f6df20be2" +} diff --git a/.sqlx/query-b84baf33fe92d42f176792204301cb54dabf2bf4c66b5781cf60a2c9222145f7.json b/.sqlx/query-b84baf33fe92d42f176792204301cb54dabf2bf4c66b5781cf60a2c9222145f7.json new file mode 100644 index 00000000..dc979945 --- /dev/null +++ b/.sqlx/query-b84baf33fe92d42f176792204301cb54dabf2bf4c66b5781cf60a2c9222145f7.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "CREATE VIEW project_export_test.project_read_access AS\n SELECT * FROM public.project_read_access WHERE project_export_test.pause()", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "b84baf33fe92d42f176792204301cb54dabf2bf4c66b5781cf60a2c9222145f7" +} diff --git a/.sqlx/query-bb2cb3fe4ce35ff3e94092e02261590e08d9cc64682d4ce7c3f5887bb176e8ed.json b/.sqlx/query-bb2cb3fe4ce35ff3e94092e02261590e08d9cc64682d4ce7c3f5887bb176e8ed.json new file mode 100644 index 00000000..ce9f23b9 --- /dev/null +++ b/.sqlx/query-bb2cb3fe4ce35ff3e94092e02261590e08d9cc64682d4ce7c3f5887bb176e8ed.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE projects SET name=repeat('界',10923) WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "bb2cb3fe4ce35ff3e94092e02261590e08d9cc64682d4ce7c3f5887bb176e8ed" +} diff --git a/.sqlx/query-bfadf2c3fe0a35ba8fea8b68173f2ff1dc9060de10a65e5219a08d2828ec7bb5.json b/.sqlx/query-bfadf2c3fe0a35ba8fea8b68173f2ff1dc9060de10a65e5219a08d2828ec7bb5.json new file mode 100644 index 00000000..16066c93 --- /dev/null +++ b/.sqlx/query-bfadf2c3fe0a35ba8fea8b68173f2ff1dc9060de10a65e5219a08d2828ec7bb5.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO invoices (id,org_id,client_id,number,issued_on,due_on,currency,total_cents) VALUES ($1,$2,$3,'EXPORT-PRIVATE','2026-09-07','2026-10-07','EUR',1234)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "bfadf2c3fe0a35ba8fea8b68173f2ff1dc9060de10a65e5219a08d2828ec7bb5" +} diff --git a/.sqlx/query-c2fc63f1e5633dab713800edab87e81382833138862012dd13c7db6d902931ca.json b/.sqlx/query-c2fc63f1e5633dab713800edab87e81382833138862012dd13c7db6d902931ca.json new file mode 100644 index 00000000..7697c4e6 --- /dev/null +++ b/.sqlx/query-c2fc63f1e5633dab713800edab87e81382833138862012dd13c7db6d902931ca.json @@ -0,0 +1,44 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT current_setting('transaction_isolation') AS \"isolation!\", current_setting('transaction_read_only') AS \"read_only!\", current_setting('statement_timeout') AS \"statement!\", current_setting('idle_in_transaction_session_timeout') AS \"idle!\", current_setting('lock_timeout') AS \"lock!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "isolation!", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "read_only!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "statement!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "idle!", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "lock!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + null, + null, + null, + null + ] + }, + "hash": "c2fc63f1e5633dab713800edab87e81382833138862012dd13c7db6d902931ca" +} diff --git a/.sqlx/query-c4a5c2dd3d7dfcd4bca19e53798bb7756fe6ec29ba5b02584663ff3502e62290.json b/.sqlx/query-c4a5c2dd3d7dfcd4bca19e53798bb7756fe6ec29ba5b02584663ff3502e62290.json new file mode 100644 index 00000000..9bcefe01 --- /dev/null +++ b/.sqlx/query-c4a5c2dd3d7dfcd4bca19e53798bb7756fe6ec29ba5b02584663ff3502e62290.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT current_setting('transaction_read_only') AS \"read_only!\",\n current_setting('transaction_isolation') AS \"isolation!\", current_setting('lock_timeout') AS \"timeout!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "read_only!", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "isolation!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "timeout!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "c4a5c2dd3d7dfcd4bca19e53798bb7756fe6ec29ba5b02584663ff3502e62290" +} diff --git a/.sqlx/query-cf1a30f4fbe4c95f53754a4d8d458d8b975b8eb2c1de7f9210b31f661bdfa5d1.json b/.sqlx/query-cf1a30f4fbe4c95f53754a4d8d458d8b975b8eb2c1de7f9210b31f661bdfa5d1.json new file mode 100644 index 00000000..793d38de --- /dev/null +++ b/.sqlx/query-cf1a30f4fbe4c95f53754a4d8d458d8b975b8eb2c1de7f9210b31f661bdfa5d1.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE time_entries SET notes=repeat('x',40000) WHERE org_id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "cf1a30f4fbe4c95f53754a4d8d458d8b975b8eb2c1de7f9210b31f661bdfa5d1" +} diff --git a/.sqlx/query-dbba9e01071380e154a27a2a4e10a2300fb059ae61db50b8ed8eab76245cc1ba.json b/.sqlx/query-dbba9e01071380e154a27a2a4e10a2300fb059ae61db50b8ed8eab76245cc1ba.json new file mode 100644 index 00000000..ebe19b95 --- /dev/null +++ b/.sqlx/query-dbba9e01071380e154a27a2a4e10a2300fb059ae61db50b8ed8eab76245cc1ba.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET org_role='member', active=$2 WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "dbba9e01071380e154a27a2a4e10a2300fb059ae61db50b8ed8eab76245cc1ba" +} diff --git a/.sqlx/query-57eb29df6705504900787fc8be1822e540c26e0293a5fe2b03b3d5dc403e25de.json b/.sqlx/query-e347eba58bab35cb82fb7e516e12929c7170cb394b6a3cb08ec89243873d53a0.json similarity index 64% rename from .sqlx/query-57eb29df6705504900787fc8be1822e540c26e0293a5fe2b03b3d5dc403e25de.json rename to .sqlx/query-e347eba58bab35cb82fb7e516e12929c7170cb394b6a3cb08ec89243873d53a0.json index 194a8dfb..80374c7e 100644 --- a/.sqlx/query-57eb29df6705504900787fc8be1822e540c26e0293a5fe2b03b3d5dc403e25de.json +++ b/.sqlx/query-e347eba58bab35cb82fb7e516e12929c7170cb394b6a3cb08ec89243873d53a0.json @@ -1,25 +1,30 @@ { "db_name": "PostgreSQL", - "query": "SELECT c.name as client_name, p.code, p.name,\n p.project_type as \"project_type: horae_core::types::ProjectType\",\n p.currency,\n p.budget_kind as \"budget_kind: horae_core::types::BudgetKind\",\n p.budget_amount_cents, p.budget_minutes, p.active\n FROM projects p\n JOIN clients c ON c.id = p.client_id\n JOIN project_read_access access ON access.project_id = p.id AND access.org_id = p.org_id\n WHERE p.org_id = $1 AND access.user_id = $3 AND access.can_view_progress AND CASE $2\n WHEN 'budgeted' THEN p.active AND p.budget_kind <> 'none'\n WHEN 'archived' THEN NOT p.active ELSE p.active END\n ORDER BY c.name, p.name, p.id", + "query": "SELECT p.id, c.name as client_name, p.code, p.name,\n p.project_type as \"project_type: horae_core::types::ProjectType\",\n p.currency,\n p.budget_kind as \"budget_kind: horae_core::types::BudgetKind\",\n p.budget_amount_cents, p.budget_minutes, p.active\n FROM projects p\n JOIN clients c ON c.id = p.client_id\n JOIN project_read_access access ON access.project_id = p.id AND access.org_id = p.org_id\n WHERE p.org_id = $1 AND access.user_id = $3 AND access.can_view_progress AND CASE $2\n WHEN 'budgeted' THEN p.active AND p.budget_kind <> 'none'\n WHEN 'archived' THEN NOT p.active ELSE p.active END\n ORDER BY c.name, p.name, p.id", "describe": { "columns": [ { "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, "name": "client_name", "type_info": "Text" }, { - "ordinal": 1, + "ordinal": 2, "name": "code", "type_info": "Text" }, { - "ordinal": 2, + "ordinal": 3, "name": "name", "type_info": "Text" }, { - "ordinal": 3, + "ordinal": 4, "name": "project_type: horae_core::types::ProjectType", "type_info": { "Custom": { @@ -36,12 +41,12 @@ } }, { - "ordinal": 4, + "ordinal": 5, "name": "currency", "type_info": "Bpchar" }, { - "ordinal": 5, + "ordinal": 6, "name": "budget_kind: horae_core::types::BudgetKind", "type_info": { "Custom": { @@ -57,17 +62,17 @@ } }, { - "ordinal": 6, + "ordinal": 7, "name": "budget_amount_cents", "type_info": "Int8" }, { - "ordinal": 7, + "ordinal": 8, "name": "budget_minutes", "type_info": "Int8" }, { - "ordinal": 8, + "ordinal": 9, "name": "active", "type_info": "Bool" } @@ -80,6 +85,7 @@ ] }, "nullable": [ + false, false, true, false, @@ -91,5 +97,5 @@ false ] }, - "hash": "57eb29df6705504900787fc8be1822e540c26e0293a5fe2b03b3d5dc403e25de" + "hash": "e347eba58bab35cb82fb7e516e12929c7170cb394b6a3cb08ec89243873d53a0" } diff --git a/.sqlx/query-e662ef38037adae52938efea69fe6ee60e83745479075779a59b427d227d9750.json b/.sqlx/query-e662ef38037adae52938efea69fe6ee60e83745479075779a59b427d227d9750.json new file mode 100644 index 00000000..cebeb7cc --- /dev/null +++ b/.sqlx/query-e662ef38037adae52938efea69fe6ee60e83745479075779a59b427d227d9750.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE invoices SET notes='Refreshed' WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "e662ef38037adae52938efea69fe6ee60e83745479075779a59b427d227d9750" +} diff --git a/.sqlx/query-e8ee71e047bda91a68bc2b15d66e4d6b504be36113b38178412f81d487a842f2.json b/.sqlx/query-e8ee71e047bda91a68bc2b15d66e4d6b504be36113b38178412f81d487a842f2.json new file mode 100644 index 00000000..994bedbe --- /dev/null +++ b/.sqlx/query-e8ee71e047bda91a68bc2b15d66e4d6b504be36113b38178412f81d487a842f2.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE projects SET name=repeat('x',40000) WHERE org_id=$1 AND id<>$2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "e8ee71e047bda91a68bc2b15d66e4d6b504be36113b38178412f81d487a842f2" +} diff --git a/.sqlx/query-f0400ac12cd7470a9bb87ae1827258e001d0e800ad60d535fe1764e79f2ebd39.json b/.sqlx/query-f0400ac12cd7470a9bb87ae1827258e001d0e800ad60d535fe1764e79f2ebd39.json new file mode 100644 index 00000000..10c50219 --- /dev/null +++ b/.sqlx/query-f0400ac12cd7470a9bb87ae1827258e001d0e800ad60d535fe1764e79f2ebd39.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET org_role=$1 WHERE id=$2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + { + "Custom": { + "name": "org_role", + "kind": { + "Enum": [ + "admin", + "manager", + "member" + ] + } + } + }, + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "f0400ac12cd7470a9bb87ae1827258e001d0e800ad60d535fe1764e79f2ebd39" +} diff --git a/.sqlx/query-fadf96822435f8b36a6bb10cc9c6d5a5e7aadfc2e7f315d1563b7a9d9100c10a.json b/.sqlx/query-fadf96822435f8b36a6bb10cc9c6d5a5e7aadfc2e7f315d1563b7a9d9100c10a.json new file mode 100644 index 00000000..7af3a9c8 --- /dev/null +++ b/.sqlx/query-fadf96822435f8b36a6bb10cc9c6d5a5e7aadfc2e7f315d1563b7a9d9100c10a.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE assignments SET role='freelancer' WHERE project_id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "fadf96822435f8b36a6bb10cc9c6d5a5e7aadfc2e7f315d1563b7a9d9100c10a" +} diff --git a/.sqlx/query-fb3c8c2b27c1af2fd14d156f71727ec11222ea72b278c18be93dd027c3d0345f.json b/.sqlx/query-fb3c8c2b27c1af2fd14d156f71727ec11222ea72b278c18be93dd027c3d0345f.json new file mode 100644 index 00000000..a3058bd8 --- /dev/null +++ b/.sqlx/query-fb3c8c2b27c1af2fd14d156f71727ec11222ea72b278c18be93dd027c3d0345f.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO project_settings (id,org_id,project_id,creator_id,rate_mode,report_visibility)\n VALUES ($1,$2,$3,$4,'person',$5) ON CONFLICT (project_id) DO UPDATE SET report_visibility=EXCLUDED.report_visibility", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "fb3c8c2b27c1af2fd14d156f71727ec11222ea72b278c18be93dd027c3d0345f" +} diff --git a/.sqlx/query-fcd84444ec98392c829ea4c5093f7b0e28b45499efa1dcd123fd61a3542c8b4a.json b/.sqlx/query-fcd84444ec98392c829ea4c5093f7b0e28b45499efa1dcd123fd61a3542c8b4a.json new file mode 100644 index 00000000..fd1700a4 --- /dev/null +++ b/.sqlx/query-fcd84444ec98392c829ea4c5093f7b0e28b45499efa1dcd123fd61a3542c8b4a.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE time_entries SET notes='Refreshed' WHERE org_id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "fcd84444ec98392c829ea4c5093f7b0e28b45499efa1dcd123fd61a3542c8b4a" +} diff --git a/crates/horae/src/reports.rs b/crates/horae/src/reports.rs index a57ae96c..c8ee3702 100644 --- a/crates/horae/src/reports.rs +++ b/crates/horae/src/reports.rs @@ -41,9 +41,8 @@ async fn require_session(session: &Session) -> Result<(uuid::Uuid, uuid::Uuid), } /// Every invoice server function gates on `require_manager`, so exporting one -/// has to as well. Returns the manager's org id so the invoice fetches below -/// can be org-scoped exactly like their server-fn counterparts. -async fn require_manager(session: &Session) -> Result { +/// has to as well. Retain both actor and tenant for transaction reauthorization. +async fn require_manager(session: &Session) -> Result<(uuid::Uuid, uuid::Uuid), StatusCode> { let (user_id, _) = require_session(session).await?; let state = crate::state::global_state().await; let row = sqlx::query!( @@ -58,10 +57,27 @@ async fn require_manager(session: &Session) -> Result { row.org_role .is_manager_or_above() - .then_some(row.org_id) + .then_some((user_id, row.org_id)) .ok_or(StatusCode::FORBIDDEN) } +async fn render_manager_export( + permit: bounded::ExportPermit, + pool: &sqlx::PgPool, + org_id: uuid::Uuid, + actor_id: uuid::Uuid, + render: impl FnOnce() -> Result, StatusCode> + Send + 'static, +) -> Result { + let body = permit.render(render).await?; + // Rendering holds no authority locks. Recheck before releasing its result. + limits::begin_manager(pool, org_id, actor_id) + .await? + .commit() + .await + .map_err(limits::database_error)?; + Ok(body) +} + /// Mirrors the Reports page filters, so a download matches what is on screen. /// Absent client/project/user/tag means "all", as on the page. #[derive(Deserialize)] @@ -154,7 +170,7 @@ pub async fn export_csv( ) -> Result { // Same rows as the manager-only `report_detailed` server fn (every user's // hours and notes), so the same gate applies. - let org_id = require_manager(&session).await?; + let (_, org_id) = require_manager(&session).await?; let state = crate::state::global_state().await; streaming::entries(state.db.clone(), org_id, params).await @@ -209,13 +225,16 @@ pub async fn export_xlsx( ) -> Result { // Same rows as the manager-only `report_detailed` server fn (every user's // hours and notes), so the same gate applies. - let org_id = require_manager(&session).await?; + let (actor_id, org_id) = require_manager(&session).await?; let permit = bounded::ExportPermit::acquire()?; let state = crate::state::global_state().await; - let entries = limits::entries(&state.db, org_id, ¶ms).await?; + let entries = limits::entries(&state.db, org_id, actor_id, ¶ms).await?; - let data = permit.render(move || entries_xlsx(&entries)).await?; + let data = render_manager_export(permit, &state.db, org_id, actor_id, move || { + entries_xlsx(&entries) + }) + .await?; Ok(( [ @@ -292,6 +311,7 @@ pub struct ProjectsExportParams { } struct ProjectExportRow { + id: uuid::Uuid, client_name: String, code: Option, name: String, @@ -312,6 +332,7 @@ fn budget_cell(r: &ProjectExportRow) -> String { ) } +#[cfg(test)] async fn fetch_projects_export<'e>( executor: impl sqlx::PgExecutor<'e> + 'e, org_id: uuid::Uuid, @@ -331,7 +352,7 @@ fn stream_projects_export<'e>( ) -> impl Stream> + 'e { sqlx::query_as!( ProjectExportRow, - r#"SELECT c.name as client_name, p.code, p.name, + r#"SELECT p.id, c.name as client_name, p.code, p.name, p.project_type as "project_type: horae_core::types::ProjectType", p.currency, p.budget_kind as "budget_kind: horae_core::types::BudgetKind", @@ -375,7 +396,16 @@ pub async fn export_projects_xlsx( let state = crate::state::global_state().await; let rows = limits::projects(&state.db, org_id, viewer_id, scope).await?; - let data = permit.render(move || projects_xlsx(&rows)).await?; + let project_ids = rows.iter().map(|row| row.id).collect(); + let data = render_project_export( + permit, + &state.db, + org_id, + viewer_id, + project_ids, + move || projects_xlsx(&rows), + ) + .await?; Ok(( [ @@ -392,6 +422,21 @@ pub async fn export_projects_xlsx( )) } +async fn render_project_export( + permit: bounded::ExportPermit, + pool: &sqlx::PgPool, + org_id: uuid::Uuid, + actor_id: uuid::Uuid, + mut project_ids: Vec, + render: impl FnOnce() -> Result, StatusCode> + Send + 'static, +) -> Result { + project_ids.sort_unstable(); + project_ids.dedup(); + let body = permit.render(render).await?; + limits::authorize_projects(pool, org_id, actor_id, &project_ids).await?; + Ok(body) +} + fn projects_xlsx(rows: &[ProjectExportRow]) -> Result, StatusCode> { let mut workbook = rust_xlsxwriter::Workbook::new(); let worksheet = workbook.add_worksheet(); @@ -508,7 +553,7 @@ pub async fn export_invoice_csv( session: Session, Path(invoice_id): Path, ) -> Result { - let org_id = require_manager(&session).await?; + let (_, org_id) = require_manager(&session).await?; let state = crate::state::global_state().await; streaming::invoice(state.db.clone(), org_id, invoice_id).await @@ -518,16 +563,17 @@ pub async fn export_invoice_xlsx( session: Session, Path(invoice_id): Path, ) -> Result { - let org_id = require_manager(&session).await?; + let (actor_id, org_id) = require_manager(&session).await?; let permit = bounded::ExportPermit::acquire()?; let state = crate::state::global_state().await; - let (invoice, lines) = limits::invoice(&state.db, org_id, invoice_id).await?; + let (invoice, lines) = limits::invoice(&state.db, org_id, actor_id, invoice_id).await?; let filename = format!("invoice-{}.xlsx", invoice.number); - let data = permit - .render(move || invoice_xlsx(&invoice, &lines)) - .await?; + let data = render_manager_export(permit, &state.db, org_id, actor_id, move || { + invoice_xlsx(&invoice, &lines) + }) + .await?; Ok(( [ @@ -636,29 +682,28 @@ pub async fn export_invoice_pdf( session: Session, Path(invoice_id): Path, ) -> Result { - let org_id = require_manager(&session).await?; + let (actor_id, org_id) = require_manager(&session).await?; let permit = bounded::ExportPermit::acquire()?; let state = crate::state::global_state().await; - let document = limits::pdf(&state.db, org_id, invoice_id).await?; + let document = limits::pdf(&state.db, org_id, actor_id, invoice_id).await?; let filename = format!("invoice-{}.pdf", document.invoice.number); - let pdf_bytes = permit - .render(move || { - crate::render::render_invoice_pdf( - &document.invoice, - &document.lines, - &document.client_name, - document.client_address.as_deref(), - document.client_tax_id.as_deref(), - &document.branding, - ) - .map_err(|e| { - tracing::error!("PDF rendering failed: {e}"); - StatusCode::INTERNAL_SERVER_ERROR - }) + let pdf_bytes = render_manager_export(permit, &state.db, org_id, actor_id, move || { + crate::render::render_invoice_pdf( + &document.invoice, + &document.lines, + &document.client_name, + document.client_address.as_deref(), + document.client_tax_id.as_deref(), + &document.branding, + ) + .map_err(|e| { + tracing::error!("PDF rendering failed: {e}"); + StatusCode::INTERNAL_SERVER_ERROR }) - .await?; + }) + .await?; Ok(( [ ( diff --git a/crates/horae/src/reports/limits.rs b/crates/horae/src/reports/limits.rs index fe19ca62..cbc9ad6c 100644 --- a/crates/horae/src/reports/limits.rs +++ b/crates/horae/src/reports/limits.rs @@ -6,6 +6,9 @@ use crate::models::{DetailedReportRow, Invoice, InvoiceLine, OrgBranding}; use super::{ExportParams, ProjectExportRow}; +mod project; +pub(super) use project::{authorize_projects, projects}; + const MAX_FIELD_BYTES: i32 = 32_767; #[derive(Clone, Copy)] @@ -42,18 +45,40 @@ pub(super) fn database_error(error: sqlx::Error) -> StatusCode { } } +#[cfg(test)] async fn begin(pool: &PgPool) -> Result, StatusCode> { let mut tx = pool.begin().await.map_err(database_error)?; configure_transaction(&mut tx).await?; Ok(tx) } +pub(super) async fn begin_manager( + pool: &PgPool, + org_id: Uuid, + actor_id: Uuid, +) -> Result, StatusCode> { + let mut tx = crate::server_fns::snapshot::manager(pool, org_id, actor_id) + .await + .map_err(|error| match error { + dioxus::prelude::ServerFnError::ServerError { code, .. } => { + StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR) + } + _ => StatusCode::INTERNAL_SERVER_ERROR, + })?; + configure_deadlines(&mut tx).await?; + Ok(tx) +} + pub(super) async fn configure_transaction(connection: &mut PgConnection) -> Result<(), StatusCode> { // Size checks and payload reads must see exactly the same rows and text. sqlx::query!("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY") .execute(&mut *connection) .await .map_err(database_error)?; + configure_deadlines(connection).await +} + +async fn configure_deadlines(connection: &mut PgConnection) -> Result<(), StatusCode> { sqlx::query!("SET LOCAL statement_timeout = '5s'") .execute(&mut *connection) .await @@ -68,11 +93,12 @@ pub(super) async fn configure_transaction(connection: &mut PgConnection) -> Resu pub(super) async fn entries( pool: &PgPool, org_id: Uuid, + actor_id: Uuid, params: &ExportParams, ) -> Result, StatusCode> { let from: chrono::NaiveDate = params.from.parse().map_err(|_| StatusCode::BAD_REQUEST)?; let to: chrono::NaiveDate = params.to.parse().map_err(|_| StatusCode::BAD_REQUEST)?; - let mut tx = begin(pool).await?; + let mut tx = begin_manager(pool, org_id, actor_id).await?; let size = sqlx::query!( r#"SELECT COUNT(*) as "rows!", COALESCE(SUM(octet_length(project_name)::bigint + octet_length(task_name) @@ -104,42 +130,6 @@ pub(super) async fn entries( Ok(rows) } -pub(super) async fn projects( - pool: &PgPool, - org_id: Uuid, - viewer_id: Uuid, - scope: &str, -) -> Result, StatusCode> { - let mut tx = begin(pool).await?; - let size = sqlx::query!( - r#"SELECT COUNT(*) as "rows!", - COALESCE(SUM(octet_length(client_name)::bigint + COALESCE(octet_length(code), 0) - + octet_length(name) + octet_length(currency)), 0)::bigint as "bytes!", - COALESCE(MAX(GREATEST(octet_length(client_name), COALESCE(octet_length(code), 0), - octet_length(name), octet_length(currency))), 0) as "field_bytes!" - FROM (SELECT c.name client_name, p.code, p.name, p.currency - FROM projects p JOIN clients c ON c.id = p.client_id - JOIN project_read_access access ON access.project_id = p.id AND access.org_id = p.org_id - WHERE p.org_id = $1 AND access.user_id = $4 AND access.can_view_progress AND CASE $2 - WHEN 'budgeted' THEN p.active AND p.budget_kind <> 'none' - WHEN 'archived' THEN NOT p.active ELSE p.active END - LIMIT $3) bounded"#, - org_id, - scope, - XLSX.rows + 1, - viewer_id, - ) - .fetch_one(&mut *tx) - .await - .map_err(database_error)?; - check(size.rows, size.bytes, size.field_bytes, XLSX)?; - let rows = super::fetch_projects_export(&mut *tx, org_id, viewer_id, scope) - .await - .map_err(database_error)?; - tx.commit().await.map_err(database_error)?; - Ok(rows) -} - async fn read_invoice( connection: &mut PgConnection, org_id: Uuid, @@ -175,9 +165,10 @@ async fn read_invoice( pub(super) async fn invoice( pool: &PgPool, org_id: Uuid, + actor_id: Uuid, invoice_id: Uuid, ) -> Result<(Invoice, Vec), StatusCode> { - let mut tx = begin(pool).await?; + let mut tx = begin_manager(pool, org_id, actor_id).await?; let result = read_invoice(&mut tx, org_id, invoice_id, XLSX).await?; tx.commit().await.map_err(database_error)?; Ok(result) @@ -195,9 +186,10 @@ pub(super) struct PdfInvoice { pub(super) async fn pdf( pool: &PgPool, org_id: Uuid, + actor_id: Uuid, invoice_id: Uuid, ) -> Result { - let mut tx = begin(pool).await?; + let mut tx = begin_manager(pool, org_id, actor_id).await?; let size = sqlx::query!( r#"SELECT COALESCE(SUM(octet_length(value)::bigint), 0)::bigint as "bytes!", COALESCE(MAX(octet_length(value)), 0) as "field_bytes!" @@ -248,6 +240,9 @@ mod tests { use horae_core::types::OrgRole; use std::io::{Cursor, Read}; + mod authorization; + mod project_authorization; + fn xlsx_part(bytes: &[u8], path: &str) -> String { let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); let mut content = String::new(); @@ -297,7 +292,7 @@ mod tests { async fn invoice_exports_show_saved_adjustments_and_exact_large_amounts(pool: PgPool) { let ids = seed(&pool, OrgRole::Manager).await; let (id, _) = add_invoice(&pool, &ids, 1).await; - let mut document = pdf(&pool, ids.org_id, id).await.unwrap(); + let mut document = pdf(&pool, ids.org_id, ids.user_id, id).await.unwrap(); let inv = &mut document.invoice; inv.subtotal_cents = 10001; inv.discount_bps = 1250; @@ -442,12 +437,13 @@ mod tests { .await .unwrap(); assert!(matches!( - entries(&pool, ids.org_id, ¶ms()).await, + entries(&pool, ids.org_id, ids.user_id, ¶ms()).await, Err(StatusCode::PAYLOAD_TOO_LARGE) )); let rows = entries( &pool, ids.org_id, + ids.user_id, &ExportParams { tag_id: Some(tag), ..params() @@ -476,12 +472,15 @@ mod tests { add_entries(&pool, &other, 1).await; add_entries(&pool, &ids, XLSX.rows as usize).await; assert_eq!( - entries(&pool, ids.org_id, ¶ms()).await.unwrap().len(), + entries(&pool, ids.org_id, ids.user_id, ¶ms()) + .await + .unwrap() + .len(), XLSX.rows as usize ); add_entries(&pool, &ids, 1).await; assert!(matches!( - entries(&pool, ids.org_id, ¶ms()).await, + entries(&pool, ids.org_id, ids.user_id, ¶ms()).await, Err(StatusCode::PAYLOAD_TOO_LARGE) )); for filter in [ @@ -508,7 +507,7 @@ mod tests { }, ] { assert!( - entries(&pool, ids.org_id, &filter) + entries(&pool, ids.org_id, ids.user_id, &filter) .await .unwrap() .is_empty() @@ -529,7 +528,7 @@ mod tests { .await .unwrap(); assert!(matches!( - entries(&pool, ids.org_id, ¶ms()).await, + entries(&pool, ids.org_id, ids.user_id, ¶ms()).await, Err(StatusCode::PAYLOAD_TOO_LARGE) )); sqlx::query!( @@ -540,7 +539,9 @@ mod tests { .await .unwrap(); assert_eq!( - entries(&pool, ids.org_id, ¶ms()).await.unwrap()[0] + entries(&pool, ids.org_id, ids.user_id, ¶ms()) + .await + .unwrap()[0] .notes .as_ref() .unwrap() @@ -556,7 +557,7 @@ mod tests { .await .unwrap(); assert!(matches!( - entries(&pool, ids.org_id, ¶ms()).await, + entries(&pool, ids.org_id, ids.user_id, ¶ms()).await, Err(StatusCode::PAYLOAD_TOO_LARGE) )); } @@ -619,11 +620,15 @@ mod tests { let other = seed(&pool, OrgRole::Manager).await; let (id, lines) = add_invoice(&pool, &ids, PDF.rows as usize + 1).await; assert_eq!( - invoice(&pool, ids.org_id, id).await.unwrap().1.len(), + invoice(&pool, ids.org_id, ids.user_id, id) + .await + .unwrap() + .1 + .len(), PDF.rows as usize + 1 ); assert!(matches!( - pdf(&pool, ids.org_id, id).await, + pdf(&pool, ids.org_id, ids.user_id, id).await, Err(StatusCode::PAYLOAD_TOO_LARGE) )); sqlx::query!("DELETE FROM invoice_line_items WHERE id = $1", lines[0]) @@ -631,15 +636,19 @@ mod tests { .await .unwrap(); assert_eq!( - pdf(&pool, ids.org_id, id).await.unwrap().lines.len(), + pdf(&pool, ids.org_id, ids.user_id, id) + .await + .unwrap() + .lines + .len(), PDF.rows as usize ); assert!(matches!( - invoice(&pool, other.org_id, id).await, + invoice(&pool, other.org_id, other.user_id, id).await, Err(StatusCode::NOT_FOUND) )); assert!(matches!( - pdf(&pool, other.org_id, id).await, + pdf(&pool, other.org_id, other.user_id, id).await, Err(StatusCode::NOT_FOUND) )); sqlx::query!( @@ -650,10 +659,10 @@ mod tests { .await .unwrap(); assert!(matches!( - pdf(&pool, ids.org_id, id).await, + pdf(&pool, ids.org_id, ids.user_id, id).await, Err(StatusCode::PAYLOAD_TOO_LARGE) )); - assert!(invoice(&pool, ids.org_id, id).await.is_ok()); + assert!(invoice(&pool, ids.org_id, ids.user_id, id).await.is_ok()); } #[sqlx::test(migrations = "./migrations")] @@ -674,7 +683,7 @@ mod tests { assert_eq!(before, during); tx.commit().await.unwrap(); assert!(matches!( - invoice(&pool, ids.org_id, id).await, + invoice(&pool, ids.org_id, ids.user_id, id).await, Err(StatusCode::PAYLOAD_TOO_LARGE) )); } @@ -716,7 +725,7 @@ mod tests { .unwrap(); sqlx::query!("INSERT INTO project_fee_occurrences (id,org_id,project_id,period_key,due_on,description,amount_cents,currency) VALUES ($1,$2,$3,'single','2026-09-01','Fixed fee',12500,'EUR')", fee_id, ids.org_id, ids.project_id).execute(&pool).await.unwrap(); sqlx::query!("INSERT INTO invoice_line_items (id,invoice_id,fee_occurrence_id,description,amount_cents) VALUES ($1,$2,$3,'Fixed fee',12500)", uuid::Uuid::now_v7(), id, fee_id).execute(&pool).await.unwrap(); - let (invoice, lines) = invoice(&pool, ids.org_id, id).await.unwrap(); + let (invoice, lines) = invoice(&pool, ids.org_id, ids.user_id, id).await.unwrap(); let workbook = super::super::invoice_xlsx(&invoice, &lines).unwrap(); let xml = xlsx_part(&workbook, "xl/worksheets/sheet1.xml"); assert!(!xml.contains("r=\"B2\"")); @@ -724,7 +733,7 @@ mod tests { assert_cell(&xml, "D2", "125"); assert_cell(&xml, "D3", "125"); assert_cell(&xml, "D4", "125"); - let document = pdf(&pool, ids.org_id, id).await.unwrap(); + let document = pdf(&pool, ids.org_id, ids.user_id, id).await.unwrap(); tokio::task::spawn_blocking(move || { let render = || { crate::render::render_invoice_pdf( @@ -757,7 +766,9 @@ mod tests { .execute(&pool) .await .unwrap(); - let rows = entries(&pool, ids.org_id, ¶ms()).await.unwrap(); + let rows = entries(&pool, ids.org_id, ids.user_id, ¶ms()) + .await + .unwrap(); let sheet = super::super::entries_xlsx(&rows).unwrap(); let xml = xlsx_part(&sheet, "xl/worksheets/sheet1.xml"); assert_cell(&xml, "E2", "1"); @@ -768,14 +779,14 @@ mod tests { .unwrap(); let sheet = super::super::projects_xlsx(&rows).unwrap(); assert!(xlsx_part(&sheet, "xl/sharedStrings.xml").contains("Widget")); - let (invoice, lines) = invoice(&pool, ids.org_id, id).await.unwrap(); + let (invoice, lines) = invoice(&pool, ids.org_id, ids.user_id, id).await.unwrap(); let sheet = super::super::invoice_xlsx(&invoice, &lines).unwrap(); let xml = xlsx_part(&sheet, "xl/worksheets/sheet1.xml"); assert_cell(&xml, "C2", "12.34"); assert_cell(&xml, "D2", "12.34"); assert_cell(&xml, "D3", "12.34"); assert_cell(&xml, "D4", "12.34"); - let document = pdf(&pool, ids.org_id, id).await.unwrap(); + let document = pdf(&pool, ids.org_id, ids.user_id, id).await.unwrap(); tokio::task::spawn_blocking(move || { let render = || { crate::render::render_invoice_pdf( @@ -804,7 +815,9 @@ mod tests { let (id, _) = add_invoice(&pool, &ids, PDF.rows as usize).await; add_entries(&pool, &ids, (XLSX.rows - PDF.rows) as usize).await; let start = std::time::Instant::now(); - let rows = entries(&pool, ids.org_id, ¶ms()).await.unwrap(); + let rows = entries(&pool, ids.org_id, ids.user_id, ¶ms()) + .await + .unwrap(); assert_eq!(rows.len(), XLSX.rows as usize); let loaded = start.elapsed(); let bytes = tokio::task::spawn_blocking(move || super::super::entries_xlsx(&rows).unwrap()) @@ -825,7 +838,7 @@ mod tests { } drop(bytes); let start = std::time::Instant::now(); - let document = pdf(&pool, ids.org_id, id).await.unwrap(); + let document = pdf(&pool, ids.org_id, ids.user_id, id).await.unwrap(); let loaded = start.elapsed(); let bytes = tokio::task::spawn_blocking(move || { crate::render::render_invoice_pdf( diff --git a/crates/horae/src/reports/limits/project.rs b/crates/horae/src/reports/limits/project.rs new file mode 100644 index 00000000..20b1d16e --- /dev/null +++ b/crates/horae/src/reports/limits/project.rs @@ -0,0 +1,129 @@ +use super::*; + +async fn begin_project_read( + pool: &PgPool, + org_id: Uuid, + actor_id: Uuid, +) -> Result, StatusCode> { + let mut tx = pool.begin().await.map_err(database_error)?; + sqlx::query!("SET TRANSACTION ISOLATION LEVEL READ COMMITTED, READ WRITE") + .execute(&mut *tx) + .await + .map_err(database_error)?; + configure_deadlines(&mut tx).await?; + crate::db::lock_organization(&mut tx, org_id, crate::db::OrganizationLock::Shared) + .await + .map_err(|error| match error { + sqlx::Error::RowNotFound => StatusCode::FORBIDDEN, + other => database_error(other), + })?; + sqlx::query_scalar!( + "SELECT id FROM users WHERE id=$1 AND org_id=$2 AND active FOR SHARE", + actor_id, + org_id, + ) + .fetch_optional(&mut *tx) + .await + .map_err(database_error)? + .ok_or(StatusCode::FORBIDDEN)?; + Ok(tx) +} + +pub(in crate::reports) async fn projects( + pool: &PgPool, + org_id: Uuid, + actor_id: Uuid, + scope: &str, +) -> Result, StatusCode> { + let mut tx = begin_project_read(pool, org_id, actor_id).await?; + // One fresh statement sees completed scope changes after the gate wait, + // while keeping size checks and payload on the same database snapshot. + let records = sqlx::query!( + r#"WITH bounded AS MATERIALIZED ( + SELECT p.id, c.name client_name, p.code, p.name, p.project_type, + p.currency, p.budget_kind, p.budget_amount_cents, p.budget_minutes, p.active + FROM projects p JOIN clients c ON c.id=p.client_id + JOIN project_read_access a ON a.project_id=p.id AND a.org_id=p.org_id + WHERE p.org_id=$1 AND a.user_id=$2 AND a.can_view_progress AND CASE $3 + WHEN 'budgeted' THEN p.active AND p.budget_kind <> 'none' + WHEN 'archived' THEN NOT p.active ELSE p.active END + LIMIT $4::bigint + 1 + ), stats AS ( + SELECT COUNT(*) rows, + COALESCE(SUM(octet_length(client_name)::bigint + COALESCE(octet_length(code),0) + + octet_length(name) + octet_length(currency)),0)::bigint bytes, + COALESCE(MAX(GREATEST(octet_length(client_name), COALESCE(octet_length(code),0), + octet_length(name), octet_length(currency))),0) field_bytes + FROM bounded + ) + SELECT s.rows AS "rows!", s.bytes AS "bytes!", s.field_bytes AS "field_bytes!", + b.id AS "id?", b.client_name AS "client_name?", b.code, b.name AS "name?", + b.project_type AS "project_type?: horae_core::types::ProjectType", + b.currency AS "currency?", b.budget_kind AS "budget_kind?: horae_core::types::BudgetKind", + b.budget_amount_cents, b.budget_minutes, b.active AS "active?" + FROM stats s LEFT JOIN bounded b ON s.rows <= $4 AND s.bytes <= $5 AND s.field_bytes <= $6 + ORDER BY b.client_name, b.name, b.id"#, + org_id, actor_id, scope, XLSX.rows, XLSX.bytes, MAX_FIELD_BYTES, + ).fetch_all(&mut *tx).await.map_err(database_error)?; + let mut rows = Vec::with_capacity(records.len()); + for record in records { + check(record.rows, record.bytes, record.field_bytes, XLSX)?; + let Some(id) = record.id else { continue }; + rows.push(ProjectExportRow { + id, + client_name: record + .client_name + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?, + code: record.code, + name: record.name.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?, + project_type: record + .project_type + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?, + currency: record.currency.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?, + budget_kind: record + .budget_kind + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?, + budget_amount_cents: record.budget_amount_cents, + budget_minutes: record.budget_minutes, + active: record.active.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?, + }); + } + tx.commit().await.map_err(database_error)?; + Ok(rows) +} + +pub(in crate::reports) async fn authorize_projects( + pool: &PgPool, + org_id: Uuid, + actor_id: Uuid, + project_ids: &[Uuid], +) -> Result<(), StatusCode> { + let mut tx = begin_project_read(pool, org_id, actor_id).await?; + let locked = sqlx::query_scalar!( + "SELECT id FROM projects WHERE org_id=$1 AND id=ANY($2) ORDER BY id FOR SHARE", + org_id, + project_ids, + ) + .fetch_all(&mut *tx) + .await + .map_err(database_error)?; + if locked.len() != project_ids.len() { + return Err(StatusCode::FORBIDDEN); + } + // Assignment/settings triggers write the parent. Refresh the relationship + // snapshot only after acquiring those parents, not in the locking query. + let allowed = sqlx::query_scalar!( + r#"SELECT COUNT(*) AS "count!" FROM project_read_access + WHERE org_id=$1 AND user_id=$2 AND project_id=ANY($3) AND can_view_progress"#, + org_id, + actor_id, + project_ids, + ) + .fetch_one(&mut *tx) + .await + .map_err(database_error)?; + if usize::try_from(allowed).ok() != Some(project_ids.len()) { + return Err(StatusCode::FORBIDDEN); + } + tx.commit().await.map_err(database_error) +} diff --git a/crates/horae/src/reports/limits/tests/authorization.rs b/crates/horae/src/reports/limits/tests/authorization.rs new file mode 100644 index 00000000..529f2abf --- /dev/null +++ b/crates/horae/src/reports/limits/tests/authorization.rs @@ -0,0 +1,691 @@ +use super::*; +use crate::server_fns::test_seed::wait_for_blocked; +use serde_json::{Value, json}; +use std::{sync::Arc, time::Duration}; + +#[derive(Clone, Copy, Debug)] +enum Reader { + Entries, + Invoice, + Pdf, +} + +const READERS: [Reader; 3] = [Reader::Entries, Reader::Invoice, Reader::Pdf]; + +struct Case { + ids: SeedIds, + invoice_id: Uuid, +} + +impl Case { + async fn new(pool: &PgPool) -> Self { + let ids = seed(pool, OrgRole::Manager).await; + let (invoice_id, _) = add_invoice(pool, &ids, 1).await; + Self { ids, invoice_id } + } + + async fn read( + &self, + pool: &PgPool, + (org, actor): (Uuid, Uuid), + reader: Reader, + ) -> Result { + Ok(match reader { + Reader::Entries => json!(entries(pool, org, actor, ¶ms()).await?), + Reader::Invoice => json!(invoice(pool, org, actor, self.invoice_id).await?), + Reader::Pdf => { + let doc = pdf(pool, org, actor, self.invoice_id).await?; + json!({"invoice":doc.invoice, "lines":doc.lines, "client":doc.client_name, + "address":doc.client_address, "tax_id":doc.client_tax_id, "branding":doc.branding}) + } + }) + } + + async fn stored(&self, pool: &PgPool) -> Value { + sqlx::query_scalar!(r#"SELECT jsonb_build_object( + 'invoice', (SELECT to_jsonb(i) FROM invoices i WHERE id=$1), + 'lines', (SELECT jsonb_agg(to_jsonb(l) ORDER BY id) FROM invoice_line_items l WHERE invoice_id=$1), + 'time', (SELECT jsonb_agg(to_jsonb(t) ORDER BY id) FROM time_entries t WHERE org_id=$2) + ) AS "snapshot!""#, self.invoice_id, self.ids.org_id).fetch_one(pool).await.unwrap() + } +} + +async fn block_payload(pool: &PgPool, reader: Reader) -> (Transaction<'_, Postgres>, i32) { + let mut tx = pool.begin().await.unwrap(); + match reader { + Reader::Entries => sqlx::query!("LOCK TABLE project_tasks IN ACCESS EXCLUSIVE MODE") + .execute(&mut *tx) + .await + .unwrap(), + Reader::Invoice | Reader::Pdf => { + sqlx::query!("LOCK TABLE invoice_line_items IN ACCESS EXCLUSIVE MODE") + .execute(&mut *tx) + .await + .unwrap() + } + }; + let pid = sqlx::query_scalar!("SELECT pg_backend_pid() as \"pid!\"") + .fetch_one(&mut *tx) + .await + .unwrap(); + (tx, pid) +} + +fn spawn_read( + pool: &PgPool, + case: &Arc, + reader: Reader, +) -> tokio::task::JoinHandle> { + let pool = pool.clone(); + let case = Arc::clone(case); + tokio::spawn(async move { + case.read(&pool, (case.ids.org_id, case.ids.user_id), reader) + .await + }) +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn materialized_exports_deny_revoked_managers(pool: PgPool) { + let ids = seed(&pool, OrgRole::Manager).await; + let (id, _) = add_invoice(&pool, &ids, 1).await; + sqlx::query!( + "UPDATE users SET org_role='member' WHERE id=$1", + ids.user_id + ) + .execute(&pool) + .await + .unwrap(); + assert!(matches!( + entries(&pool, ids.org_id, ids.user_id, ¶ms()).await, + Err(StatusCode::FORBIDDEN) + )); + assert!(matches!( + invoice(&pool, ids.org_id, ids.user_id, id).await, + Err(StatusCode::FORBIDDEN) + )); + assert!(matches!( + pdf(&pool, ids.org_id, ids.user_id, id).await, + Err(StatusCode::FORBIDDEN) + )); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn materialized_exports_require_same_tenant_active_actor(pool: PgPool) { + let case = Case::new(&pool).await; + let before = case.stored(&pool).await; + let foreign = seed(&pool, OrgRole::Admin).await; + for reader in READERS { + for identity in [ + (case.ids.org_id, Uuid::now_v7()), + (case.ids.org_id, foreign.user_id), + (Uuid::now_v7(), case.ids.user_id), + ] { + assert_eq!( + case.read(&pool, identity, reader).await, + Err(StatusCode::FORBIDDEN) + ); + } + for role in [OrgRole::Manager, OrgRole::Admin] { + sqlx::query!( + "UPDATE users SET org_role=$2, active=true WHERE id=$1", + case.ids.user_id, + role as OrgRole + ) + .execute(&pool) + .await + .unwrap(); + case.read(&pool, (case.ids.org_id, case.ids.user_id), reader) + .await + .unwrap(); + sqlx::query!( + "UPDATE users SET active=false WHERE id=$1", + case.ids.user_id + ) + .execute(&pool) + .await + .unwrap(); + assert_eq!( + case.read(&pool, (case.ids.org_id, case.ids.user_id), reader) + .await, + Err(StatusCode::FORBIDDEN) + ); + } + let other = case + .read(&pool, (foreign.org_id, foreign.user_id), reader) + .await; + match reader { + Reader::Entries => assert_eq!(other.unwrap(), json!([])), + _ => assert_eq!(other, Err(StatusCode::NOT_FOUND)), + } + } + assert_eq!(case.stored(&pool).await, before); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn materialized_exports_deny_winning_legacy_revocations(pool: PgPool) { + for reader in READERS { + for gated in [false, true] { + for deactivate in [false, true] { + let case = Arc::new(Case::new(&pool).await); + let before = case.stored(&pool).await; + let mut writer = pool.begin().await.unwrap(); + if gated { + crate::db::lock_organization( + &mut writer, + case.ids.org_id, + crate::db::OrganizationLock::AccessChange, + ) + .await + .unwrap(); + } + let pid = sqlx::query_scalar!("SELECT pg_backend_pid() as \"pid!\"") + .fetch_one(&mut *writer) + .await + .unwrap(); + if deactivate { + sqlx::query!( + "UPDATE users SET active=false WHERE id=$1", + case.ids.user_id + ) + .execute(&mut *writer) + .await + .unwrap(); + } else { + sqlx::query!( + "UPDATE users SET org_role='member' WHERE id=$1", + case.ids.user_id + ) + .execute(&mut *writer) + .await + .unwrap(); + } + let pending = spawn_read(&pool, &case, reader); + wait_for_blocked(&pool, pid).await; + writer.commit().await.unwrap(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(5), pending) + .await + .unwrap() + .unwrap(), + Err(StatusCode::FORBIDDEN) + ); + assert_eq!(case.stored(&pool).await, before); + } + } + } +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn materialized_exports_refresh_after_access_revision_change(pool: PgPool) { + for reader in READERS { + let case = Arc::new(Case::new(&pool).await); + let old = case + .read(&pool, (case.ids.org_id, case.ids.user_id), reader) + .await + .unwrap(); + let mut writer = pool.begin().await.unwrap(); + sqlx::query!( + "UPDATE organizations SET access_revision=access_revision+1 WHERE id=$1", + case.ids.org_id + ) + .execute(&mut *writer) + .await + .unwrap(); + let pid = sqlx::query_scalar!("SELECT pg_backend_pid() as \"pid!\"") + .fetch_one(&mut *writer) + .await + .unwrap(); + let pending = spawn_read(&pool, &case, reader); + wait_for_blocked(&pool, pid).await; + sqlx::query!( + "UPDATE time_entries SET notes='Refreshed' WHERE org_id=$1", + case.ids.org_id + ) + .execute(&mut *writer) + .await + .unwrap(); + sqlx::query!( + "UPDATE invoices SET notes='Refreshed' WHERE id=$1", + case.invoice_id + ) + .execute(&mut *writer) + .await + .unwrap(); + writer.commit().await.unwrap(); + let current = case + .read(&pool, (case.ids.org_id, case.ids.user_id), reader) + .await + .unwrap(); + assert_ne!(current, old); + assert_eq!( + tokio::time::timeout(Duration::from_secs(5), pending) + .await + .unwrap() + .unwrap() + .unwrap(), + current + ); + } +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn materialized_exports_retain_authority_and_release_cancelled_reads(pool: PgPool) { + let reader_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_with((*pool.connect_options()).clone()) + .await + .unwrap(); + for reader in READERS { + for cancel in [false, true] { + let case = Arc::new(Case::new(&pool).await); + let before = case.stored(&pool).await; + let expected = case + .read(&reader_pool, (case.ids.org_id, case.ids.user_id), reader) + .await + .unwrap(); + let (blocker, pid) = block_payload(&pool, reader).await; + let pending = spawn_read(&reader_pool, &case, reader); + wait_for_blocked(&pool, pid).await; + let reader_pid = sqlx::query_scalar!("SELECT pid FROM pg_stat_activity WHERE datname=current_database() AND $1=ANY(pg_blocking_pids(pid))", pid).fetch_one(&pool).await.unwrap().unwrap(); + let revoke = { + let pool = pool.clone(); + let case = Arc::clone(&case); + tokio::spawn(async move { + let mut tx = pool.begin().await.unwrap(); + crate::db::lock_organization( + &mut tx, + case.ids.org_id, + crate::db::OrganizationLock::AccessChange, + ) + .await + .unwrap(); + sqlx::query!( + "UPDATE users SET active=false WHERE id=$1", + case.ids.user_id + ) + .execute(&mut *tx) + .await + .unwrap(); + tx.commit().await.unwrap(); + }) + }; + wait_for_blocked(&pool, reader_pid).await; + if cancel { + pending.abort(); + assert!(pending.await.unwrap_err().is_cancelled()); + blocker.rollback().await.unwrap(); + } else { + blocker.rollback().await.unwrap(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(5), pending) + .await + .unwrap() + .unwrap() + .unwrap(), + expected + ); + } + tokio::time::timeout(Duration::from_secs(5), revoke) + .await + .unwrap() + .unwrap(); + assert_eq!( + tokio::time::timeout( + Duration::from_secs(5), + case.read(&reader_pool, (case.ids.org_id, case.ids.user_id), reader) + ) + .await + .unwrap(), + Err(StatusCode::FORBIDDEN) + ); + assert_eq!(case.stored(&pool).await, before); + let mut writer = pool.begin().await.unwrap(); + sqlx::query!( + "SELECT id FROM organizations WHERE id=$1 FOR UPDATE NOWAIT", + case.ids.org_id + ) + .fetch_one(&mut *writer) + .await + .unwrap(); + sqlx::query!( + "SELECT id FROM users WHERE id=$1 FOR UPDATE NOWAIT", + case.ids.user_id + ) + .fetch_one(&mut *writer) + .await + .unwrap(); + writer.rollback().await.unwrap(); + } + } + reader_pool.close().await; +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn materialized_exports_preserve_checked_snapshots(pool: PgPool) { + for reader in READERS { + let case = Arc::new(Case::new(&pool).await); + let before = case + .read(&pool, (case.ids.org_id, case.ids.user_id), reader) + .await + .unwrap(); + let (mut writer, pid) = block_payload(&pool, reader).await; + let pending = spawn_read(&pool, &case, reader); + wait_for_blocked(&pool, pid).await; + match reader { + Reader::Entries => { + sqlx::query!( + "UPDATE time_entries SET notes=repeat('x',40000) WHERE org_id=$1", + case.ids.org_id + ) + .execute(&mut *writer) + .await + .unwrap(); + } + Reader::Invoice => { + sqlx::query!( + "UPDATE invoices SET notes=repeat('x',40000) WHERE id=$1", + case.invoice_id + ) + .execute(&mut *writer) + .await + .unwrap(); + } + Reader::Pdf => { + sqlx::query!( + "UPDATE clients SET address=repeat('x',40000) WHERE id=$1", + case.ids.client_id + ) + .execute(&mut *writer) + .await + .unwrap(); + } + } + writer.commit().await.unwrap(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(5), pending) + .await + .unwrap() + .unwrap() + .unwrap(), + before + ); + assert_eq!( + case.read(&pool, (case.ids.org_id, case.ids.user_id), reader) + .await, + Err(StatusCode::PAYLOAD_TOO_LARGE) + ); + } +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn materialized_exports_keep_deadlines_and_restore_pool_defaults(pool: PgPool) { + let case = Case::new(&pool).await; + let before = case.stored(&pool).await; + let reader_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .after_connect(|connection, _| { + Box::pin(async move { + sqlx::query!("SET default_transaction_read_only=on") + .execute(&mut *connection) + .await?; + sqlx::query!("SET default_transaction_isolation='serializable'") + .execute(&mut *connection) + .await?; + sqlx::query!("SET lock_timeout='250ms'") + .execute(connection) + .await?; + Ok(()) + }) + }) + .connect_with((*pool.connect_options()).clone()) + .await + .unwrap(); + for reader in READERS { + let expected = case + .read(&pool, (case.ids.org_id, case.ids.user_id), reader) + .await + .unwrap(); + assert_eq!( + case.read(&reader_pool, (case.ids.org_id, case.ids.user_id), reader) + .await + .unwrap(), + expected + ); + } + let mut tx = begin_manager(&reader_pool, case.ids.org_id, case.ids.user_id) + .await + .unwrap(); + let settings = sqlx::query!(r#"SELECT current_setting('transaction_isolation') AS "isolation!", current_setting('transaction_read_only') AS "read_only!", current_setting('statement_timeout') AS "statement!", current_setting('idle_in_transaction_session_timeout') AS "idle!", current_setting('lock_timeout') AS "lock!""#).fetch_one(&mut *tx).await.unwrap(); + assert_eq!( + ( + settings.isolation.as_str(), + settings.read_only.as_str(), + settings.statement.as_str(), + settings.idle.as_str(), + settings.lock.as_str() + ), + ("repeatable read", "off", "5s", "10s", "250ms") + ); + let error = sqlx::query!("DO $$ BEGIN PERFORM pg_sleep(10); END $$") + .execute(&mut *tx) + .await + .unwrap_err(); + assert_eq!(database_error(error), StatusCode::GATEWAY_TIMEOUT); + tx.rollback().await.unwrap(); + let settings = sqlx::query!(r#"SELECT current_setting('transaction_isolation') AS "isolation!", current_setting('transaction_read_only') AS "read_only!", current_setting('statement_timeout') AS "statement!", current_setting('idle_in_transaction_session_timeout') AS "idle!", current_setting('lock_timeout') AS "lock!""#).fetch_one(&reader_pool).await.unwrap(); + assert_eq!( + ( + settings.isolation.as_str(), + settings.read_only.as_str(), + settings.statement.as_str(), + settings.idle.as_str(), + settings.lock.as_str() + ), + ("serializable", "on", "0", "0", "250ms") + ); + assert_eq!(case.stored(&pool).await, before); + reader_pool.close().await; +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn materialized_exports_recheck_after_render_without_retaining_authority(pool: PgPool) { + use crate::reports::{bounded::ExportPermit, render_manager_export}; + for gated in [false, true] { + for deactivate in [false, true] { + let case = Case::new(&pool).await; + let (started, ready) = tokio::sync::oneshot::channel(); + let (release, wait) = std::sync::mpsc::channel(); + let pending = { + let pool = pool.clone(); + let org = case.ids.org_id; + let actor = case.ids.user_id; + tokio::spawn(async move { + let _rows = entries(&pool, org, actor, ¶ms()).await.unwrap(); + render_manager_export( + ExportPermit::acquire().unwrap(), + &pool, + org, + actor, + move || { + started.send(()).unwrap(); + wait.recv_timeout(Duration::from_secs(5)).unwrap(); + Ok(b"private export".to_vec()) + }, + ) + .await + }) + }; + ready.await.unwrap(); + let mut writer = pool.begin().await.unwrap(); + if gated { + sqlx::query!( + "SELECT id FROM organizations WHERE id=$1 FOR NO KEY UPDATE NOWAIT", + case.ids.org_id + ) + .fetch_one(&mut *writer) + .await + .unwrap(); + } + sqlx::query!( + "SELECT id FROM users WHERE id=$1 FOR UPDATE NOWAIT", + case.ids.user_id + ) + .fetch_one(&mut *writer) + .await + .unwrap(); + if deactivate { + sqlx::query!( + "UPDATE users SET active=false WHERE id=$1", + case.ids.user_id + ) + .execute(&mut *writer) + .await + .unwrap(); + } else { + sqlx::query!( + "UPDATE users SET org_role='member' WHERE id=$1", + case.ids.user_id + ) + .execute(&mut *writer) + .await + .unwrap(); + } + writer.commit().await.unwrap(); + release.send(()).unwrap(); + assert!(matches!( + tokio::time::timeout(Duration::from_secs(5), pending) + .await + .unwrap() + .unwrap(), + Err(StatusCode::FORBIDDEN) + )); + let first = ExportPermit::acquire().unwrap(); + let second = ExportPermit::acquire().unwrap(); + drop((first, second)); + } + } + let ids = seed(&pool, OrgRole::Admin).await; + let body = render_manager_export( + ExportPermit::acquire().unwrap(), + &pool, + ids.org_id, + ids.user_id, + || Ok(b"authorized export".to_vec()), + ) + .await + .unwrap(); + assert_eq!( + axum::body::to_bytes(body, 100).await.unwrap().as_ref(), + b"authorized export" + ); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn materialized_exports_release_rendered_body_when_final_check_is_interrupted(pool: PgPool) { + use crate::reports::{bounded::ExportPermit, render_manager_export}; + let ids = seed(&pool, OrgRole::Manager).await; + for cancel in [false, true] { + let lock_timeout = if cancel { "0" } else { "250ms" }; + let reader_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .after_connect(move |connection, _| { + Box::pin(async move { + sqlx::query!("SELECT set_config('lock_timeout', $1, false)", lock_timeout) + .fetch_one(connection) + .await?; + Ok(()) + }) + }) + .connect_with((*pool.connect_options()).clone()) + .await + .unwrap(); + let (started, ready) = tokio::sync::oneshot::channel(); + let (release, wait) = std::sync::mpsc::channel(); + let pending = { + let pool = reader_pool.clone(); + let org = ids.org_id; + let actor = ids.user_id; + tokio::spawn(async move { + render_manager_export( + ExportPermit::acquire().unwrap(), + &pool, + org, + actor, + move || { + started.send(()).unwrap(); + wait.recv_timeout(Duration::from_secs(5)).unwrap(); + Ok(b"private export".to_vec()) + }, + ) + .await + }) + }; + ready.await.unwrap(); + let mut writer = pool.begin().await.unwrap(); + sqlx::query!( + "SELECT id FROM organizations WHERE id=$1 FOR NO KEY UPDATE NOWAIT", + ids.org_id + ) + .fetch_one(&mut *writer) + .await + .unwrap(); + let pid = sqlx::query_scalar!("SELECT pg_backend_pid() as \"pid!\"") + .fetch_one(&mut *writer) + .await + .unwrap(); + release.send(()).unwrap(); + wait_for_blocked(&pool, pid).await; + if cancel { + pending.abort(); + assert!(pending.await.unwrap_err().is_cancelled()); + } else { + assert!(matches!( + tokio::time::timeout(Duration::from_secs(5), pending) + .await + .unwrap() + .unwrap(), + Err(StatusCode::INTERNAL_SERVER_ERROR) + )); + } + writer.rollback().await.unwrap(); + let first = ExportPermit::acquire().unwrap(); + let second = ExportPermit::acquire().unwrap(); + drop((first, second)); + tokio::time::timeout(Duration::from_secs(5), async { + begin_manager(&reader_pool, ids.org_id, ids.user_id) + .await + .unwrap() + .commit() + .await + .unwrap(); + }) + .await + .unwrap(); + let mut writer = pool.begin().await.unwrap(); + sqlx::query!( + "SELECT id FROM organizations WHERE id=$1 FOR UPDATE NOWAIT", + ids.org_id + ) + .fetch_one(&mut *writer) + .await + .unwrap(); + sqlx::query!( + "SELECT id FROM users WHERE id=$1 FOR UPDATE NOWAIT", + ids.user_id + ) + .fetch_one(&mut *writer) + .await + .unwrap(); + writer.rollback().await.unwrap(); + reader_pool.close().await; + } +} diff --git a/crates/horae/src/reports/limits/tests/project_authorization.rs b/crates/horae/src/reports/limits/tests/project_authorization.rs new file mode 100644 index 00000000..9db303a0 --- /dev/null +++ b/crates/horae/src/reports/limits/tests/project_authorization.rs @@ -0,0 +1,798 @@ +use super::*; +use crate::server_fns::test_seed::wait_for_blocked; +use std::time::Duration; + +async fn assign(connection: &mut PgConnection, ids: &SeedIds) { + sqlx::query!( + "INSERT INTO assignments (id,project_id,user_id,role) VALUES ($1,$2,$3,'freelancer')", + Uuid::now_v7(), + ids.project_id, + ids.user_id + ) + .execute(connection) + .await + .unwrap(); +} + +async fn visibility(connection: &mut PgConnection, ids: &SeedIds, value: &str) { + sqlx::query!("INSERT INTO project_settings (id,org_id,project_id,creator_id,rate_mode,report_visibility) + VALUES ($1,$2,$3,$4,'person',$5) ON CONFLICT (project_id) DO UPDATE SET report_visibility=EXCLUDED.report_visibility", + Uuid::now_v7(), ids.org_id, ids.project_id, ids.user_id, value) + .execute(connection).await.unwrap(); +} + +async fn names(pool: &PgPool, org: Uuid, actor: Uuid) -> Result, StatusCode> { + Ok(projects(pool, org, actor, "active") + .await? + .into_iter() + .map(|row| row.name) + .collect()) +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn project_exports_require_current_tenant_bound_actor(pool: PgPool) { + let ids = seed(&pool, OrgRole::Member).await; + let foreign = seed(&pool, OrgRole::Admin).await; + assert!( + projects(&pool, ids.org_id, ids.user_id, "active") + .await + .unwrap() + .is_empty() + ); + for (org, actor) in [ + (ids.org_id, Uuid::now_v7()), + (ids.org_id, foreign.user_id), + (Uuid::now_v7(), ids.user_id), + ] { + assert!(matches!( + projects(&pool, org, actor, "active").await, + Err(StatusCode::FORBIDDEN) + )); + } + sqlx::query!("UPDATE users SET active=false WHERE id=$1", ids.user_id) + .execute(&pool) + .await + .unwrap(); + assert!(matches!( + projects(&pool, ids.org_id, ids.user_id, "active").await, + Err(StatusCode::FORBIDDEN) + )); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn project_exports_preserve_membership_visibility_and_history_rules(pool: PgPool) { + let ids = seed(&pool, OrgRole::Member).await; + add_entries(&pool, &ids, 1).await; + assert!( + names(&pool, ids.org_id, ids.user_id) + .await + .unwrap() + .is_empty() + ); + assign(&mut pool.acquire().await.unwrap(), &ids).await; + assert_eq!( + names(&pool, ids.org_id, ids.user_id).await.unwrap(), + ["Widget"] + ); + visibility(&mut pool.acquire().await.unwrap(), &ids, "managers").await; + assert!( + names(&pool, ids.org_id, ids.user_id) + .await + .unwrap() + .is_empty() + ); + for role in [ + horae_core::types::ProjectRole::Lead, + horae_core::types::ProjectRole::Admin, + ] { + sqlx::query!( + "UPDATE assignments SET role=$1 WHERE project_id=$2 AND user_id=$3", + role as horae_core::types::ProjectRole, + ids.project_id, + ids.user_id + ) + .execute(&pool) + .await + .unwrap(); + assert_eq!( + names(&pool, ids.org_id, ids.user_id).await.unwrap(), + ["Widget"] + ); + } + sqlx::query!( + "DELETE FROM assignments WHERE project_id=$1", + ids.project_id + ) + .execute(&pool) + .await + .unwrap(); + for role in [OrgRole::Manager, OrgRole::Admin] { + sqlx::query!( + "UPDATE users SET org_role=$1 WHERE id=$2", + role as OrgRole, + ids.user_id + ) + .execute(&pool) + .await + .unwrap(); + assert_eq!( + names(&pool, ids.org_id, ids.user_id).await.unwrap(), + ["Widget"] + ); + } +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn project_exports_refresh_scope_after_winning_relationship_changes(pool: PgPool) { + for change in [ + "add", + "remove", + "expand", + "restrict", + "insert_settings", + "new_project", + ] { + let ids = seed(&pool, OrgRole::Member).await; + if !matches!(change, "add" | "new_project") { + assign(&mut pool.acquire().await.unwrap(), &ids).await; + } + if change == "expand" { + visibility(&mut pool.acquire().await.unwrap(), &ids, "managers").await; + } + if change == "restrict" { + visibility(&mut pool.acquire().await.unwrap(), &ids, "project_members").await; + } + let mut writer = pool.begin().await.unwrap(); + crate::db::lock_organization( + &mut writer, + ids.org_id, + crate::db::OrganizationLock::AccessChange, + ) + .await + .unwrap(); + let pid = sqlx::query_scalar!("SELECT pg_backend_pid() as \"pid!\"") + .fetch_one(&mut *writer) + .await + .unwrap(); + let pending = { + let pool = pool.clone(); + let (org, actor) = (ids.org_id, ids.user_id); + tokio::spawn(async move { names(&pool, org, actor).await }) + }; + wait_for_blocked(&pool, pid).await; + match change { + "add" => assign(&mut writer, &ids).await, + "remove" => { + sqlx::query!( + "DELETE FROM assignments WHERE project_id=$1", + ids.project_id + ) + .execute(&mut *writer) + .await + .unwrap(); + } + "expand" => visibility(&mut writer, &ids, "project_members").await, + "restrict" | "insert_settings" => visibility(&mut writer, &ids, "managers").await, + "new_project" => { + let project = Uuid::now_v7(); + sqlx::query!("INSERT INTO projects (id,org_id,client_id,name,currency) VALUES ($1,$2,$3,'New project','EUR')", + project, ids.org_id, ids.client_id).execute(&mut *writer).await.unwrap(); + sqlx::query!("INSERT INTO assignments (id,project_id,user_id,role) VALUES ($1,$2,$3,'freelancer')", + Uuid::now_v7(), project, ids.user_id).execute(&mut *writer).await.unwrap(); + } + _ => unreachable!(), + } + writer.commit().await.unwrap(); + let result = pending.await.unwrap().unwrap(); + match change { + "add" | "expand" => assert_eq!(result, ["Widget"], "{change}"), + "new_project" => assert_eq!(result, ["New project"]), + _ => assert!(result.is_empty(), "{change}: {result:?}"), + } + } +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn project_exports_deny_actor_revoked_during_either_authority_wait(pool: PgPool) { + for gate in [false, true] { + let ids = seed(&pool, OrgRole::Member).await; + assign(&mut pool.acquire().await.unwrap(), &ids).await; + let mut writer = pool.begin().await.unwrap(); + if gate { + crate::db::lock_organization( + &mut writer, + ids.org_id, + crate::db::OrganizationLock::AccessChange, + ) + .await + .unwrap(); + } + sqlx::query!("UPDATE users SET active=false WHERE id=$1", ids.user_id) + .execute(&mut *writer) + .await + .unwrap(); + let pid = sqlx::query_scalar!("SELECT pg_backend_pid() as \"pid!\"") + .fetch_one(&mut *writer) + .await + .unwrap(); + let pending = { + let pool = pool.clone(); + tokio::spawn(async move { names(&pool, ids.org_id, ids.user_id).await }) + }; + wait_for_blocked(&pool, pid).await; + writer.commit().await.unwrap(); + assert_eq!(pending.await.unwrap(), Err(StatusCode::FORBIDDEN)); + } +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn project_exports_refresh_release_scope_after_parent_wait(pool: PgPool) { + for change in ["remove", "insert_settings", "downgrade"] { + let ids = seed(&pool, OrgRole::Member).await; + assign(&mut pool.acquire().await.unwrap(), &ids).await; + if change == "downgrade" { + visibility(&mut pool.acquire().await.unwrap(), &ids, "managers").await; + sqlx::query!( + "UPDATE assignments SET role='lead' WHERE project_id=$1", + ids.project_id + ) + .execute(&pool) + .await + .unwrap(); + } + assert_eq!( + names(&pool, ids.org_id, ids.user_id).await.unwrap(), + ["Widget"] + ); + let mut writer = pool.begin().await.unwrap(); + match change { + "remove" => { + sqlx::query!( + "DELETE FROM assignments WHERE project_id=$1", + ids.project_id + ) + .execute(&mut *writer) + .await + .unwrap(); + } + "insert_settings" => visibility(&mut writer, &ids, "managers").await, + "downgrade" => { + sqlx::query!( + "UPDATE assignments SET role='freelancer' WHERE project_id=$1", + ids.project_id + ) + .execute(&mut *writer) + .await + .unwrap(); + } + _ => unreachable!(), + } + let pid = sqlx::query_scalar!("SELECT pg_backend_pid() as \"pid!\"") + .fetch_one(&mut *writer) + .await + .unwrap(); + let pending = { + let pool = pool.clone(); + tokio::spawn(async move { + authorize_projects(&pool, ids.org_id, ids.user_id, &[ids.project_id]).await + }) + }; + wait_for_blocked(&pool, pid).await; + writer.commit().await.unwrap(); + assert_eq!( + pending.await.unwrap(), + Err(StatusCode::FORBIDDEN), + "{change}" + ); + } +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn project_exports_release_every_captured_project_without_render_locks(pool: PgPool) { + use crate::reports::{bounded::ExportPermit, render_project_export}; + for change in ["remove", "deactivate", "demote_retained"] { + let ids = seed(&pool, OrgRole::Manager).await; + assign(&mut pool.acquire().await.unwrap(), &ids).await; + let second = Uuid::now_v7(); + sqlx::query!("INSERT INTO projects (id,org_id,client_id,name,currency) VALUES ($1,$2,$3,'Second','EUR')", + second, ids.org_id, ids.client_id).execute(&pool).await.unwrap(); + sqlx::query!( + "INSERT INTO assignments (id,project_id,user_id,role) VALUES ($1,$2,$3,'freelancer')", + Uuid::now_v7(), + second, + ids.user_id + ) + .execute(&pool) + .await + .unwrap(); + let (started, ready) = tokio::sync::oneshot::channel(); + let (release, wait) = std::sync::mpsc::channel(); + let pending = { + let pool = pool.clone(); + let (org, actor) = (ids.org_id, ids.user_id); + tokio::spawn(async move { + let rows = projects(&pool, org, actor, "active").await.unwrap(); + assert_eq!(rows.len(), 2); + render_project_export( + ExportPermit::acquire().unwrap(), + &pool, + org, + actor, + rows.iter().map(|row| row.id).collect(), + move || { + started.send(()).unwrap(); + wait.recv_timeout(Duration::from_secs(5)).unwrap(); + Ok(b"project export".to_vec()) + }, + ) + .await + }) + }; + ready.await.unwrap(); + let mut writer = pool.begin().await.unwrap(); + sqlx::query!( + "SELECT id FROM organizations WHERE id=$1 FOR NO KEY UPDATE NOWAIT", + ids.org_id + ) + .fetch_one(&mut *writer) + .await + .unwrap(); + sqlx::query!( + "SELECT id FROM users WHERE id=$1 FOR UPDATE NOWAIT", + ids.user_id + ) + .fetch_one(&mut *writer) + .await + .unwrap(); + sqlx::query!( + "SELECT id FROM projects WHERE id=$1 FOR UPDATE NOWAIT", + second + ) + .fetch_one(&mut *writer) + .await + .unwrap(); + sqlx::query!( + "UPDATE users SET org_role='member', active=$2 WHERE id=$1", + ids.user_id, + change != "deactivate" + ) + .execute(&mut *writer) + .await + .unwrap(); + if change == "remove" { + sqlx::query!("DELETE FROM assignments WHERE project_id=$1", second) + .execute(&mut *writer) + .await + .unwrap(); + } + writer.commit().await.unwrap(); + release.send(()).unwrap(); + let result = pending.await.unwrap(); + if change == "demote_retained" { + let body = result.unwrap(); + let mut writer = pool.begin().await.unwrap(); + sqlx::query!( + "SELECT id FROM projects WHERE id=$1 FOR UPDATE NOWAIT", + second + ) + .fetch_one(&mut *writer) + .await + .unwrap(); + writer.rollback().await.unwrap(); + assert_eq!( + axum::body::to_bytes(body, 100).await.unwrap().as_ref(), + b"project export" + ); + } else { + assert!(matches!(result, Err(StatusCode::FORBIDDEN)), "{change}"); + } + let first = ExportPermit::acquire().unwrap(); + let second = ExportPermit::acquire().unwrap(); + drop((first, second)); + } +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn project_exports_bound_rows_and_text_after_scope_filtering(pool: PgPool) { + let ids = seed(&pool, OrgRole::Manager).await; + let keys: Vec<_> = (1..XLSX.rows).map(|_| Uuid::now_v7()).collect(); + sqlx::query!( + "INSERT INTO projects (id,org_id,client_id,name,currency) + SELECT id,$2,$3,'Additional','EUR' FROM unnest($1::uuid[]) id", + &keys, + ids.org_id, + ids.client_id + ) + .execute(&pool) + .await + .unwrap(); + assert_eq!( + projects(&pool, ids.org_id, ids.user_id, "active") + .await + .unwrap() + .len(), + XLSX.rows as usize + ); + let overflow = Uuid::now_v7(); + sqlx::query!("INSERT INTO projects (id,org_id,client_id,name,currency) VALUES ($1,$2,$3,'Overflow','EUR')", + overflow, ids.org_id, ids.client_id).execute(&pool).await.unwrap(); + assert!(matches!( + projects(&pool, ids.org_id, ids.user_id, "active").await, + Err(StatusCode::PAYLOAD_TOO_LARGE) + )); + sqlx::query!( + "UPDATE projects SET name=repeat('x',40000) WHERE org_id=$1 AND id<>$2", + ids.org_id, + ids.project_id + ) + .execute(&pool) + .await + .unwrap(); + assign(&mut pool.acquire().await.unwrap(), &ids).await; + sqlx::query!( + "UPDATE users SET org_role='member' WHERE id=$1", + ids.user_id + ) + .execute(&pool) + .await + .unwrap(); + assert_eq!( + names(&pool, ids.org_id, ids.user_id).await.unwrap(), + ["Widget"] + ); + sqlx::query!( + "UPDATE projects SET name=repeat('界',10923) WHERE id=$1", + ids.project_id + ) + .execute(&pool) + .await + .unwrap(); + assert!(matches!( + projects(&pool, ids.org_id, ids.user_id, "active").await, + Err(StatusCode::PAYLOAD_TOO_LARGE) + )); + sqlx::query!( + "UPDATE projects SET name=repeat('界',10922) WHERE id=$1", + ids.project_id + ) + .execute(&pool) + .await + .unwrap(); + assert_eq!( + projects(&pool, ids.org_id, ids.user_id, "active") + .await + .unwrap()[0] + .name + .len(), + 32766 + ); + + let aggregate = seed(&pool, OrgRole::Manager).await; + let keys: Vec<_> = (0..300).map(|_| Uuid::now_v7()).collect(); + sqlx::query!( + "INSERT INTO projects (id,org_id,client_id,name,currency) + SELECT id,$2,$3,repeat('n',32767),'EUR' FROM unnest($1::uuid[]) id", + &keys, + aggregate.org_id, + aggregate.client_id + ) + .execute(&pool) + .await + .unwrap(); + assert!(matches!( + projects(&pool, aggregate.org_id, aggregate.user_id, "active").await, + Err(StatusCode::PAYLOAD_TOO_LARGE) + )); +} + +async fn paused_project_pool(pool: &PgPool) -> PgPool { + sqlx::query!("CREATE SCHEMA project_export_test") + .execute(pool) + .await + .unwrap(); + sqlx::query!( + "CREATE FUNCTION project_export_test.pause() RETURNS boolean LANGUAGE plpgsql AS $$ + BEGIN PERFORM pg_advisory_xact_lock(715015); RETURN true; END $$" + ) + .execute(pool) + .await + .unwrap(); + sqlx::query!( + "CREATE VIEW project_export_test.project_read_access AS + SELECT * FROM public.project_read_access WHERE project_export_test.pause()" + ) + .execute(pool) + .await + .unwrap(); + sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .after_connect(|connection, _| { + Box::pin(async move { + sqlx::query!("SET search_path=project_export_test,public") + .execute(connection) + .await?; + Ok(()) + }) + }) + .connect_with((*pool.connect_options()).clone()) + .await + .unwrap() +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn project_exports_share_one_size_and_payload_snapshot(pool: PgPool) { + let ids = seed(&pool, OrgRole::Member).await; + assign(&mut pool.acquire().await.unwrap(), &ids).await; + let reader_pool = paused_project_pool(&pool).await; + let mut blocker = pool.begin().await.unwrap(); + sqlx::query!("SELECT pg_advisory_xact_lock(715015) AS \"lock!: ()\"") + .fetch_one(&mut *blocker) + .await + .unwrap(); + let pid = sqlx::query_scalar!("SELECT pg_backend_pid() as \"pid!\"") + .fetch_one(&mut *blocker) + .await + .unwrap(); + let pending = { + let reader_pool = reader_pool.clone(); + tokio::spawn(async move { projects(&reader_pool, ids.org_id, ids.user_id, "active").await }) + }; + wait_for_blocked(&pool, pid).await; + sqlx::query!("UPDATE projects SET name=repeat('x',40000),budget_kind='hours',budget_minutes=300 WHERE id=$1", ids.project_id) + .execute(&pool).await.unwrap(); + blocker.rollback().await.unwrap(); + let rows = pending.await.unwrap().unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].name, "Widget"); + assert_eq!(rows[0].budget_minutes, None); + assert!(matches!( + projects(&reader_pool, ids.org_id, ids.user_id, "active").await, + Err(StatusCode::PAYLOAD_TOO_LARGE) + )); + reader_pool.close().await; +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn project_exports_release_cancelled_and_timed_out_checks(pool: PgPool) { + use crate::reports::{bounded::ExportPermit, render_project_export}; + let ids = seed(&pool, OrgRole::Member).await; + assign(&mut pool.acquire().await.unwrap(), &ids).await; + let reader_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .after_connect(|connection, _| { + Box::pin(async move { + sqlx::query!("SET default_transaction_read_only=on") + .execute(&mut *connection) + .await?; + sqlx::query!("SET default_transaction_isolation='serializable'") + .execute(&mut *connection) + .await?; + sqlx::query!("SET lock_timeout='250ms'") + .execute(connection) + .await?; + Ok(()) + }) + }) + .connect_with((*pool.connect_options()).clone()) + .await + .unwrap(); + assert_eq!( + names(&reader_pool, ids.org_id, ids.user_id).await.unwrap(), + ["Widget"] + ); + for cancel in [false, true] { + let mut blocker = pool.begin().await.unwrap(); + crate::db::lock_organization( + &mut blocker, + ids.org_id, + crate::db::OrganizationLock::AccessChange, + ) + .await + .unwrap(); + let pid = sqlx::query_scalar!("SELECT pg_backend_pid() as \"pid!\"") + .fetch_one(&mut *blocker) + .await + .unwrap(); + let pending = { + let reader_pool = reader_pool.clone(); + let (org, actor, project) = (ids.org_id, ids.user_id, ids.project_id); + tokio::spawn(async move { + render_project_export( + ExportPermit::acquire().unwrap(), + &reader_pool, + org, + actor, + vec![project], + || Ok(b"rendered".to_vec()), + ) + .await + }) + }; + wait_for_blocked(&pool, pid).await; + if cancel { + pending.abort(); + assert!(pending.await.unwrap_err().is_cancelled()); + } else { + assert!(matches!( + pending.await.unwrap(), + Err(StatusCode::INTERNAL_SERVER_ERROR) + )); + } + blocker.rollback().await.unwrap(); + assert_eq!( + tokio::time::timeout( + Duration::from_secs(5), + names(&reader_pool, ids.org_id, ids.user_id) + ) + .await + .unwrap() + .unwrap(), + ["Widget"] + ); + let settings = sqlx::query!("SELECT current_setting('transaction_read_only') AS \"read_only!\", + current_setting('transaction_isolation') AS \"isolation!\", current_setting('lock_timeout') AS \"timeout!\"") + .fetch_one(&reader_pool).await.unwrap(); + assert_eq!( + ( + settings.read_only.as_str(), + settings.isolation.as_str(), + settings.timeout.as_str() + ), + ("on", "serializable", "250ms") + ); + let first = ExportPermit::acquire().unwrap(); + let second = ExportPermit::acquire().unwrap(); + drop((first, second)); + } + reader_pool.close().await; +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn project_exports_reject_missing_foreign_and_unreadable_captured_ids(pool: PgPool) { + let ids = seed(&pool, OrgRole::Manager).await; + let foreign = seed(&pool, OrgRole::Admin).await; + for project in [Uuid::now_v7(), foreign.project_id] { + assert_eq!( + authorize_projects(&pool, ids.org_id, ids.user_id, &[ids.project_id, project]).await, + Err(StatusCode::FORBIDDEN) + ); + } + authorize_projects(&pool, ids.org_id, ids.user_id, &[]) + .await + .unwrap(); + sqlx::query!( + "UPDATE users SET org_role='member' WHERE id=$1", + ids.user_id + ) + .execute(&pool) + .await + .unwrap(); + assert_eq!( + authorize_projects(&pool, ids.org_id, ids.user_id, &[ids.project_id]).await, + Err(StatusCode::FORBIDDEN) + ); + sqlx::query!("UPDATE users SET active=false WHERE id=$1", ids.user_id) + .execute(&pool) + .await + .unwrap(); + assert_eq!( + authorize_projects(&pool, ids.org_id, ids.user_id, &[]).await, + Err(StatusCode::FORBIDDEN) + ); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn project_exports_retain_parent_authority_until_release_check_finishes(pool: PgPool) { + let ids = seed(&pool, OrgRole::Member).await; + assign(&mut pool.acquire().await.unwrap(), &ids).await; + let reader_pool = paused_project_pool(&pool).await; + let mut blocker = pool.begin().await.unwrap(); + sqlx::query!("SELECT pg_advisory_xact_lock(715015) AS \"lock!: ()\"") + .fetch_one(&mut *blocker) + .await + .unwrap(); + let pid = sqlx::query_scalar!("SELECT pg_backend_pid() as \"pid!\"") + .fetch_one(&mut *blocker) + .await + .unwrap(); + let pending = { + let reader_pool = reader_pool.clone(); + tokio::spawn(async move { + authorize_projects(&reader_pool, ids.org_id, ids.user_id, &[ids.project_id]).await + }) + }; + wait_for_blocked(&pool, pid).await; + let reader_pid = sqlx::query_scalar!( + r#"SELECT pid AS "pid!" FROM pg_stat_activity + WHERE datname=current_database() AND $1=ANY(pg_blocking_pids(pid)) LIMIT 1"#, + pid, + ) + .fetch_one(&pool) + .await + .unwrap(); + let writer = { + let pool = pool.clone(); + tokio::spawn(async move { + sqlx::query!( + "DELETE FROM assignments WHERE project_id=$1", + ids.project_id + ) + .execute(&pool) + .await + .unwrap() + .rows_affected() + }) + }; + wait_for_blocked(&pool, reader_pid).await; + blocker.rollback().await.unwrap(); + pending.await.unwrap().unwrap(); + assert_eq!(writer.await.unwrap(), 1); + assert_eq!( + authorize_projects(&reader_pool, ids.org_id, ids.user_id, &[ids.project_id]).await, + Err(StatusCode::FORBIDDEN) + ); + reader_pool.close().await; +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn project_exports_cancel_loading_without_retaining_authority(pool: PgPool) { + let ids = seed(&pool, OrgRole::Member).await; + assign(&mut pool.acquire().await.unwrap(), &ids).await; + let reader_pool = paused_project_pool(&pool).await; + let mut blocker = pool.begin().await.unwrap(); + sqlx::query!("SELECT pg_advisory_xact_lock(715015) AS \"lock!: ()\"") + .fetch_one(&mut *blocker) + .await + .unwrap(); + let pid = sqlx::query_scalar!("SELECT pg_backend_pid() as \"pid!\"") + .fetch_one(&mut *blocker) + .await + .unwrap(); + let pending = { + let reader_pool = reader_pool.clone(); + tokio::spawn(async move { names(&reader_pool, ids.org_id, ids.user_id).await }) + }; + wait_for_blocked(&pool, pid).await; + pending.abort(); + assert!(pending.await.unwrap_err().is_cancelled()); + blocker.rollback().await.unwrap(); + assert_eq!( + tokio::time::timeout( + Duration::from_secs(5), + names(&reader_pool, ids.org_id, ids.user_id) + ) + .await + .unwrap() + .unwrap(), + ["Widget"] + ); + let mut writer = pool.begin().await.unwrap(); + sqlx::query!( + "SELECT id FROM organizations WHERE id=$1 FOR NO KEY UPDATE NOWAIT", + ids.org_id + ) + .fetch_one(&mut *writer) + .await + .unwrap(); + sqlx::query!( + "SELECT id FROM users WHERE id=$1 FOR UPDATE NOWAIT", + ids.user_id + ) + .fetch_one(&mut *writer) + .await + .unwrap(); + writer.rollback().await.unwrap(); + reader_pool.close().await; +} diff --git a/crates/horae/src/reports/privacy_tests.rs b/crates/horae/src/reports/privacy_tests.rs index 46a90322..84a21f3c 100644 --- a/crates/horae/src/reports/privacy_tests.rs +++ b/crates/horae/src/reports/privacy_tests.rs @@ -138,10 +138,8 @@ async fn unauthorized_project_text_does_not_affect_export_size_limits(pool: PgPo .execute(&pool) .await .unwrap(); - assert!( - limits::projects(&pool, ids.org_id, ids.user_id, "active") - .await - .unwrap() - .is_empty() - ); + assert!(matches!( + limits::projects(&pool, ids.org_id, ids.user_id, "active").await, + Err(StatusCode::FORBIDDEN) + )); } diff --git a/crates/horae/src/server_fns.rs b/crates/horae/src/server_fns.rs index 7b73ec2a..7d000aa0 100644 --- a/crates/horae/src/server_fns.rs +++ b/crates/horae/src/server_fns.rs @@ -518,7 +518,7 @@ mod project_creation; mod projects; mod reports; #[cfg(feature = "server")] -mod snapshot; +pub(crate) mod snapshot; mod time_entries; mod users; diff --git a/crates/horae/src/server_fns/importers/authorization_tests.rs b/crates/horae/src/server_fns/importers/authorization_tests.rs index 835daad2..e5b87e1c 100644 --- a/crates/horae/src/server_fns/importers/authorization_tests.rs +++ b/crates/horae/src/server_fns/importers/authorization_tests.rs @@ -25,6 +25,7 @@ use super::*; mod approval_labels; mod cli; +mod exports; mod financial_snapshots; mod legacy_readers; mod session_identity; @@ -214,6 +215,19 @@ async fn job_endpoints_enforce_session_role_and_organization(pool: PgPool) { let upload_pool = pool.clone(); let router = Router::new() .register_server_functions() + .route("/api/reports/export/xlsx", get(crate::reports::export_xlsx)) + .route( + "/api/projects/export/xlsx", + get(crate::reports::export_projects_xlsx), + ) + .route( + "/api/invoices/{id}/export/xlsx", + get(crate::reports::export_invoice_xlsx), + ) + .route( + "/api/invoices/{id}/export/pdf", + get(crate::reports::export_invoice_pdf), + ) .route( "/api/import/harvest/jobs/{job_id}/errors", get(crate::jobs::report::download), @@ -331,6 +345,7 @@ async fn job_endpoints_enforce_session_role_and_organization(pool: PgPool) { approval_labels::check(&pool, &api).await; legacy_readers::check(&pool, &api).await; financial_snapshots::check(&pool, &api).await; + exports::check(&pool, &api).await; let admin = api.cookie(owner.user_id).await; let expired = api.cookie(owner.user_id).await; assert_eq!( diff --git a/crates/horae/src/server_fns/importers/authorization_tests/exports.rs b/crates/horae/src/server_fns/importers/authorization_tests/exports.rs new file mode 100644 index 00000000..47f09b25 --- /dev/null +++ b/crates/horae/src/server_fns/importers/authorization_tests/exports.rs @@ -0,0 +1,217 @@ +//! Real session and response checks for the three materialized manager exports. + +use super::*; +use crate::server_fns::test_seed::{seed, time_entry, wait_for_blocked}; +use horae_core::types::EntryState; +use std::io::{Cursor, Read}; + +mod projects; + +async fn download(api: &Api, path: &str, cookie: Option<&str>) -> reqwest::Response { + let mut request = api.client.get(format!("{}{path}", api.base)); + if let Some(cookie) = cookie { + request = request.header("cookie", cookie); + } + request.send().await.unwrap() +} + +async fn denied(response: reqwest::Response, status: StatusCode) { + assert_eq!(response.status(), status); + assert!(response.headers().get("content-disposition").is_none()); + let body = response.text().await.unwrap(); + for private in [ + "EXPORT-PRIVATE", + "Private export note", + "Other tenant secret", + ] { + assert!(!body.contains(private), "{body}"); + } +} + +pub(super) async fn check(pool: &PgPool, api: &Api) { + projects::check(pool, api).await; + let ids = seed(pool, OrgRole::Manager).await; + let foreign = seed(pool, OrgRole::Admin).await; + let own_entry = time_entry(pool, &ids, EntryState::Open).await; + for (entry, note) in [ + (own_entry, "Private export note"), + ( + time_entry(pool, &foreign, EntryState::Open).await, + "Other tenant secret", + ), + ] { + sqlx::query!("UPDATE time_entries SET notes=$2 WHERE id=$1", entry, note) + .execute(pool) + .await + .unwrap(); + } + let invoice_id = Uuid::now_v7(); + sqlx::query!("INSERT INTO invoices (id,org_id,client_id,number,issued_on,due_on,currency,total_cents) VALUES ($1,$2,$3,'EXPORT-PRIVATE','2026-09-07','2026-10-07','EUR',1234)", invoice_id, ids.org_id, ids.client_id).execute(pool).await.unwrap(); + sqlx::query!("INSERT INTO invoice_line_items (id,invoice_id,time_entry_id,description,minutes,rate_cents,amount_cents) VALUES ($1,$2,$3,'Private export note',60,1234,1234)", Uuid::now_v7(), invoice_id, own_entry).execute(pool).await.unwrap(); + let cookie = api.cookie(ids.user_id).await; + let member = api + .cookie(user(pool, ids.org_id, OrgRole::Member).await) + .await; + let other = api.cookie(foreign.user_id).await; + let expired = api.cookie(ids.user_id).await; + assert_eq!( + api.client + .post(format!("{}/test/expire", api.base)) + .header("cookie", &expired) + .send() + .await + .unwrap() + .status(), + StatusCode::NO_CONTENT + ); + let paths = [ + "/api/reports/export/xlsx?from=2026-09-07&to=2026-09-07".to_owned(), + format!("/api/invoices/{invoice_id}/export/xlsx"), + format!("/api/invoices/{invoice_id}/export/pdf"), + ]; + for path in &paths { + denied(download(api, path, None).await, StatusCode::UNAUTHORIZED).await; + denied( + download(api, path, Some(&member)).await, + StatusCode::FORBIDDEN, + ) + .await; + denied( + download(api, path, Some(&expired)).await, + StatusCode::UNAUTHORIZED, + ) + .await; + for role in [OrgRole::Manager, OrgRole::Admin] { + sqlx::query!( + "UPDATE users SET org_role=$2 WHERE id=$1", + ids.user_id, + role as OrgRole + ) + .execute(pool) + .await + .unwrap(); + let separator = if path.contains('?') { '&' } else { '?' }; + let forged = format!( + "{path}{separator}org_id={}&actor_id={}&org_role=member", + foreign.org_id, foreign.user_id + ); + let response = download(api, &forged, Some(&cookie)).await; + assert_eq!(response.status(), StatusCode::OK); + let pdf = path.ends_with("/pdf"); + assert_eq!( + response.headers()["content-type"], + if pdf { + "application/pdf" + } else { + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + } + ); + assert!( + response.headers()["content-disposition"] + .to_str() + .unwrap() + .contains(if path.contains("/reports/") { + "timesheet.xlsx" + } else { + "EXPORT-PRIVATE" + }) + ); + let bytes = response.bytes().await.unwrap(); + if pdf { + assert!(bytes.starts_with(b"%PDF-")); + } else { + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut strings = String::new(); + archive + .by_name("xl/sharedStrings.xml") + .unwrap() + .read_to_string(&mut strings) + .unwrap(); + assert!(strings.contains("Private export note")); + assert!(!strings.contains("Other tenant secret")); + } + } + if path.contains("/invoices/") { + denied( + download(api, path, Some(&other)).await, + StatusCode::NOT_FOUND, + ) + .await; + let missing = path.replace(&invoice_id.to_string(), &Uuid::now_v7().to_string()); + denied( + download(api, &missing, Some(&cookie)).await, + StatusCode::NOT_FOUND, + ) + .await; + } else { + let filtered = format!("{path}&project_id={}", foreign.project_id); + let response = download(api, &filtered, Some(&cookie)).await; + assert_eq!(response.status(), StatusCode::OK); + let mut archive = + zip::ZipArchive::new(Cursor::new(response.bytes().await.unwrap())).unwrap(); + let mut sheet = String::new(); + archive + .by_name("xl/worksheets/sheet1.xml") + .unwrap() + .read_to_string(&mut sheet) + .unwrap(); + assert_eq!(sheet.matches(" String { + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers()["content-disposition"], + "attachment; filename=\"projects.xlsx\"" + ); + assert_eq!( + response.headers()["content-type"], + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ); + let bytes = response.bytes().await.unwrap(); + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut result = String::new(); + archive + .by_name("xl/sharedStrings.xml") + .unwrap() + .read_to_string(&mut result) + .unwrap(); + result +} + +fn copy_api(api: &Api) -> Api { + Api { + base: api.base.clone(), + client: api.client.clone(), + } +} + +async fn export_after_writer( + pool: &PgPool, + api: &Api, + client_id: Uuid, + cookies: (&str, &str), + operation: &'static str, + request: Value, +) -> (Value, String) { + let mut blocker = pool.begin().await.unwrap(); + sqlx::query!("SELECT id FROM clients WHERE id=$1 FOR UPDATE", client_id) + .fetch_one(&mut *blocker) + .await + .unwrap(); + let pid = sqlx::query_scalar!("SELECT pg_backend_pid() as \"pid!\"") + .fetch_one(&mut *blocker) + .await + .unwrap(); + let writer = { + let api = copy_api(api); + let cookie = cookies.0.to_owned(); + tokio::spawn(async move { api.json(operation, request, &cookie).await }) + }; + wait_for_blocked(pool, pid).await; + let writer_pid = sqlx::query_scalar!( + r#"SELECT pid AS "pid!" FROM pg_stat_activity + WHERE datname=current_database() AND $1=ANY(pg_blocking_pids(pid)) LIMIT 1"#, + pid, + ) + .fetch_one(pool) + .await + .unwrap(); + let reader = { + let api = copy_api(api); + let cookie = cookies.1.to_owned(); + tokio::spawn(async move { strings(download(&api, PATH, Some(&cookie)).await).await }) + }; + wait_for_blocked(pool, writer_pid).await; + blocker.commit().await.unwrap(); + (writer.await.unwrap(), reader.await.unwrap()) +} + +pub(super) async fn check(pool: &PgPool, api: &Api) { + let ids = seed(pool, OrgRole::Manager).await; + let member = user(pool, ids.org_id, OrgRole::Member).await; + let manager_cookie = api.cookie(ids.user_id).await; + let member_cookie = api.cookie(member).await; + denied(download(api, PATH, None).await, StatusCode::UNAUTHORIZED).await; + assert!( + !strings(download(api, PATH, Some(&member_cookie)).await) + .await + .contains("Widget") + ); + + let draft_id = Uuid::now_v7(); + let form = ProjectForm { + client_id: Some(ids.client_id), + name: "Newly assigned export project".to_owned(), + report_visibility: ReportVisibility::ProjectMembers, + team: vec![ProjectMemberInput { + user_id: member, + manager: false, + billable_rate: "100.00".to_owned(), + cost_rate: String::new(), + budget: String::new(), + }], + ..ProjectForm::default() + }; + let saved = api + .json( + "save_project_draft", + json!({ + "draft_id":draft_id,"expected_revision":0,"form":form + }), + &manager_cookie, + ) + .await; + let revision = sqlx::query_scalar!( + "SELECT access_revision FROM organizations WHERE id=$1", + ids.org_id + ) + .fetch_one(pool) + .await + .unwrap(); + let (project_id, exported) = export_after_writer( + pool, + api, + ids.client_id, + (&manager_cookie, &member_cookie), + "finalize_project_draft", + json!({ + "draft_id":draft_id,"expected_revision":saved["revision"],"form":form + }), + ) + .await; + assert!(exported.contains(&form.name)); + assert!(!exported.contains("Widget")); + assert_eq!( + sqlx::query_scalar!( + "SELECT access_revision FROM organizations WHERE id=$1", + ids.org_id + ) + .fetch_one(pool) + .await + .unwrap(), + revision + ); + + let mut editor = api + .json( + "load_project_editor", + json!({"project_id":project_id}), + &manager_cookie, + ) + .await; + editor["form"]["report_visibility"] = json!("managers"); + api.json( + "save_project_editor", + json!({"request":{ + "id":Uuid::now_v7(),"project_id":project_id, + "expected_revision":editor["revision"],"form":editor["form"] + }}), + &manager_cookie, + ) + .await; + assert!( + !strings(download(api, PATH, Some(&member_cookie)).await) + .await + .contains(&form.name) + ); + + let mut editor = api + .json( + "load_project_editor", + json!({"project_id":project_id}), + &manager_cookie, + ) + .await; + editor["form"]["report_visibility"] = json!("project_members"); + let (_, exported) = export_after_writer( + pool, + api, + ids.client_id, + (&manager_cookie, &member_cookie), + "save_project_editor", + json!({"request":{ + "id":Uuid::now_v7(),"project_id":project_id, + "expected_revision":editor["revision"],"form":editor["form"] + }}), + ) + .await; + assert!(exported.contains(&form.name)); + + let foreign = seed(pool, OrgRole::Admin).await; + let forged = format!( + "{PATH}?org_id={}&viewer_id={}&actor_id={}", + foreign.org_id, foreign.user_id, foreign.user_id + ); + let exported = strings(download(api, &forged, Some(&member_cookie)).await).await; + assert!(exported.contains(&form.name)); + assert!(!exported.contains("Widget")); + let own_project: Uuid = serde_json::from_value(project_id).unwrap(); + sqlx::query!( + "UPDATE projects SET name='Other tenant secret' WHERE id=$1", + foreign.project_id + ) + .execute(pool) + .await + .unwrap(); + assert!( + !strings(download(api, PATH, Some(&member_cookie)).await) + .await + .contains("Other tenant secret") + ); + sqlx::query!( + "DELETE FROM assignments WHERE user_id=$1 AND project_id=$2", + member, + own_project + ) + .execute(pool) + .await + .unwrap(); + assert!( + !strings(download(api, PATH, Some(&member_cookie)).await) + .await + .contains(&form.name) + ); + sqlx::query!("UPDATE users SET active=false WHERE id=$1", member) + .execute(pool) + .await + .unwrap(); + denied( + download(api, PATH, Some(&member_cookie)).await, + StatusCode::UNAUTHORIZED, + ) + .await; +}