diff --git a/server/tests/administration/test_create_sequester_service.py b/server/tests/administration/test_create_sequester_service.py index 4bdd199de37..f5e288dd5b4 100644 --- a/server/tests/administration/test_create_sequester_service.py +++ b/server/tests/administration/test_create_sequester_service.py @@ -1,8 +1,26 @@ # Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS +from base64 import b64encode +from unittest.mock import ANY + import httpx +import pytest -from tests.common import Backend, SequesteredOrgRpcClients +from parsec._parsec import ( + DateTime, + SequesterPrivateKeyDer, + SequesterServiceCertificate, + SequesterServiceID, + SequesterSigningKeyDer, +) +from parsec._parsec import ( + testbed as tb, +) +from parsec.components.sequester import ( + StorageSequesterService, + WebhookSequesterService, +) +from tests.common import Backend, CoolorgRpcClients, SequesteredOrgRpcClients async def test_bad_auth( @@ -53,68 +71,204 @@ async def test_unknown_organization( json={"service_certificate": "ZHVtbXk=", "config": {"type": "storage"}}, ) assert response.status_code == 404, response.content + assert response.json() == {"detail": "Organization not found"} + + +@pytest.mark.parametrize("kind", ("storage", "webhook")) +async def test_ok( + client: httpx.AsyncClient, + backend: Backend, + sequestered_org: SequesteredOrgRpcClients, + kind: str, +) -> None: + certif = SequesterServiceCertificate( + timestamp=DateTime.now(), + service_id=SequesterServiceID.new(), + service_label="Sequester Service 3", + encryption_key_der=SequesterPrivateKeyDer.generate_pair(1024)[1], + ) + certif_signed = sequestered_org.sequester_authority_signing_key.sign(certif.dump()) + + match kind: + case "storage": + config = {"type": "storage"} + expected_service = StorageSequesterService( + service_id=certif.service_id, + service_label=certif.service_label, + service_certificate=certif_signed, + created_on=certif.timestamp, + revoked_on=None, + ) + + case "webhook": + config = {"type": "webhook", "webhook_url": "https://parsec.invalid/webhook"} + expected_service = WebhookSequesterService( + service_id=certif.service_id, + service_label=certif.service_label, + service_certificate=certif_signed, + created_on=certif.timestamp, + revoked_on=None, + webhook_url="https://parsec.invalid/webhook", + ) + + case unknown: + assert False, unknown + + url = f"http://parsec.invalid/administration/organizations/{sequestered_org.organization_id.str}/sequester/services" + response = await client.post( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + json={ + "service_certificate": b64encode(certif_signed).decode(), + "config": config, + }, + ) + assert response.status_code == 200, response.content + assert response.json() == {} + + sequester_services = await backend.sequester.get_organization_services( + sequestered_org.organization_id + ) + assert isinstance(sequester_services, list) + assert sorted(sequester_services, key=lambda s: s.service_label) == [ + StorageSequesterService( + service_id=sequestered_org.sequester_service_1_id, + service_label="Sequester Service 1", + service_certificate=ANY, + created_on=DateTime(2000, 1, 11), + revoked_on=DateTime(2000, 1, 16), + ), + StorageSequesterService( + service_id=sequestered_org.sequester_service_2_id, + service_label="Sequester Service 2", + service_certificate=ANY, + created_on=DateTime(2000, 1, 18), + revoked_on=None, + ), + expected_service, + ] + + +@pytest.mark.parametrize("kind", ("serialization", "signature")) +async def test_invalid_certificate( + client: httpx.AsyncClient, + backend: Backend, + sequestered_org: SequesteredOrgRpcClients, + kind: str, +) -> None: + match kind: + case "serialization": + certif_signed = b"dummy" + case "signature": + certif = SequesterServiceCertificate( + timestamp=DateTime.now(), + service_id=SequesterServiceID.new(), + service_label="Sequester Service 3", + encryption_key_der=SequesterPrivateKeyDer.generate_pair(1024)[1], + ) + certif_signed = SequesterSigningKeyDer.generate_pair(1024)[0].sign(certif.dump()) + case unknown: + assert False, unknown + + url = f"http://parsec.invalid/administration/organizations/{sequestered_org.organization_id.str}/sequester/services" + response = await client.post( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + json={ + "service_certificate": b64encode(certif_signed).decode(), + "config": {"type": "storage"}, + }, + ) + assert response.status_code == 400, response.content + assert response.json() == {"detail": "Invalid certificate"} + + +async def test_not_sequestered_organization( + client: httpx.AsyncClient, + backend: Backend, + sequestered_org: SequesteredOrgRpcClients, + coolorg: CoolorgRpcClients, +) -> None: + other_org_new_sequester_service_event = next( + e + for e in sequestered_org.testbed_template.events + if isinstance(e, tb.TestbedEventNewSequesterService) + ) + + url = f"http://parsec.invalid/administration/organizations/{coolorg.organization_id.str}/sequester/services" + response = await client.post( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + json={ + "service_certificate": b64encode( + other_org_new_sequester_service_event.raw_certificate + ).decode(), + "config": {"type": "storage"}, + }, + ) + assert response.status_code == 400, response.content + assert response.json() == {"detail": "Sequester disabled"} + + +async def test_sequestered_service_already_exists( + client: httpx.AsyncClient, + backend: Backend, + sequestered_org: SequesteredOrgRpcClients, +) -> None: + certif = SequesterServiceCertificate( + timestamp=DateTime.now(), + service_id=sequestered_org.sequester_service_2_id, + service_label="Sequester Service 3", + encryption_key_der=SequesterPrivateKeyDer.generate_pair(1024)[1], + ) + certif_signed = sequestered_org.sequester_authority_signing_key.sign(certif.dump()) + + url = f"http://parsec.invalid/administration/organizations/{sequestered_org.organization_id.str}/sequester/services" + response = await client.post( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + json={ + "service_certificate": b64encode(certif_signed).decode(), + "config": {"type": "storage"}, + }, + ) + assert response.status_code == 400, response.content + assert response.json() == {"detail": "Sequester service already exists"} -# async def test_ok( -# client: httpx.AsyncClient, -# backend: Backend, -# sequestered_org: SequesteredOrgRpcClients, -# ) -> None: -# url = f"http://parsec.invalid/administration/organizations/{sequestered_org.organization_id.str}/sequester/services" -# response = await client.post( -# url, -# headers={"Authorization": f"Bearer {backend.config.administration_token}"}, -# ) -# assert response.status_code == 200, response.content -# assert response.json() == { -# "services": [ -# { -# "service_id": sequestered_org.sequester_service_1_id.hex, -# "service_label": "Sequester Service 1", -# "created_on": "2000-01-11T00:00:00Z", -# "revoked_on": "2000-01-16T00:00:00Z", -# "type": "storage", -# }, -# { -# "service_id": sequestered_org.sequester_service_2_id.hex, -# "service_label": "Sequester Service 2", -# "created_on": "2000-01-18T00:00:00Z", -# "revoked_on": None, -# "type": "storage", -# }, -# ], -# } - -# # Update service config - -# outcome = await backend.sequester.update_config_for_service( -# organization_id=sequestered_org.organization_id, -# service_id=sequestered_org.sequester_service_2_id, -# config=(SequesterServiceType.WEBHOOK, "https://parsec.invalid/webhook"), -# ) -# assert outcome is None - -# response = await client.post( -# url, -# headers={"Authorization": f"Bearer {backend.config.administration_token}"}, -# ) -# assert response.status_code == 200, response.content -# assert response.json() == { -# "services": [ -# { -# "service_id": sequestered_org.sequester_service_1_id.hex, -# "service_label": "Sequester Service 1", -# "created_on": "2000-01-11T00:00:00Z", -# "revoked_on": "2000-01-16T00:00:00Z", -# "type": "storage", -# }, -# { -# "service_id": sequestered_org.sequester_service_2_id.hex, -# "service_label": "Sequester Service 2", -# "created_on": "2000-01-18T00:00:00Z", -# "revoked_on": None, -# "type": "webhook", -# "webhook_url": "https://parsec.invalid/webhook", -# }, -# ], -# } +async def test_require_greater_timestamp( + client: httpx.AsyncClient, + backend: Backend, + sequestered_org: SequesteredOrgRpcClients, +) -> None: + last_sequester_event = next( + e + for e in reversed(sequestered_org.testbed_template.events) + if isinstance( + e, (tb.TestbedEventNewSequesterService, tb.TestbedEventRevokeSequesterService) + ) + ) + certif = SequesterServiceCertificate( + timestamp=last_sequester_event.timestamp, + service_id=SequesterServiceID.new(), + service_label="Sequester Service 3", + encryption_key_der=SequesterPrivateKeyDer.generate_pair(1024)[1], + ) + certif_signed = sequestered_org.sequester_authority_signing_key.sign(certif.dump()) + + url = f"http://parsec.invalid/administration/organizations/{sequestered_org.organization_id.str}/sequester/services" + response = await client.post( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + json={ + "service_certificate": b64encode(certif_signed).decode(), + "config": {"type": "storage"}, + }, + ) + assert response.status_code == 400, response.content + assert response.json() == { + "detail": { + "msg": "Require greater timestamp", + "strictly_greater_than": "2000-01-18T00:00:00Z", + } + } diff --git a/server/tests/administration/test_list_sequester_services.py b/server/tests/administration/test_list_sequester_services.py index 82cd30034f2..7a2872aab29 100644 --- a/server/tests/administration/test_list_sequester_services.py +++ b/server/tests/administration/test_list_sequester_services.py @@ -3,7 +3,7 @@ import httpx from parsec.components.sequester import SequesterServiceType -from tests.common import Backend, SequesteredOrgRpcClients +from tests.common import Backend, CoolorgRpcClients, SequesteredOrgRpcClients async def test_bad_auth( @@ -52,6 +52,22 @@ async def test_unknown_organization( headers={"Authorization": f"Bearer {backend.config.administration_token}"}, ) assert response.status_code == 404, response.content + assert response.json() == {"detail": "Organization not found"} + + +async def test_not_sequestered_organization( + client: httpx.AsyncClient, + backend: Backend, + sequestered_org: SequesteredOrgRpcClients, + coolorg: CoolorgRpcClients, +) -> None: + url = f"http://parsec.invalid/administration/organizations/{coolorg.organization_id.str}/sequester/services" + response = await client.get( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + ) + assert response.status_code == 400, response.content + assert response.json() == {"detail": "Sequester disabled"} async def test_ok( diff --git a/server/tests/administration/test_revoke_sequester_service.py b/server/tests/administration/test_revoke_sequester_service.py index 24aead461e7..af4616eb569 100644 --- a/server/tests/administration/test_revoke_sequester_service.py +++ b/server/tests/administration/test_revoke_sequester_service.py @@ -1,8 +1,23 @@ # Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS +from base64 import b64encode +from unittest.mock import ANY + import httpx +import pytest -from tests.common import Backend, SequesteredOrgRpcClients +from parsec._parsec import ( + DateTime, + SequesterRevokedServiceCertificate, + SequesterSigningKeyDer, +) +from parsec._parsec import ( + testbed as tb, +) +from parsec.components.sequester import ( + StorageSequesterService, +) +from tests.common import Backend, CoolorgRpcClients, SequesteredOrgRpcClients async def test_bad_auth( @@ -53,3 +68,164 @@ async def test_unknown_organization( json={"revoked_service_certificate": "ZHVtbXk="}, ) assert response.status_code == 404, response.content + assert response.json() == {"detail": "Organization not found"} + + +async def test_ok( + client: httpx.AsyncClient, + backend: Backend, + sequestered_org: SequesteredOrgRpcClients, +) -> None: + certif = SequesterRevokedServiceCertificate( + timestamp=DateTime.now(), + service_id=sequestered_org.sequester_service_2_id, + ) + certif_signed = sequestered_org.sequester_authority_signing_key.sign(certif.dump()) + + url = f"http://parsec.invalid/administration/organizations/{sequestered_org.organization_id.str}/sequester/services/revoke" + response = await client.post( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + json={ + "revoked_service_certificate": b64encode(certif_signed).decode(), + }, + ) + assert response.status_code == 200, response.content + assert response.json() == {} + + sequester_services = await backend.sequester.get_organization_services( + sequestered_org.organization_id + ) + assert isinstance(sequester_services, list) + assert sorted(sequester_services, key=lambda s: s.service_label) == [ + StorageSequesterService( + service_id=sequestered_org.sequester_service_1_id, + service_label="Sequester Service 1", + service_certificate=ANY, + created_on=DateTime(2000, 1, 11), + revoked_on=DateTime(2000, 1, 16), + ), + StorageSequesterService( + service_id=sequestered_org.sequester_service_2_id, + service_label="Sequester Service 2", + service_certificate=ANY, + created_on=DateTime(2000, 1, 18), + revoked_on=certif.timestamp, + ), + ] + + +@pytest.mark.parametrize("kind", ("serialization", "signature")) +async def test_invalid_certificate( + client: httpx.AsyncClient, + backend: Backend, + sequestered_org: SequesteredOrgRpcClients, + kind: str, +) -> None: + match kind: + case "serialization": + certif_signed = b"dummy" + case "signature": + certif = SequesterRevokedServiceCertificate( + timestamp=DateTime.now(), + service_id=sequestered_org.sequester_service_2_id, + ) + certif_signed = SequesterSigningKeyDer.generate_pair(1024)[0].sign(certif.dump()) + case unknown: + assert False, unknown + + url = f"http://parsec.invalid/administration/organizations/{sequestered_org.organization_id.str}/sequester/services/revoke" + response = await client.post( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + json={ + "revoked_service_certificate": b64encode(certif_signed).decode(), + }, + ) + assert response.status_code == 400, response.content + assert response.json() == {"detail": "Invalid certificate"} + + +async def test_not_sequestered_organization( + client: httpx.AsyncClient, + backend: Backend, + sequestered_org: SequesteredOrgRpcClients, + coolorg: CoolorgRpcClients, +) -> None: + other_org_revoke_sequester_service_event = next( + e + for e in sequestered_org.testbed_template.events + if isinstance(e, tb.TestbedEventRevokeSequesterService) + ) + + url = f"http://parsec.invalid/administration/organizations/{coolorg.organization_id.str}/sequester/services/revoke" + response = await client.post( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + json={ + "revoked_service_certificate": b64encode( + other_org_revoke_sequester_service_event.raw_certificate + ).decode(), + }, + ) + assert response.status_code == 400, response.content + assert response.json() == {"detail": "Sequester disabled"} + + +async def test_sequestered_service_already_revoked( + client: httpx.AsyncClient, + backend: Backend, + sequestered_org: SequesteredOrgRpcClients, +) -> None: + certif = SequesterRevokedServiceCertificate( + timestamp=DateTime.now(), + service_id=sequestered_org.sequester_service_1_id, + ) + certif_signed = sequestered_org.sequester_authority_signing_key.sign(certif.dump()) + + url = f"http://parsec.invalid/administration/organizations/{sequestered_org.organization_id.str}/sequester/services/revoke" + response = await client.post( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + json={ + "revoked_service_certificate": b64encode(certif_signed).decode(), + }, + ) + assert response.status_code == 400, response.content + assert response.json() == {"detail": "Sequester service already revoked"} + + +async def test_require_greater_timestamp( + client: httpx.AsyncClient, + backend: Backend, + sequestered_org: SequesteredOrgRpcClients, +) -> None: + last_sequester_event = next( + e + for e in reversed(sequestered_org.testbed_template.events) + if isinstance( + e, (tb.TestbedEventNewSequesterService, tb.TestbedEventRevokeSequesterService) + ) + ) + certif = SequesterRevokedServiceCertificate( + timestamp=last_sequester_event.timestamp, + service_id=sequestered_org.sequester_service_2_id, + ) + certif_signed = sequestered_org.sequester_authority_signing_key.sign(certif.dump()) + + url = f"http://parsec.invalid/administration/organizations/{sequestered_org.organization_id.str}/sequester/services/revoke" + response = await client.post( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + json={ + "revoked_service_certificate": b64encode(certif_signed).decode(), + "config": {"type": "storage"}, + }, + ) + assert response.status_code == 400, response.content + assert response.json() == { + "detail": { + "msg": "Require greater timestamp", + "strictly_greater_than": "2000-01-18T00:00:00Z", + } + } diff --git a/server/tests/administration/test_update_sequester_service_config.py b/server/tests/administration/test_update_sequester_service_config.py index f63016c0494..a053a9503e7 100644 --- a/server/tests/administration/test_update_sequester_service_config.py +++ b/server/tests/administration/test_update_sequester_service_config.py @@ -1,8 +1,19 @@ # Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS +from unittest.mock import ANY + import httpx +import pytest -from tests.common import Backend, SequesteredOrgRpcClients +from parsec._parsec import ( + DateTime, +) +from parsec.components.sequester import ( + BaseSequesterService, + StorageSequesterService, + WebhookSequesterService, +) +from tests.common import Backend, CoolorgRpcClients, SequesteredOrgRpcClients async def test_bad_auth( @@ -59,3 +70,106 @@ async def test_unknown_organization( }, ) assert response.status_code == 404, response.content + + +async def test_unknown_service( + client: httpx.AsyncClient, + backend: Backend, + sequestered_org: SequesteredOrgRpcClients, +) -> None: + url = f"http://parsec.invalid/administration/organizations/{sequestered_org.organization_id.str}/sequester/services/config" + response = await client.put( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + json={ + "service_id": "460369bc70914615bf73ba30f896957e", + "config": {"type": "webhook", "webhook_url": "https://parsec.invalid/webhook"}, + }, + ) + assert response.status_code == 404, response.content + assert response.json() == {"detail": "Sequester service not found"} + + +async def test_not_sequestered_organization( + client: httpx.AsyncClient, + backend: Backend, + sequestered_org: SequesteredOrgRpcClients, + coolorg: CoolorgRpcClients, +) -> None: + url = f"http://parsec.invalid/administration/organizations/{coolorg.organization_id.str}/sequester/services/config" + response = await client.put( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + json={ + "service_id": sequestered_org.sequester_service_2_id.hex, + "config": {"type": "webhook", "webhook_url": "https://parsec.invalid/webhook"}, + }, + ) + assert response.status_code == 400, response.content + assert response.json() == {"detail": "Sequester disabled"} + + +@pytest.mark.parametrize("kind", ("active", "revoked")) +async def test_ok( + client: httpx.AsyncClient, + backend: Backend, + sequestered_org: SequesteredOrgRpcClients, + kind: str, +) -> None: + expected_services: list[BaseSequesterService] = [ + StorageSequesterService( + service_id=sequestered_org.sequester_service_1_id, + service_label="Sequester Service 1", + service_certificate=ANY, + created_on=DateTime(2000, 1, 11), + revoked_on=DateTime(2000, 1, 16), + ), + StorageSequesterService( + service_id=sequestered_org.sequester_service_2_id, + service_label="Sequester Service 2", + service_certificate=ANY, + created_on=DateTime(2000, 1, 18), + revoked_on=None, + ), + ] + match kind: + case "active": + service_id = sequestered_org.sequester_service_2_id + expected_services[1] = WebhookSequesterService( + service_id=expected_services[1].service_id, + service_label=expected_services[1].service_label, + service_certificate=expected_services[1].service_certificate, + created_on=expected_services[1].created_on, + revoked_on=expected_services[1].revoked_on, + webhook_url="https://parsec.invalid/webhook", + ) + case "revoked": + service_id = sequestered_org.sequester_service_1_id + expected_services[0] = WebhookSequesterService( + service_id=expected_services[0].service_id, + service_label=expected_services[0].service_label, + service_certificate=expected_services[0].service_certificate, + created_on=expected_services[0].created_on, + revoked_on=expected_services[0].revoked_on, + webhook_url="https://parsec.invalid/webhook", + ) + case unknown: + assert False, unknown + + url = f"http://parsec.invalid/administration/organizations/{sequestered_org.organization_id.str}/sequester/services/config" + response = await client.put( + url, + headers={"Authorization": f"Bearer {backend.config.administration_token}"}, + json={ + "service_id": service_id.hex, + "config": {"type": "webhook", "webhook_url": "https://parsec.invalid/webhook"}, + }, + ) + assert response.status_code == 200, response.content + assert response.json() == {} + + sequester_services = await backend.sequester.get_organization_services( + sequestered_org.organization_id + ) + assert isinstance(sequester_services, list) + assert sorted(sequester_services, key=lambda s: s.service_label) == expected_services