diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 323cd08..f2b5c59 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -50,6 +50,9 @@ jobs: - name: Run Lab 03 (seeding + indexes) run: python labs/03_seeding_indexes.py + - name: Run Lab 05 (secured embedded) + run: python labs/05_secured_embedded.py + attach: # Path C: attach to a RavenDB server running in Docker, with NO .NET installed. Proves the # driver needs no runtime when it does not boot the embedded server. diff --git a/README.md b/README.md index 66685ae..fbc53c1 100644 --- a/README.md +++ b/README.md @@ -58,15 +58,68 @@ upgrading to a new RavenDB minor version. Runnable walkthrough: [Lab 02 — isolated embedded databases](labs/02-embedded-per-test.md). +#### Where test data lives + +Embedded test servers run in memory, so the create-and-delete-a-database cycle behind every +`get_document_store()` call never lands on disk. Only the server log is written, to a scratch +directory the driver removes when the interpreter exits. + +Two consequences worth knowing: + +- A large fixture seeded in `setup_database` is held in RAM rather than spilled to disk. +- Nothing survives a server restart, and there are no files to inspect after a failing run. + +To go back to disk-backed storage, either set the argument yourself, which the driver never +overrides: + +```python +options = TestServerOptions() +options.command_line_args.append("--RunInMemory=false") +options.data_directory = "/path/you/choose" +RavenTestDriver.configure_server(options) +``` + +or switch it off on the options themselves: + +```python +options = TestServerOptions() +options.run_in_memory = False +RavenTestDriver.configure_server(options) +``` + +The driver also redirects the data directory when you leave it at the `ravendb-embedded` default, +which otherwise points inside the installed package. Set `data_directory` explicitly and the +driver leaves your path alone. + +Nothing closes the shared test server before interpreter exit. Call +`RavenTestDriver.stop_test_server()` from a session-scoped fixture teardown when you want that +cost inside your test run rather than after the runner prints its summary; the server starts +again on the next `get_document_store()`. + +#### Secured embedded server + +Pass a server certificate together with the client PEM the tests authenticate with, and the driver +wires that client material into every store it hands out: + +```python +options = TestServerOptions() +options.secured("server.pfx", "client.pem", ca_certificate_path="ca.crt") +RavenTestDriver.configure_server(options) +``` + +The client PEM is required here: a secured server the test client cannot authenticate to is +rejected before the server starts. + +Runnable walkthrough: [Lab 05 — secured embedded server](labs/05-secured-embedded.md). + ### On-demand self-contained server Let the driver download, cache, and manage the self-contained build for the current platform: ```python -from ravendb_embedded import ServerOptions -from ravendb_test_driver import RavenTestDriver +from ravendb_test_driver import RavenTestDriver, TestServerOptions -options = ServerOptions() +options = TestServerOptions() options.with_auto_downloaded_server() RavenTestDriver.configure_server(options) @@ -75,6 +128,9 @@ with RavenTestDriver() as driver: ... ``` +`TestServerOptions` is a `ravendb_embedded.ServerOptions` that names the intent. `configure_server` +still accepts a plain `ServerOptions`, and the driver applies the same test defaults either way. + The same test configuration works across supported Windows, Linux, and macOS machines because the operating system and architecture are detected at runtime. The first run downloads 100 MB+; later runs reuse `~/.cache/ravendb-embedded`. Pass `cache_root` to @@ -128,12 +184,18 @@ The equivalent environment variables are: `trust_store_path` or `RAVENDB_TEST_SERVER_CA` is needed when the server's CA is not already trusted by the test machine. +Explicit configuration wins over the environment. If a test calls `configure_server()` and +`RAVENDB_TEST_SERVER_URL` is also set, the environment variable is ignored and a warning is +emitted, because the driver creates and hard-deletes databases on whichever server it uses. To let +the environment pick the server, do not call `configure_server()`. + Runnable walkthrough: [Lab 01 — Docker, Testcontainers, and shared servers](labs/01-attach-to-server.md). ## Test lifecycle -Create a `RavenTestDriver` for the test or fixture, then close every returned store. A context -manager handles both steps: +Create a `RavenTestDriver` for the test or fixture and register its cleanup once. Closing the driver +closes any store you left open and deletes its database, so nothing leaks if a test throws halfway +and you never have to close a store yourself: ```python from unittest import TestCase @@ -141,16 +203,33 @@ from ravendb_test_driver import RavenTestDriver class TestPeople(TestCase): + def setUp(self): + self.driver = RavenTestDriver() + self.addCleanup(self.driver.close) # the only cleanup line you need + def test_stores_a_person(self): - with RavenTestDriver() as driver: - with driver.get_document_store() as store: - with store.open_session() as session: - session.store({"name": "John"}, "people/1") - session.save_changes() + store = self.driver.get_document_store() + with store.open_session() as session: + session.store({"name": "John"}, "people/1") + session.save_changes() +``` + +Closing stores yourself is still fine, and it is what you want when one test creates several +databases and the order they go away in matters: + +```python +with RavenTestDriver() as driver: + with driver.get_document_store() as store: + ... ``` Each `get_document_store()` call creates a new database. Closing the store deletes it, which keeps -tests independent even when they share one RavenDB server process. +tests independent even when they share one RavenDB server process. Database names are generated +(`test_1`, `test_2`, ...) from a process-wide counter; treat them as opaque and read +`store.database` rather than assuming a name, or pass `database="..."` to pick the stem yourself. + +If closing the driver hits errors, it raises `DriverCloseError`, a `RuntimeError` subclass whose +`exceptions` attribute holds every original exception rather than a joined string. ## Seed data and wait for indexing @@ -165,14 +244,75 @@ class PeopleTestDriver(RavenTestDriver): session.save_changes() ``` +Override `pre_configure_database(self, database_record)` to change the database itself before it is +created, for settings, revisions, expiration, encryption or topology: + +```python +class PeopleTestDriver(RavenTestDriver): + def pre_configure_database(self, database_record): + database_record.settings["Indexing.MapTimeoutInSec"] = "30" +``` + Use `GetDocumentStoreOptions.wait_for_indexing_timeout` when a store should not be returned until -indexing settles, or call `wait_for_indexing(store)` directly. +indexing settles, or call `wait_for_indexing(store)` directly. It waits until every applicable +index is non-stale and any side-by-side replacement has been swapped in. -`wait_for_user_to_continue_the_test(store)` opens RavenDB Studio and pauses the test for manual -inspection. +## Pausing for manual inspection + +`wait_for_user_to_continue_the_test(store)` prints the Studio URL for that database, opens a +browser, and blocks until a document with the id `Debug/Done` shows up in the database. Store one +from Studio to continue; the driver deletes the marker so a later wait on the same store still +blocks. + +The wait is unbounded, because you are the one looking at Studio. Pass a `timeout` to bound it and +get a `TimeoutException` instead. A CI job protects itself from a call left in committed code with +`RAVENDB_TEST_WAIT_FOR_USER=0`, which skips the wait entirely. Runnable walkthrough: [Lab 03 — seeding and indexes](labs/03-seeding-indexes.md). +## Opt-in switches + +Defaults are chosen so an existing suite keeps working. These are the knobs worth knowing: + +| Switch | Default | What it does | +|--------|---------|--------------| +| `TestServerOptions.run_in_memory` | `True` | Runs embedded test servers in memory. Set `False` on the options you pass to `configure_server` to go back to disk | +| `RavenTestDriver.use_caller_name_for_database` | `False` | Names databases after the calling test (`test_stores_a_person_3`) instead of `test_3` | +| `RAVENDB_TEST_UNIQUE_DB_NAMES` | off | Adds the process id to database names, so parallel runners sharing one attached server stop colliding | +| `RAVENDB_TEST_WAIT_FOR_USER` | on | Set to `0` to skip `wait_for_user_to_continue_the_test` entirely | + +Anything describing the server itself belongs on the options object; the environment variables exist +so a CI job can flip a switch without editing test code. + +Caller-name databases are sanitized to `[A-Za-z0-9_.-]`, and fall back to `test` when the caller has +no usable name, such as a lambda or a module-level call. + +## Inspecting HTTP traffic + +The client sends its requests through `requests`, which honors `HTTP_PROXY`, so any interception +proxy works without driver support: + +```bash +HTTP_PROXY=http://127.0.0.1:8080 python -m unittest +``` + +On Windows, proxy bypass rules skip loopback addresses, so traffic to `127.0.0.1` never reaches the +proxy. Bind the test server to the machine name instead, which also needs unsecured access to be +allowed on the private network: + +```python +import socket + +from ravendb_test_driver import RavenTestDriver, TestServerOptions + +options = TestServerOptions() +options.server_url = f"http://{socket.gethostname()}:0" +options.command_line_args.append("--Security.UnsecuredAccessAllowed=PrivateNetwork") +RavenTestDriver.configure_server(options) +``` + +That pair is what `TestServerOptions.UseFiddler()` does in the .NET test driver. + ## Labs | Lab | Scenario | Needs system .NET? | @@ -181,6 +321,7 @@ Runnable walkthrough: [Lab 03 — seeding and indexes](labs/03-seeding-indexes.m | [02](labs/02-embedded-per-test.md) | Default embedded server and isolated databases | Yes | | [03](labs/03-seeding-indexes.md) | Seed data and wait for real indexing | Yes | | [04](labs/04-embedded-no-dotnet.md) | On-demand self-contained server | No | +| [05](labs/05-secured-embedded.md) | Secured embedded server with client certificates | Yes | The runnable scripts live in this repository rather than `site-packages`. Clone or download the repository, install the package, and run them from the repository root. See the diff --git a/labs/02-embedded-per-test.md b/labs/02-embedded-per-test.md index 4a0c3d4..ffac38e 100644 --- a/labs/02-embedded-per-test.md +++ b/labs/02-embedded-per-test.md @@ -21,17 +21,43 @@ from ravendb_test_driver import RavenTestDriver class TestThings(TestCase): def setUp(self): self.driver = RavenTestDriver() + self.addCleanup(self.driver.close) # the only cleanup line you need def test_it(self): - with self.driver.get_document_store() as store: # fresh isolated database - with store.open_session() as session: - session.store({"name": "John"}, "people/1") - session.save_changes() + store = self.driver.get_document_store() # fresh isolated database, never closed by hand + with store.open_session() as session: + session.store({"name": "John"}, "people/1") + session.save_changes() ``` Two `get_document_store()` calls give two different databases, so data written to one is invisible to the other. That isolation is what keeps tests independent. +You do not have to close the stores. Closing the driver closes whatever is still open and deletes +those databases, so registering the driver's cleanup once covers every test in the class, including +the ones that throw halfway through. + +Outside a test class, a `with` block does the same thing: + +```python +with RavenTestDriver() as driver: + store = driver.get_document_store() + ... # no store.close() anywhere +# leaving the block closed the store and deleted its database +``` + +Closing stores yourself is still fine, and it is what you want when a single test creates several +databases and you care about the order they go away in. + +The embedded server is shared by every driver in the process and runs in memory. Nothing closes it +before the interpreter exits, so call `RavenTestDriver.stop_test_server()` from your runner's +teardown when you want that cost inside the run: + +```python +def pytest_sessionfinish(session, exitstatus): + RavenTestDriver.stop_test_server() +``` + ## Takeaway No server to manage in your tests: the driver runs one and gives each test its own database. To diff --git a/labs/02_embedded_per_test.py b/labs/02_embedded_per_test.py index e13b8f1..96ddab9 100644 --- a/labs/02_embedded_per_test.py +++ b/labs/02_embedded_per_test.py @@ -24,7 +24,19 @@ def main() -> None: with second.open_session() as session: assert session.load("people/1", dict) is None - print("Lab 02 OK: two stores, two isolated databases, no cross-test leakage.") + # You do not have to close the stores. Leaving the driver's `with` block closes whatever is + # still open and deletes those databases, so a test that throws halfway still cleans up. + with RavenTestDriver() as driver: + forgotten = driver.get_document_store() # no forgotten.close() anywhere + assert forgotten.database.startswith("test_"), forgotten.database + + assert driver.disposed + + # The server is shared by every driver in the process, and nothing closes it before the + # interpreter exits. Call this from your runner's teardown to keep that cost inside the run. + RavenTestDriver.stop_test_server() + + print("Lab 02 OK: two stores, two isolated databases, driver-level cleanup, server stopped.") if __name__ == "__main__": diff --git a/labs/03-seeding-indexes.md b/labs/03-seeding-indexes.md index d98f085..5e7562f 100644 --- a/labs/03-seeding-indexes.md +++ b/labs/03-seeding-indexes.md @@ -43,7 +43,23 @@ RavenDB indexes are updated asynchronously, so right after you write, an index q stale (empty) results. `wait_for_indexing()` blocks until no index is stale, making index-backed assertions deterministic instead of flaky. +## Configure the database itself + +`setup_database()` runs against a database that already exists. To change the database before it is +created, override `pre_configure_database()` and edit the `DatabaseRecord`: settings, revisions, +expiration, encryption and topology are all in reach. + +```python +class SeedingDriver(RavenTestDriver): + def pre_configure_database(self, database_record): # before the database is created + database_record.settings["Indexing.MapTimeoutInSec"] = "30" +``` + +`wait_for_indexing()` also waits for a side-by-side index deployment to finish, so a test that +redeploys an index definition queries the new one rather than the index it replaced. + ## Takeaway -`setup_database()` is the single place to seed data and register indexes for every test database; -`wait_for_indexing()` removes the race between writing and querying an index. +`pre_configure_database()` shapes the database, `setup_database()` is the single place to seed data +and register indexes for every test database, and `wait_for_indexing()` removes the race between +writing and querying an index. diff --git a/labs/03_seeding_indexes.py b/labs/03_seeding_indexes.py index adf7527..acd8c81 100644 --- a/labs/03_seeding_indexes.py +++ b/labs/03_seeding_indexes.py @@ -2,12 +2,14 @@ For: tests that need pre-seeded data and a defined index, and must wait for indexing to settle before asserting. Override setup_database() to seed and create the index for every database the -driver hands out; call wait_for_indexing() before querying so the assertion is not racing the -indexer. Boots the embedded server (needs .NET). +driver hands out, pre_configure_database() to change the database itself before it is created, +and call wait_for_indexing() before querying so the assertion is not racing the indexer. Boots +the embedded server (needs .NET). Run: python labs/03_seeding_indexes.py """ +from ravendb import GetDatabaseRecordOperation from ravendb.documents.indexes.abstract_index_creation_tasks import AbstractIndexCreationTask from ravendb_test_driver import RavenTestDriver @@ -26,6 +28,9 @@ def __init__(self): class SeedingDriver(RavenTestDriver): + def pre_configure_database(self, database_record) -> None: # before the database is created + database_record.settings["Indexing.MapTimeoutInSec"] = "30" + def setup_database(self, store) -> None: # runs for every database the driver creates store.execute_index(People_ByName()) with store.open_session() as session: @@ -36,12 +41,15 @@ def setup_database(self, store) -> None: # runs for every database the driver c def main() -> None: with SeedingDriver() as driver: with driver.get_document_store() as store: + record = store.maintenance.server.send(GetDatabaseRecordOperation(store.database)) + assert record.settings["Indexing.MapTimeoutInSec"] == "30" # set before creation + driver.wait_for_indexing(store) # block until the index is no longer stale with store.open_session() as session: hits = list(session.query_index_type(People_ByName, Person).where_equals("name", "Seeded")) assert len(hits) == 1 and hits[0].name == "Seeded", hits - print("Lab 03 OK: setup_database seeded data + index, wait_for_indexing settled, query returned it.") + print("Lab 03 OK: database pre-configured, data + index seeded, wait_for_indexing settled, query returned it.") if __name__ == "__main__": diff --git a/labs/05-secured-embedded.md b/labs/05-secured-embedded.md new file mode 100644 index 0000000..c8b280f --- /dev/null +++ b/labs/05-secured-embedded.md @@ -0,0 +1,45 @@ +# Lab 05: Secured embedded server, with client-certificate authentication + +**For:** tests that must run against HTTPS and a client certificate, without standing up a server +yourself. Point `ServerOptions.secured()` at a server certificate and the client certificate your +tests authenticate with; the driver passes that client material to every store it hands out. This +path boots the embedded server and needs a matching .NET (see the README). + +## Run it + +```bash +pip install ravendb-test-driver +python labs/05_secured_embedded.py +``` + +The complete example is [`05_secured_embedded.py`](05_secured_embedded.py). The part that matters +is three lines of configuration: + +```python +from ravendb_test_driver import RavenTestDriver, TestServerOptions + +options = TestServerOptions() +options.secured("server.pfx", "client.pem", ca_certificate_path="ca.crt") +RavenTestDriver.configure_server(options) + +with RavenTestDriver() as driver: + with driver.get_document_store() as store: # https, already authenticated + with store.open_session() as session: + session.store({"name": "John"}, "people/1") + session.save_changes() +``` + +- `server.pfx` is what the server presents. RavenDB requires it to carry the `DigitalSignature` + and `KeyEncipherment` key usages, and a matching subject alternative name. +- `client.pem` is what the tests authenticate with. It is required: a secured server the test + client cannot authenticate to is reported before the server starts. +- `ca_certificate_path` becomes the store's trust store, which a self-signed certificate needs. + +The lab generates all three into a temporary directory so it can run anywhere. A real suite points +at its own files instead. + +## Takeaway + +Securing the test server is a configuration change, not a code change: the stores the driver hands +you already carry the client certificate and the trust store. To attach to a secured server you run +yourself, see Lab 01 and `configure_external_server(url, certificate_pem_path=..., trust_store_path=...)`. diff --git a/labs/05_secured_embedded.py b/labs/05_secured_embedded.py new file mode 100644 index 0000000..96b1e41 --- /dev/null +++ b/labs/05_secured_embedded.py @@ -0,0 +1,110 @@ +"""Lab 05: Secured embedded server, with client-certificate authentication. + +For: tests that must run against HTTPS and a client certificate, without standing up a server +yourself. Point ServerOptions.secured() at a server certificate and the client certificate the +tests authenticate with; the driver passes that client material to every store it hands out, so +sessions work with no extra setup. Boots the embedded server and therefore needs a matching .NET. + +Run: python labs/05_secured_embedded.py +""" + +import datetime +import ipaddress +import tempfile +from pathlib import Path + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import pkcs12 +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + +from ravendb_test_driver import RavenTestDriver, TestServerOptions + + +def main() -> None: + with tempfile.TemporaryDirectory() as directory: + # In a real suite these are your own files; here they are generated so the lab is runnable. + server_pfx, client_pem, ca_certificate = self_signed_material(directory) + + options = TestServerOptions() + options.secured(server_pfx, client_pem, ca_certificate_path=ca_certificate) + RavenTestDriver.configure_server(options) + + with RavenTestDriver() as driver: + with driver.get_document_store() as store: + assert store.urls[0].startswith("https://"), store.urls + # The driver handed the server's client certificate to the store for you. + assert store.certificate_pem_path == client_pem + + with store.open_session() as session: + session.store({"name": "John"}, "people/1") + session.save_changes() + + with store.open_session() as session: + assert session.load("people/1", dict)["name"] == "John" + + RavenTestDriver.stop_test_server() + + print("Lab 05 OK: secured embedded server, authenticated session, isolated database.") + + +def self_signed_material(directory: str): + """Write a self-signed server certificate, a client PEM, and the CA to `directory`.""" + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]) + now = datetime.datetime.now(datetime.timezone.utc) + certificate = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=3650)) + .add_extension( + x509.SubjectAlternativeName([x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), + critical=False, + ) + .add_extension( + # RavenDB refuses a server certificate without DigitalSignature and KeyEncipherment. + x509.KeyUsage( + digital_signature=True, + key_encipherment=True, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension( + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH, ExtendedKeyUsageOID.CLIENT_AUTH]), + critical=False, + ) + .sign(key, hashes.SHA256()) + ) + + certificate_pem = certificate.public_bytes(serialization.Encoding.PEM) + key_pem = key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + ) + + server_pfx = Path(directory, "server.pfx") + client_pem = Path(directory, "client.pem") + ca_certificate = Path(directory, "ca.crt") + server_pfx.write_bytes( + pkcs12.serialize_key_and_certificates(b"localhost", key, certificate, None, serialization.NoEncryption()) + ) + client_pem.write_bytes(key_pem + certificate_pem) + ca_certificate.write_bytes(certificate_pem) + return str(server_pfx), str(client_pem), str(ca_certificate) + + +if __name__ == "__main__": + main() diff --git a/labs/README.md b/labs/README.md index 706bc3f..f8ad306 100644 --- a/labs/README.md +++ b/labs/README.md @@ -12,6 +12,7 @@ download the repository first, then run them from its root. | [02](02-embedded-per-test.md) | Embedded server, one isolated database per test (the default) | Yes | | [03](03-seeding-indexes.md) | Seed data and query an index (`setup_database`, `wait_for_indexing`) | Yes | | [04](04-embedded-no-dotnet.md) | Self-contained embedded server, downloaded and cached automatically | No | +| [05](05-secured-embedded.md) | Secured embedded server with client-certificate authentication | Yes | For lower-level server configuration, see the [`ravendb-python-embedded`](https://github.com/ravendb/ravendb-python-embedded) labs. diff --git a/ravendb_test_driver/__init__.py b/ravendb_test_driver/__init__.py index e06a8ea..b263a7d 100644 --- a/ravendb_test_driver/__init__.py +++ b/ravendb_test_driver/__init__.py @@ -1,2 +1,10 @@ +from ravendb_test_driver.errors import DriverCloseError from ravendb_test_driver.raven_test_driver import RavenTestDriver -from ravendb_test_driver.options import GetDocumentStoreOptions +from ravendb_test_driver.options import GetDocumentStoreOptions, TestServerOptions + +__all__ = [ + "DriverCloseError", + "GetDocumentStoreOptions", + "RavenTestDriver", + "TestServerOptions", +] diff --git a/ravendb_test_driver/errors.py b/ravendb_test_driver/errors.py new file mode 100644 index 0000000..d6f583c --- /dev/null +++ b/ravendb_test_driver/errors.py @@ -0,0 +1,17 @@ +from typing import Iterable, List + + +class DriverCloseError(RuntimeError): + """Raised when closing the driver hit one or more errors. + + Subclasses RuntimeError so existing `except RuntimeError` handlers keep working, and keeps + the original exceptions in `exceptions` instead of flattening them into a string, which is + what AggregateException.InnerExceptions gives the C# driver. + """ + + def __init__(self, exceptions: Iterable[BaseException]) -> None: + self.exceptions: List[BaseException] = list(exceptions) + super().__init__( + f"{len(self.exceptions)} error(s) while closing the test driver: " + + "; ".join(f"{type(e).__name__}: {e}" for e in self.exceptions) + ) diff --git a/ravendb_test_driver/options.py b/ravendb_test_driver/options.py index 9795d98..dced796 100644 --- a/ravendb_test_driver/options.py +++ b/ravendb_test_driver/options.py @@ -3,6 +3,27 @@ from datetime import timedelta from typing import Optional +from ravendb_embedded import ServerOptions + + +class TestServerOptions(ServerOptions): + """Embedded server options for a test run. + + The defaults a test server needs (in-memory storage, a scratch data directory, an empty + settings file) are applied by the driver to *any* `ServerOptions` it is given, right before + the server starts, so `configure_server` keeps accepting the base type and nobody has to + migrate. This subclass is the documented entry point for that intent, and the place where + test-only defaults that cannot be inferred from a plain `ServerOptions` will live. + """ + + # pytest collects Test*-named classes it finds in a test module's namespace, imported ones + # included, and warns about the __init__ it cannot construct. + __test__ = False + + def __init__(self) -> None: + super().__init__() + self.run_in_memory: bool = True + class GetDocumentStoreOptions: def __init__(self): diff --git a/ravendb_test_driver/raven_test_driver.py b/ravendb_test_driver/raven_test_driver.py index e4c87d1..f12bcb2 100644 --- a/ravendb_test_driver/raven_test_driver.py +++ b/ravendb_test_driver/raven_test_driver.py @@ -1,8 +1,13 @@ import atexit +import logging import os +import re import shutil +import sys import tempfile +import threading import time +import warnings import webbrowser from datetime import timedelta from typing import Optional, Dict, Any, Callable @@ -16,6 +21,7 @@ GetIndexErrorsOperation, ) from ravendb.documents.indexes.definitions import IndexState +from ravendb.exceptions.cluster import NoLoaderException from ravendb.exceptions.exceptions import ( DatabaseDoesNotExistException, TimeoutException, @@ -26,13 +32,21 @@ from ravendb.serverwide.operations.common import DeleteDatabaseOperation from ravendb_embedded import EmbeddedServer, ServerOptions -from ravendb_test_driver.options import GetDocumentStoreOptions +from ravendb_test_driver.errors import DriverCloseError +from ravendb_test_driver.options import GetDocumentStoreOptions, TestServerOptions + +_LOGGER = logging.getLogger(__name__) + +_WAIT_FOR_USER_ENVIRONMENT_VARIABLE = "RAVENDB_TEST_WAIT_FOR_USER" +_UNIQUE_DATABASE_NAMES_ENVIRONMENT_VARIABLE = "RAVENDB_TEST_UNIQUE_DB_NAMES" +_FALSY_ENVIRONMENT_VALUES = frozenset({"0", "false", "no", "off"}) class RavenTestDriver: + use_caller_name_for_database: bool = False + _TEST_SERVER: EmbeddedServer = EmbeddedServer() - _TEST_SERVER_STORE: Lazy[DocumentStore] = Lazy(lambda: RavenTestDriver.run_server()) - _INDEX = 0 + _TEST_SERVER_STORE: Lazy[DocumentStore] = Lazy(lambda: RavenTestDriver._run_server()) _GLOBAL_SERVER_OPTIONS: Optional[ServerOptions] = None _EMPTY_SETTINGS_FILE_NAME: Optional[str] = None _EXTERNAL_SERVER_URL: Optional[str] = None @@ -50,21 +64,29 @@ def __enter__(self) -> "RavenTestDriver": def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: self.close() + @staticmethod + def _remove_empty_settings_file(path: str) -> None: + try: + os.unlink(path) + except OSError: + pass # already gone, or held by something else + @staticmethod def _get_empty_settings_file() -> str: if not RavenTestDriver._EMPTY_SETTINGS_FILE_NAME: - temp_file = tempfile.NamedTemporaryFile(delete=False, prefix="settings-", suffix=".json") - temp_file.write(b"{}") - temp_file.close() + with tempfile.NamedTemporaryFile(delete=False, prefix="settings-", suffix=".json") as temp_file: + temp_file.write(b"{}") RavenTestDriver._EMPTY_SETTINGS_FILE_NAME = temp_file.name + # Registered before the server's own atexit hook, so it runs after it (LIFO). + atexit.register(RavenTestDriver._remove_empty_settings_file, temp_file.name) return RavenTestDriver._EMPTY_SETTINGS_FILE_NAME @staticmethod def configure_server(options: ServerOptions) -> None: if RavenTestDriver._TEST_SERVER_STORE.is_value_created: raise RuntimeError( - "Cannot configure server after it was started. " - "Please call 'configureServer' method before any 'getDocumentStore' is called." + "Cannot configure the server after it was started. " + "Call 'configure_server' before any 'get_document_store'." ) RavenTestDriver._GLOBAL_SERVER_OPTIONS = options @@ -94,20 +116,22 @@ def get_document_store( options: Optional[GetDocumentStoreOptions] = None, database: Optional[str] = None, ) -> DocumentStore: - database = database or "test" options = options or GetDocumentStoreOptions() - self._INDEX += 1 - name = f"{database}_{self._INDEX}" + name = self._next_database_name(database) document_store = self._TEST_SERVER_STORE.value - create_database_operation = CreateDatabaseOperation(DatabaseRecord(name)) + database_record = DatabaseRecord(name) + self.pre_configure_database(database_record) + + create_database_operation = CreateDatabaseOperation(database_record) document_store.maintenance.server.send(create_database_operation) - store = DocumentStore(document_store.urls, name) - if document_store.certificate_pem_path: - store.certificate_pem_path = document_store.certificate_pem_path - if document_store.trust_store_path: - store.trust_store_path = document_store.trust_store_path + store = self._store_with_credentials( + document_store.urls, + name, + document_store.certificate_pem_path, + document_store.trust_store_path, + ) self.pre_initialize(store) store.initialize() @@ -118,51 +142,115 @@ def __close_event_callback(): except KeyError: return - try: - store.maintenance.server.send(DeleteDatabaseOperation(store.database, True)) - except DatabaseDoesNotExistException: - pass # ignore + # The record's name, not store.database: pre_configure_database may rename it. + self._delete_test_database(store, database_record.database_name) store.add_after_close(__close_event_callback) self.setup_database(store) - if options.wait_for_indexing_timeout: + if options.wait_for_indexing_timeout is not None: self.wait_for_indexing(store, name, options.wait_for_indexing_timeout) self._document_stores[store] = True return store + @staticmethod + def _delete_test_database(store: DocumentStore, database_name: str) -> None: + """Hard-delete a test database, ignoring the failures that are not the test's problem.""" + try: + store.maintenance.server.send(DeleteDatabaseOperation(database_name, True)) + except (DatabaseDoesNotExistException, NoLoaderException): + pass # already gone, or the cluster has no leader right now + except RavenException as e: + # The client maps NoLeaderException under a misspelled key, so it arrives untyped. + if "NoLeaderException" not in str(e): + raise + + @staticmethod + def _database_stem(frame_name: str) -> Optional[str]: + # CPython wraps every synthetic code-object name in angle brackets (, , + # , and the comprehensions before 3.12 inlined them). No identifier can. + if frame_name.startswith("<"): + return None + + return re.sub(r"[^A-Za-z0-9_.-]", "_", frame_name) or None + + @staticmethod + def _caller_name() -> Optional[str]: + """The calling test's name, C#'s [CallerMemberName] equivalent. + + Walks out of this module instead of counting frames, so adding a driver-internal call + cannot silently rename every database. sys._getframe, not inspect.stack(): the latter + costs milliseconds per call. + """ + frame = sys._getframe(1) + while frame is not None and frame.f_globals.get("__name__") == __name__: + frame = frame.f_back + + return RavenTestDriver._database_stem(frame.f_code.co_name) if frame is not None else None + + @staticmethod + def _environment_flag(name: str, default: bool = False) -> bool: + value = os.environ.get(name, "").strip().lower() + if not value: + return default + return value not in _FALSY_ENVIRONMENT_VALUES + + _DATABASE_COUNTER = 0 + _DATABASE_COUNTER_LOCK = threading.Lock() + + @staticmethod + def _next_database_number() -> int: + with RavenTestDriver._DATABASE_COUNTER_LOCK: + RavenTestDriver._DATABASE_COUNTER += 1 + return RavenTestDriver._DATABASE_COUNTER + + @classmethod + def _next_database_name(cls, database: Optional[str] = None) -> str: + stem = database + if stem is None and cls.use_caller_name_for_database: + stem = cls._caller_name() + + parts = [stem or "test"] + if cls._environment_flag(_UNIQUE_DATABASE_NAMES_ENVIRONMENT_VARIABLE): + # The counter restarts per process, so runners sharing a server would collide. + parts.append(str(os.getpid())) + parts.append(str(cls._next_database_number())) + + return "_".join(parts) + def pre_initialize(self, document_store: DocumentStore) -> None: - pass # empty by design + """Override to configure the store before initialize() runs on it. Does nothing here.""" + + def pre_configure_database(self, database_record: DatabaseRecord) -> None: + """Override to change the record before the database is created. Does nothing here.""" def setup_database(self, document_store: DocumentStore) -> None: - pass # empty by design + """Override to create indexes or seed data on the initialized store. Does nothing here.""" @staticmethod def wait_for_indexing( store: DocumentStore, database: Optional[str] = None, - timeout: Optional[timedelta] = None, + timeout: timedelta = timedelta(seconds=60), ) -> None: - database = database or None - timeout = timeout or timedelta(seconds=60) # Default timeout admin = store.maintenance.for_database(database) start_time = time.monotonic() while time.monotonic() - start_time < timeout.total_seconds(): database_statistics = admin.send(GetStatisticsOperation()) - stale = [ + # A replacement index holds the wait: until the swap lands, queries hit the old one. + pending = [ x for x in database_statistics.indexes if x.state != IndexState.DISABLED - and x.stale - and not x.name.startswith(Documents.Indexing.SIDE_BY_SIDE_INDEX_NAME_PREFIX) + and (x.stale or x.name.startswith(Documents.Indexing.SIDE_BY_SIDE_INDEX_NAME_PREFIX)) ] - if not stale: + if not pending: return if any(index.state == IndexState.ERROR for index in database_statistics.indexes): @@ -181,9 +269,20 @@ def wait_for_indexing( else "" ) - raise TimeoutException(f"The indexes stayed stale for more than {timeout} seconds. {all_index_errors_text}") + raise TimeoutException(f"The indexes stayed stale for more than {timeout}. {all_index_errors_text}") + + def wait_for_user_to_continue_the_test( + self, + store: DocumentStore, + timeout: Optional[timedelta] = None, + ) -> None: + """Open Studio and block until a 'Debug/Done' document shows up in this database. + + Unbounded unless `timeout` is given. RAVENDB_TEST_WAIT_FOR_USER=0/false/no/off skips it. + """ + if not self._environment_flag(_WAIT_FOR_USER_ENVIRONMENT_VARIABLE, default=True): + return - def wait_for_user_to_continue_the_test(self, store: DocumentStore) -> None: database_name_encoded = quote(store.database, safe="") documents_page = ( f"{store.urls[0]}/studio/index.html#databases/documents?&database={database_name_encoded}&withStop=true" @@ -191,108 +290,241 @@ def wait_for_user_to_continue_the_test(self, store: DocumentStore) -> None: self.open_browser(documents_page) + start_time = time.monotonic() while True: + if timeout is not None and time.monotonic() - start_time >= timeout.total_seconds(): + raise TimeoutException( + f"No 'Debug/Done' document showed up in '{store.database}' within {timeout}. " + "Store a document with that id to continue the test, pass timeout=None to wait " + f"forever, or set {_WAIT_FOR_USER_ENVIRONMENT_VARIABLE}=0 to skip this wait." + ) + time.sleep(0.5) with store.open_session() as session: - if session.load("Debug/Done", dict): + # Deleted after the wait, so a later one cannot return on a stale marker. + if session.advanced.exists("Debug/Done"): + session.delete("Debug/Done") + session.save_changes() break - @staticmethod - def open_browser(url: str) -> None: + def open_browser(self, url: str) -> None: print(url) try: - webbrowser.open(url) + opened = webbrowser.open(url) except Exception as e: - raise RuntimeError() from e + raise RuntimeError(f"Failed to open a browser at {url}") from e + + if not opened: + # Headless machines return False rather than raising; the wait still works. + print("No browser could be opened here; use the URL above.") def close(self) -> None: - if getattr(self, "disposed", False): + if self.disposed: return + self.disposed = True exceptions = [] - for document_store in self._document_stores: + # Snapshot: each store's after-close callback pops itself out of this dict. + for document_store in list(self._document_stores): try: document_store.close() except Exception as e: exceptions.append(e) - self.disposed = True - if self.on_driver_closed: - self.on_driver_closed(self) + # Collected, so a raising callback cannot discard the store-close errors. + try: + self.on_driver_closed(self) + except Exception as e: + exceptions.append(e) if exceptions: - raise RuntimeError(", ".join(map(str, exceptions))) + raise DriverCloseError(exceptions) @staticmethod - def cleanup_temp_dirs(*dirs: str) -> None: - try: - for i in range(30): - any_failure = False - for dir_ in dirs: - if os.path.exists(dir_): - if not shutil.rmtree(dir_, ignore_errors=True): - any_failure = True - if not any_failure: - return - time.sleep(0.2) - except Exception: - pass + def _cleanup_temp_dirs(*dirs: str) -> None: + for _ in range(30): + any_failure = False + for dir_ in dirs: + if not os.path.exists(dir_): + continue + # rmtree returns None; the directory still being there is the only signal. + shutil.rmtree(dir_, ignore_errors=True) + if os.path.exists(dir_): + any_failure = True + if not any_failure: + return + time.sleep(0.2) @staticmethod - def default_server_options() -> ServerOptions: - options = ServerOptions() + def _default_server_options() -> ServerOptions: + return RavenTestDriver._normalize_test_server_options(TestServerOptions()) - data_dir = tempfile.mkdtemp() - logs_dir = tempfile.mkdtemp() + @staticmethod + def _normalize_test_server_options(options: ServerOptions) -> ServerOptions: + """Give any ServerOptions the defaults a test server needs. - options.data_directory = data_dir - options.logs_path = logs_dir + Idempotent, and it never overrides a value the caller set explicitly. + """ + security = options.security + if security is not None and not security.client_pem_certificate_path: + raise RavenException( + "A secured test server needs a client certificate the test client can " + "authenticate with. Pass client_pem_certificate_path to ServerOptions.secured()." + ) - def cleanup_temp_dirs() -> None: - RavenTestDriver.cleanup_temp_dirs(data_dir, logs_dir) + # A local copy: the caller's list is theirs, and there is more than one writer now. + command_line_args = list(options.command_line_args) - atexit.register(cleanup_temp_dirs) + settings_file = RavenTestDriver._get_empty_settings_file() + if settings_file not in command_line_args: + command_line_args[:0] = ["-c", settings_file] - return options + if getattr(options, "run_in_memory", True) and not any( + arg.startswith("--RunInMemory") for arg in command_line_args + ): + command_line_args.append("--RunInMemory=true") - @classmethod - def run_server(cls) -> DocumentStore: - external_url = cls._EXTERNAL_SERVER_URL or os.environ.get("RAVENDB_TEST_SERVER_URL") - if external_url: - # Attach to an existing server; do not boot the embedded one (no .NET needed). - certificate = cls._EXTERNAL_SERVER_CERT or os.environ.get("RAVENDB_TEST_SERVER_CERT") - trust_store = cls._EXTERNAL_SERVER_TRUST_STORE or os.environ.get("RAVENDB_TEST_SERVER_CA") - if external_url.lower().startswith("https") and not certificate: - raise RavenException( - f"Attaching to a secured server ({external_url}) needs a client certificate; pass " - "configure_external_server(url, certificate_pem_path=...) or set RAVENDB_TEST_SERVER_CERT." - ) - store = DocumentStore(external_url, None) - if certificate: - store.certificate_pem_path = certificate - if trust_store: - store.trust_store_path = trust_store - store.initialize() - return store + options.command_line_args = command_line_args - try: - options = RavenTestDriver._GLOBAL_SERVER_OPTIONS or RavenTestDriver.default_server_options() + # The embedded default sits inside the installed package. Logs follow the data directory. + if options.data_directory == ServerOptions._DEFAULT_DATA_DIRECTORY: + data_directory = tempfile.mkdtemp(prefix="ravendb-test-driver-") + options.data_directory = data_directory + atexit.register(RavenTestDriver._cleanup_temp_dirs, data_directory) + _LOGGER.info("Test server data and logs redirected to %s", data_directory) - command_line_args = options.command_line_args + return options - command_line_args.insert(0, "-c") - command_line_args.insert(1, RavenTestDriver._get_empty_settings_file()) - except Exception as e: - raise RavenException(f"Unable to start server: {e}") + @staticmethod + def _resolve_external_server_url() -> Optional[str]: + """Explicit configuration beats the environment, which could otherwise redirect a suite + onto a server where the driver creates and hard-deletes databases. + """ + if RavenTestDriver._EXTERNAL_SERVER_URL: + return RavenTestDriver._EXTERNAL_SERVER_URL + + environment_url = os.environ.get("RAVENDB_TEST_SERVER_URL") + if environment_url and RavenTestDriver._GLOBAL_SERVER_OPTIONS is not None: + warnings.warn( + f"Ignoring RAVENDB_TEST_SERVER_URL={environment_url!r} because configure_server() " + "was called explicitly. Drop that call to attach to the server from the " + "environment; the driver creates and hard-deletes databases on whichever server " + "it ends up using.", + stacklevel=2, + ) + return None - cls._TEST_SERVER.start_server(options) + return environment_url - url = cls._TEST_SERVER.get_server_uri() + @staticmethod + def _store_with_credentials( + urls, + database: Optional[str], + certificate_pem_path: Optional[str], + trust_store_path: Optional[str], + ) -> DocumentStore: + """Every store the driver hands out carries the credentials of the server it talks to.""" + store = DocumentStore(urls, database) + if certificate_pem_path: + store.certificate_pem_path = certificate_pem_path + if trust_store_path: + store.trust_store_path = trust_store_path + return store - store = DocumentStore(url, None) + @staticmethod + def _attach_to_external_server(url: str) -> DocumentStore: + """Build a store against a server somebody else runs. Nothing is booted, so no .NET is needed.""" + certificate = RavenTestDriver._EXTERNAL_SERVER_CERT or os.environ.get("RAVENDB_TEST_SERVER_CERT") + trust_store = RavenTestDriver._EXTERNAL_SERVER_TRUST_STORE or os.environ.get("RAVENDB_TEST_SERVER_CA") + + if url.lower().startswith("https") and not certificate: + raise RavenException( + f"Attaching to a secured server ({url}) needs a client certificate; pass " + "configure_external_server(url, certificate_pem_path=...) or set RAVENDB_TEST_SERVER_CERT." + ) + store = RavenTestDriver._store_with_credentials(url, None, certificate, trust_store) store.initialize() + return store + + @staticmethod + def _boot_embedded_server() -> DocumentStore: + try: + options = RavenTestDriver._GLOBAL_SERVER_OPTIONS or TestServerOptions() + RavenTestDriver._normalize_test_server_options(options) + except RavenException: + raise # already explains itself; a second wrapper would only hide it + except Exception as e: + # Only option preparation is wrapped; start_server raises the embedded layer's error. + raise RavenException(f"Unable to prepare the test server options: {e}", e) from e + + RavenTestDriver._TEST_SERVER.start_server(options) + store = RavenTestDriver._store_with_credentials( + RavenTestDriver._TEST_SERVER.get_server_uri(), + None, + RavenTestDriver._TEST_SERVER.client_pem_certificate_path, + RavenTestDriver._TEST_SERVER.trust_store_path, + ) + store.initialize() return store + + @staticmethod + def _run_server() -> DocumentStore: + external_url = RavenTestDriver._resolve_external_server_url() + if external_url: + return RavenTestDriver._attach_to_external_server(external_url) + + return RavenTestDriver._boot_embedded_server() + + @classmethod + def stop_test_server(cls) -> None: + """Close the shared test server and its server-level store. + + Nothing else closes them, so without this the cost lands at interpreter exit. + Idempotent, and the server can be started again afterwards. + """ + lazy = RavenTestDriver._TEST_SERVER_STORE + if lazy.is_value_created: + try: + lazy.value.close() + finally: + RavenTestDriver._TEST_SERVER_STORE = Lazy(lambda: RavenTestDriver._run_server()) + + RavenTestDriver._TEST_SERVER.close() + + @classmethod + def _reset_server_configuration(cls) -> None: + """Forget configure_server / configure_external_server, without touching the server.""" + RavenTestDriver._GLOBAL_SERVER_OPTIONS = None + RavenTestDriver._EXTERNAL_SERVER_URL = None + RavenTestDriver._EXTERNAL_SERVER_CERT = None + RavenTestDriver._EXTERNAL_SERVER_TRUST_STORE = None + + # Never meant to be public (private in the JVM driver, absent in C#). Kept for one release. + + @staticmethod + def _deprecated_alias(old: str, new: str) -> None: + warnings.warn( + f"RavenTestDriver.{old}() is internal and will be removed in a future release; " + f"use {new}() if you really need it.", + DeprecationWarning, + stacklevel=3, + ) + + @staticmethod + def run_server() -> DocumentStore: + RavenTestDriver._deprecated_alias("run_server", "_run_server") + return RavenTestDriver._run_server() + + @staticmethod + def default_server_options() -> ServerOptions: + RavenTestDriver._deprecated_alias("default_server_options", "_default_server_options") + return RavenTestDriver._default_server_options() + + @staticmethod + def cleanup_temp_dirs(*dirs: str) -> None: + RavenTestDriver._deprecated_alias("cleanup_temp_dirs", "_cleanup_temp_dirs") + RavenTestDriver._cleanup_temp_dirs(*dirs) diff --git a/setup.py b/setup.py index 4a97f94..ea26fab 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name="ravendb-test-driver", packages=find_packages(exclude=["*.tests.*", "tests", "*.tests", "tests.*"]), - version="7.2.5.post2", + version="7.2.5.post3", description="RavenDB package for writing integration tests against RavenDB server", long_description_content_type="text/markdown", long_description=open("README.md").read(), diff --git a/tests/support.py b/tests/support.py new file mode 100644 index 0000000..fac4b98 --- /dev/null +++ b/tests/support.py @@ -0,0 +1,102 @@ +"""Shared test helpers: self-signed certificate material and global driver state reset.""" + +import datetime +import ipaddress +import os +from pathlib import Path +from unittest.mock import patch + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import pkcs12 +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + +from ravendb_test_driver import RavenTestDriver + +ENVIRONMENT_NAMES = ("RAVENDB_TEST_SERVER_URL", "RAVENDB_TEST_SERVER_CERT", "RAVENDB_TEST_SERVER_CA") + + +def attach_mode_is_active() -> bool: + """True when the suite is pointed at a server it does not own, so embedded tests must skip.""" + return bool(RavenTestDriver._EXTERNAL_SERVER_URL or os.environ.get("RAVENDB_TEST_SERVER_URL")) + + +def isolate_environment(test) -> None: + """Restore os.environ exactly as it was once `test` finishes.""" + patcher = patch.dict(os.environ) + patcher.start() + test.addCleanup(patcher.stop) + + +def reset_driver() -> None: + """Put the shared server and its configuration back to a pristine state.""" + RavenTestDriver.stop_test_server() + RavenTestDriver._reset_server_configuration() + for name in ENVIRONMENT_NAMES: + os.environ.pop(name, None) + + +def certificates(directory): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]) + now = datetime.datetime.now(datetime.timezone.utc) + certificate = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=3650)) + .add_extension( + x509.SubjectAlternativeName( + [ + x509.DNSName("localhost"), + x509.IPAddress(ipaddress.ip_address("127.0.0.1")), + ] + ), + critical=False, + ) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=True, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension( + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH, ExtendedKeyUsageOID.CLIENT_AUTH]), + critical=False, + ) + .sign(key, hashes.SHA256()) + ) + certificate_pem = certificate.public_bytes(serialization.Encoding.PEM) + key_pem = key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + ) + + server_pfx = Path(directory, "server.pfx") + client_pem = Path(directory, "client.pem") + ca_certificate = Path(directory, "ca.crt") + server_pfx.write_bytes( + pkcs12.serialize_key_and_certificates( + b"localhost", + key, + certificate, + None, + serialization.NoEncryption(), + ) + ) + client_pem.write_bytes(key_pem + certificate_pem) + ca_certificate.write_bytes(certificate_pem) + return str(server_pfx), str(client_pem), str(ca_certificate) diff --git a/tests/test_a_secured_attach.py b/tests/test_a_secured_attach.py index 4ea90c3..f500352 100644 --- a/tests/test_a_secured_attach.py +++ b/tests/test_a_secured_attach.py @@ -1,102 +1,20 @@ -import datetime -import ipaddress import os import tempfile from pathlib import Path from unittest import TestCase -from cryptography import x509 -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.hazmat.primitives.serialization import pkcs12 -from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID -from ravendb import Lazy from ravendb.exceptions.raven_exceptions import RavenException from ravendb_embedded import EmbeddedServer, ServerOptions from ravendb_test_driver import RavenTestDriver - - -def _certificates(directory): - key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]) - now = datetime.datetime.now(datetime.timezone.utc) - certificate = ( - x509.CertificateBuilder() - .subject_name(name) - .issuer_name(name) - .public_key(key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now - datetime.timedelta(days=1)) - .not_valid_after(now + datetime.timedelta(days=3650)) - .add_extension( - x509.SubjectAlternativeName( - [ - x509.DNSName("localhost"), - x509.IPAddress(ipaddress.ip_address("127.0.0.1")), - ] - ), - critical=False, - ) - .add_extension( - x509.KeyUsage( - digital_signature=True, - key_encipherment=True, - content_commitment=False, - data_encipherment=False, - key_agreement=False, - key_cert_sign=False, - crl_sign=False, - encipher_only=False, - decipher_only=False, - ), - critical=True, - ) - .add_extension( - x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH, ExtendedKeyUsageOID.CLIENT_AUTH]), - critical=False, - ) - .sign(key, hashes.SHA256()) - ) - certificate_pem = certificate.public_bytes(serialization.Encoding.PEM) - key_pem = key.private_bytes( - serialization.Encoding.PEM, - serialization.PrivateFormat.TraditionalOpenSSL, - serialization.NoEncryption(), - ) - - server_pfx = Path(directory, "server.pfx") - client_pem = Path(directory, "client.pem") - ca_certificate = Path(directory, "ca.crt") - server_pfx.write_bytes( - pkcs12.serialize_key_and_certificates( - b"localhost", - key, - certificate, - None, - serialization.NoEncryption(), - ) - ) - client_pem.write_bytes(key_pem + certificate_pem) - ca_certificate.write_bytes(certificate_pem) - return str(server_pfx), str(client_pem), str(ca_certificate) +from tests.support import certificates, isolate_environment, reset_driver class TestSecuredAttach(TestCase): - _ENV_NAMES = ("RAVENDB_TEST_SERVER_URL", "RAVENDB_TEST_SERVER_CERT", "RAVENDB_TEST_SERVER_CA") - - @staticmethod - def _reset_driver(): - lazy = RavenTestDriver._TEST_SERVER_STORE - if lazy.is_value_created: - lazy.value.close() - RavenTestDriver._EXTERNAL_SERVER_URL = None - RavenTestDriver._EXTERNAL_SERVER_CERT = None - RavenTestDriver._EXTERNAL_SERVER_TRUST_STORE = None - RavenTestDriver._TEST_SERVER_STORE = Lazy(lambda: RavenTestDriver.run_server()) - RavenTestDriver._INDEX = 0 - for name in TestSecuredAttach._ENV_NAMES: - os.environ.pop(name, None) + def setUp(self): + isolate_environment(self) + reset_driver() + self.addCleanup(reset_driver) def _write_and_read(self, database): with RavenTestDriver() as driver: @@ -108,9 +26,8 @@ def _write_and_read(self, database): self.assertEqual(database, session.load("people/1", dict)["name"]) def test_api_and_environment_credentials_reach_database_store(self): - original_environment = {name: os.environ.get(name) for name in self._ENV_NAMES} with tempfile.TemporaryDirectory() as directory: - server_pfx, client_pem, ca_certificate = _certificates(directory) + server_pfx, client_pem, ca_certificate = certificates(directory) options = ServerOptions() options.secured( server_pfx, @@ -120,37 +37,25 @@ def test_api_and_environment_credentials_reach_database_store(self): options.data_directory = str(Path(directory, "data")) options.logs_path = str(Path(directory, "logs")) - try: - with EmbeddedServer() as server: - server.start_server(options) + with EmbeddedServer() as server: + server.start_server(options) + + RavenTestDriver.configure_external_server( + server.get_server_uri(), + certificate_pem_path=client_pem, + trust_store_path=ca_certificate, + ) + self._write_and_read("configured") - RavenTestDriver.configure_external_server( - server.get_server_uri(), - certificate_pem_path=client_pem, - trust_store_path=ca_certificate, - ) - self._write_and_read("configured") - self._reset_driver() + reset_driver() - os.environ["RAVENDB_TEST_SERVER_URL"] = server.get_server_uri() - os.environ["RAVENDB_TEST_SERVER_CERT"] = client_pem - os.environ["RAVENDB_TEST_SERVER_CA"] = ca_certificate - self._write_and_read("environment") - finally: - self._reset_driver() - for name, value in original_environment.items(): - if value is not None: - os.environ[name] = value + os.environ["RAVENDB_TEST_SERVER_URL"] = server.get_server_uri() + os.environ["RAVENDB_TEST_SERVER_CERT"] = client_pem + os.environ["RAVENDB_TEST_SERVER_CA"] = ca_certificate + self._write_and_read("environment") def test_https_attach_requires_a_client_certificate(self): - original_environment = {name: os.environ.get(name) for name in self._ENV_NAMES} - self._reset_driver() - try: - RavenTestDriver.configure_external_server("https://127.0.0.1:1") - with self.assertRaisesRegex(RavenException, "needs a client certificate"): - RavenTestDriver.run_server() - finally: - self._reset_driver() - for name, value in original_environment.items(): - if value is not None: - os.environ[name] = value + RavenTestDriver.configure_external_server("https://127.0.0.1:1") + + with self.assertRaisesRegex(RavenException, "needs a client certificate"): + RavenTestDriver._run_server() diff --git a/tests/test_attach.py b/tests/test_attach.py index c1ac567..7c30588 100644 --- a/tests/test_attach.py +++ b/tests/test_attach.py @@ -1,31 +1,77 @@ -"""Attach path (no embedded boot, no .NET): point RAVENDB_TEST_SERVER_URL at a running server. +"""Attach path: the driver talks to a server it does not own and does not boot. -Skips when unset, unless RAVENDB_TEST_REQUIRE_ATTACH=1 (CI) makes a missing URL fail loudly. +Where the server comes from, in order: + 1. RAVENDB_TEST_SERVER_URL, which is how CI runs this with no .NET installed at all. + 2. A second embedded server this module starts itself, so the attach path is covered on a + developer machine without Docker. That fallback needs .NET, which the attach mode itself + does not; only the URL is borrowed from it. +Skipped when neither is available, unless RAVENDB_TEST_REQUIRE_ATTACH=1 (CI) makes it fail loudly. """ import os from unittest import TestCase -from ravendb_test_driver import RavenTestDriver +from ravendb_embedded import EmbeddedServer + +from ravendb_test_driver import RavenTestDriver, TestServerOptions +from tests.support import isolate_environment, reset_driver -SERVER_URL = os.environ.get("RAVENDB_TEST_SERVER_URL") _REQUIRE = os.environ.get("RAVENDB_TEST_REQUIRE_ATTACH") == "1" class TestAttachToExternalServer(TestCase): + server_url = None + _owned_server = None + + @classmethod + def setUpClass(cls): + cls.server_url = os.environ.get("RAVENDB_TEST_SERVER_URL") + if cls.server_url: + return + + if _REQUIRE: + raise AssertionError("set RAVENDB_TEST_SERVER_URL to a running server to run the attach test") + + reset_driver() + try: + cls._owned_server = EmbeddedServer() + cls._owned_server.start_server(RavenTestDriver._normalize_test_server_options(TestServerOptions())) + cls.server_url = cls._owned_server.get_server_uri() + except Exception as e: # no .NET runtime, or the server refused to start + cls._owned_server = None + raise TestCase.skipTest(cls, f"no server to attach to: {e}") from e + + @classmethod + def tearDownClass(cls): + if cls._owned_server is not None: + cls._owned_server.close() + cls._owned_server = None + def setUp(self): - if not SERVER_URL: - message = "set RAVENDB_TEST_SERVER_URL to a running server to run the attach test" - if _REQUIRE: - self.fail(message) - self.skipTest(message) + isolate_environment(self) + reset_driver() + self.addCleanup(reset_driver) + os.environ["RAVENDB_TEST_SERVER_URL"] = self.server_url def test_attach_gives_isolated_database(self): driver = RavenTestDriver() with driver.get_document_store() as store: - self.assertIn(SERVER_URL.rstrip("/"), store.urls[0]) + self.assertIn(self.server_url.rstrip("/"), store.urls[0]) with store.open_session() as session: session.store({"name": "attached"}, "people/1") session.save_changes() with store.open_session() as session: self.assertEqual("attached", session.load("people/1", dict)["name"]) + + def test_the_attached_server_keeps_running_when_the_driver_closes(self): + # The driver owns the databases it creates, never the server somebody else runs. + first = RavenTestDriver() + with first.get_document_store() as store: + database = store.database + + RavenTestDriver.stop_test_server() + + second = RavenTestDriver() + with second.get_document_store() as store: + self.assertNotEqual(database, store.database) + self.assertIn(self.server_url.rstrip("/"), store.urls[0]) diff --git a/tests/test_basic.py b/tests/test_basic.py index 91a74b0..0549663 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -49,7 +49,6 @@ def setup_database(self, document_store) -> None: class TestSetupDatabaseHook(TestCase): def test_setup_database_hook_seeds_new_store(self): - # The driver's setup_database hook should run for each store it hands out. driver = _SeedingTestDriver() with driver.get_document_store() as store: with store.open_session() as session: diff --git a/tests/test_database_naming.py b/tests/test_database_naming.py new file mode 100644 index 0000000..381c715 --- /dev/null +++ b/tests/test_database_naming.py @@ -0,0 +1,75 @@ +"""Database-name generation: the caller-name opt-in, per-process uniqueness, and the counter.""" + +import os +from unittest import TestCase + +from ravendb_test_driver import RavenTestDriver +from tests.support import isolate_environment + + +class _CallerNameDriver(RavenTestDriver): + use_caller_name_for_database = True + + +class TestCallerNameOptIn(TestCase): + def test_is_off_by_default(self): + self.assertFalse(RavenTestDriver.use_caller_name_for_database) + self.assertTrue(RavenTestDriver()._next_database_name(None).startswith("test_")) + + def test_uses_the_calling_test_name_when_switched_on(self): + name = _CallerNameDriver()._next_database_name(None) + + self.assertTrue(name.startswith("test_uses_the_calling_test_name_when_switched_on_"), name) + + def test_an_explicit_database_still_wins(self): + name = _CallerNameDriver()._next_database_name("chosen") + + self.assertTrue(name.startswith("chosen_"), name) + + def test_synthetic_frame_names_fall_back_to_test(self): + driver = _CallerNameDriver() + name = (lambda: driver._next_database_name(None))() + + # co_name is here, which is not a usable database name. + self.assertTrue(name.startswith("test_"), name) + + def test_illegal_characters_are_replaced(self): + self.assertEqual("weird_name_with_chars", RavenTestDriver._database_stem("weird name/with:chars")) + + def test_synthetic_names_have_no_stem(self): + self.assertIsNone(RavenTestDriver._database_stem("")) + + +class TestPerProcessUniqueness(TestCase): + def setUp(self): + isolate_environment(self) + + def test_is_off_by_default(self): + os.environ.pop("RAVENDB_TEST_UNIQUE_DB_NAMES", None) + + name = RavenTestDriver()._next_database_name("stem") + + self.assertNotIn(str(os.getpid()), name) + + def test_adds_the_process_id_when_switched_on(self): + os.environ["RAVENDB_TEST_UNIQUE_DB_NAMES"] = "1" + + name = RavenTestDriver()._next_database_name("stem") + + self.assertTrue(name.startswith(f"stem_{os.getpid()}_"), name) + + def test_falsy_values_leave_it_off(self): + for value in ("0", "false", "no", "off", ""): + with self.subTest(value=value): + os.environ["RAVENDB_TEST_UNIQUE_DB_NAMES"] = value + + self.assertNotIn(str(os.getpid()), RavenTestDriver()._next_database_name("stem")) + + +class TestDatabaseNameCounter(TestCase): + def test_counter_is_process_wide_and_monotonic(self): + first = RavenTestDriver()._next_database_name("stem") + second = RavenTestDriver()._next_database_name("stem") + + self.assertNotEqual(first, second) + self.assertLess(int(first.rsplit("_", 1)[1]), int(second.rsplit("_", 1)[1])) diff --git a/tests/test_driver_lifecycle.py b/tests/test_driver_lifecycle.py new file mode 100644 index 0000000..d0c38b4 --- /dev/null +++ b/tests/test_driver_lifecycle.py @@ -0,0 +1,205 @@ +"""Regressions around driver lifecycle, database-name allocation, indexing waits and cleanup. + +None of these paths had coverage before, which is why the defects survived: every other test +and lab closes its store in a nested `with` block and never closes the driver itself. +""" + +import os +import tempfile +import time +from datetime import timedelta +from pathlib import Path +from types import SimpleNamespace +from unittest import TestCase + +from ravendb import GetIndexErrorsOperation, GetDatabaseRecordOperation +from ravendb.documents.indexes.definitions import IndexState +from ravendb.documents.operations.statistics import IndexInformation +from ravendb.exceptions.exceptions import TimeoutException + +from ravendb_test_driver import DriverCloseError, RavenTestDriver + + +class TestDriverClose(TestCase): + def test_closing_the_driver_closes_a_store_left_open(self): + # close() used to iterate _document_stores while each store's after-close callback + # popped itself out of that same dict, so the for statement raised + # RuntimeError: dictionary changed size during iteration - outside the try/except, + # leaving disposed unset and on_driver_closed unfired. + driver = RavenTestDriver() + store = driver.get_document_store() + with store.open_session() as session: + session.store({"name": "John"}, "people/1") + session.save_changes() + + closed = [] + driver.on_driver_closed = closed.append + + driver.close() + + self.assertTrue(driver.disposed) + self.assertEqual([driver], closed) + self.assertEqual(0, len(driver._document_stores)) + + def test_closing_the_driver_twice_is_a_no_op(self): + driver = RavenTestDriver() + with driver.get_document_store(): + pass + + driver.close() + driver.close() + + self.assertTrue(driver.disposed) + + +class TestDriverCloseError(TestCase): + def test_a_raising_callback_is_collected_not_swallowed(self): + driver = RavenTestDriver() + + def explode(_): + raise ValueError("callback blew up") + + driver.on_driver_closed = explode + + with self.assertRaises(DriverCloseError) as caught: + driver.close() + + self.assertEqual(1, len(caught.exception.exceptions)) + self.assertIsInstance(caught.exception.exceptions[0], ValueError) + # Subclasses RuntimeError, so existing handlers keep working. + self.assertIsInstance(caught.exception, RuntimeError) + + +class TestDatabaseNameAllocation(TestCase): + def test_two_drivers_get_distinct_database_names(self): + # self._DATABASE_COUNTER += 1 read the class attribute and wrote an instance one, so every + # driver restarted numbering at 1 and two live drivers both asked for test_1. + with RavenTestDriver() as first, RavenTestDriver() as second: + with first.get_document_store() as first_store: + with second.get_document_store() as second_store: + self.assertNotEqual(first_store.database, second_store.database) + + def test_counter_stays_a_class_attribute(self): + driver = RavenTestDriver() + before = RavenTestDriver._DATABASE_COUNTER + with driver.get_document_store(): + pass + + self.assertEqual(before + 1, RavenTestDriver._DATABASE_COUNTER) + self.assertNotIn("_DATABASE_COUNTER", driver.__dict__) + driver.close() + + +class _PreConfiguringDriver(RavenTestDriver): + def __init__(self): + super().__init__() + self.records = [] + + def pre_configure_database(self, database_record) -> None: + self.records.append(database_record) + database_record.settings["Indexing.MapTimeoutInSec"] = "17" + + +class TestPreConfigureDatabase(TestCase): + def test_hook_can_change_the_record_before_the_database_is_created(self): + driver = _PreConfiguringDriver() + with driver.get_document_store() as store: + self.assertEqual(1, len(driver.records)) + self.assertEqual(store.database, driver.records[0].database_name) + + created = store.maintenance.server.send(GetDatabaseRecordOperation(store.database)) + self.assertEqual("17", created.settings["Indexing.MapTimeoutInSec"]) + + driver.close() + + +def _index(name, stale=False, state=IndexState.NORMAL): + return IndexInformation(stale=stale, index_state=state, name=name) + + +class _FakeAdmin: + """Serves canned GetStatisticsOperation results, one per poll, then repeats the last.""" + + def __init__(self, *statistics): + self._statistics = list(statistics) + self.polls = 0 + + def send(self, operation): + if isinstance(operation, GetIndexErrorsOperation): + return [] + self.polls += 1 + return self._statistics.pop(0) if len(self._statistics) > 1 else self._statistics[0] + + +class _FakeStore: + def __init__(self, admin): + self.maintenance = SimpleNamespace(for_database=lambda database=None: admin) + + +class TestWaitForIndexing(TestCase): + def test_returns_once_nothing_is_stale(self): + admin = _FakeAdmin(SimpleNamespace(indexes=[_index("Orders/ByCompany")])) + + RavenTestDriver.wait_for_indexing(_FakeStore(admin), "db", timedelta(seconds=5)) + + self.assertEqual(1, admin.polls) + + def test_keeps_polling_while_something_is_stale(self): + admin = _FakeAdmin( + SimpleNamespace(indexes=[_index("Orders/ByCompany", stale=True)]), + SimpleNamespace(indexes=[_index("Orders/ByCompany")]), + ) + + RavenTestDriver.wait_for_indexing(_FakeStore(admin), "db", timedelta(seconds=5)) + + self.assertEqual(2, admin.polls) + + def test_waits_for_a_side_by_side_replacement_to_be_swapped_in(self): + # A replacement index blocks the return even when it is not stale, matching C#: + # until the server swaps it in, querying the original returns pre-swap results. + # The old predicate excluded ReplacementOf/ indexes and returned immediately. + admin = _FakeAdmin( + SimpleNamespace( + indexes=[ + _index("Orders/ByCompany"), + _index("ReplacementOf/Orders/ByCompany"), + ] + ) + ) + + with self.assertRaises(TimeoutException): + RavenTestDriver.wait_for_indexing(_FakeStore(admin), "db", timedelta(milliseconds=200)) + + self.assertGreater(admin.polls, 0) + + def test_a_zero_timeout_is_honored_instead_of_becoming_sixty_seconds(self): + # `timeout or timedelta(seconds=60)` silently turned an explicit zero into a minute. + admin = _FakeAdmin(SimpleNamespace(indexes=[_index("Orders/ByCompany", stale=True)])) + + started = time.monotonic() + with self.assertRaises(TimeoutException): + RavenTestDriver.wait_for_indexing(_FakeStore(admin), "db", timedelta(0)) + + self.assertLess(time.monotonic() - started, 1) + + def test_disabled_indexes_are_ignored(self): + admin = _FakeAdmin(SimpleNamespace(indexes=[_index("Orders/Disabled", stale=True, state=IndexState.DISABLED)])) + + RavenTestDriver.wait_for_indexing(_FakeStore(admin), "db", timedelta(seconds=5)) + + self.assertEqual(1, admin.polls) + + +class TestCleanupTempDirs(TestCase): + def test_cleanup_returns_as_soon_as_the_directory_is_gone(self): + # The retry loop read shutil.rmtree's None return as a failure flag, so a successful + # delete still cost one 0.2s sleep and a second pass. + directory = tempfile.mkdtemp() + Path(directory, "file.txt").write_text("x", encoding="utf-8") + + started = time.monotonic() + RavenTestDriver._cleanup_temp_dirs(directory) + elapsed = time.monotonic() - started + + self.assertFalse(os.path.exists(directory)) + self.assertLess(elapsed, 0.15) diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py new file mode 100644 index 0000000..e45daee --- /dev/null +++ b/tests/test_end_to_end.py @@ -0,0 +1,219 @@ +"""End-to-end checks against a real embedded server. + +The unit tests elsewhere pin behavior with fakes; these boot an actual RavenDB server and assert +on what the server, the filesystem and the process left behind. Everything here is skipped when +the suite is pointed at a server the driver does not own. +""" + +import glob +import os +import subprocess +import sys +import tempfile +from datetime import timedelta +from pathlib import Path +from unittest import TestCase, skipIf + +from ravendb import GetDatabaseNamesOperation +from ravendb.documents.indexes.abstract_index_creation_tasks import AbstractIndexCreationTask + +from ravendb_test_driver import GetDocumentStoreOptions, RavenTestDriver, TestServerOptions +from tests.support import attach_mode_is_active, certificates, isolate_environment, reset_driver + +skip_without_own_server = skipIf(attach_mode_is_active(), "attach mode: the driver does not own the server") + + +def _database_names(): + store = RavenTestDriver._TEST_SERVER_STORE.value + return store.maintenance.server.send(GetDatabaseNamesOperation(0, 1000)) + + +class Person: + def __init__(self, Id=None, name=None): + self.Id = Id + self.name = name + + +@skip_without_own_server +class TestDatabaseLifecycle(TestCase): + def test_closing_a_store_deletes_its_database_on_the_server(self): + driver = RavenTestDriver() + store = driver.get_document_store() + database = store.database + + self.assertIn(database, _database_names()) + + store.close() + + self.assertNotIn(database, _database_names()) + driver.close() + + def test_closing_the_driver_deletes_databases_of_stores_left_open(self): + # The contract the README advertises: whatever you forget to close, the driver cleans up. + driver = RavenTestDriver() + first = driver.get_document_store().database + second = driver.get_document_store().database + + self.assertIn(first, _database_names()) + self.assertIn(second, _database_names()) + + driver.close() + + names = _database_names() + self.assertNotIn(first, names) + self.assertNotIn(second, names) + + +@skip_without_own_server +class TestDatabaseNamingAgainstServer(TestCase): + def setUp(self): + self.addCleanup(setattr, RavenTestDriver, "use_caller_name_for_database", False) + isolate_environment(self) + + def test_caller_name_and_process_id_reach_the_created_database(self): + RavenTestDriver.use_caller_name_for_database = True + os.environ["RAVENDB_TEST_UNIQUE_DB_NAMES"] = "1" + + with RavenTestDriver() as driver: + with driver.get_document_store() as store: + self.assertTrue( + store.database.startswith( + f"test_caller_name_and_process_id_reach_the_created_database_{os.getpid()}_" + ), + store.database, + ) + # The server really has a database under that generated name. + self.assertIn(store.database, _database_names()) + + +@skip_without_own_server +class TestInMemoryStorage(TestCase): + def test_no_database_files_are_written_to_disk(self): + reset_driver() + self.addCleanup(reset_driver) + + with tempfile.TemporaryDirectory() as directory: + options = TestServerOptions() + options.data_directory = directory + RavenTestDriver.configure_server(options) + + with RavenTestDriver() as driver: + with driver.get_document_store() as store: + with store.open_session() as session: + for i in range(100): + session.store(Person(name=f"person-{i}"), f"people/{i}") + session.save_changes() + + written = [str(path.relative_to(directory)) for path in Path(directory).rglob("*") if path.is_file()] + + # Only the server log survives; the storage engine never touched this directory. + self.assertTrue(written, "expected at least the server log") + self.assertEqual( + [], [name for name in written if name.endswith((".voron", ".journal", ".buffers"))], written + ) + self.assertEqual([], [name for name in written if not name.startswith("Logs")], written) + + +@skip_without_own_server +class TestSecuredEmbeddedServer(TestCase): + def test_driver_authenticates_to_a_secured_embedded_server(self): + # Regression: the driver used to build its stores without the client certificate the + # embedded server was started with, so a secured embedded server was unusable. + reset_driver() + self.addCleanup(reset_driver) + + with tempfile.TemporaryDirectory() as directory: + server_pfx, client_pem, ca_certificate = certificates(directory) + options = TestServerOptions() + options.secured(server_pfx, client_pem, ca_certificate_path=ca_certificate) + RavenTestDriver.configure_server(options) + + with RavenTestDriver() as driver: + with driver.get_document_store() as store: + self.assertTrue(store.urls[0].startswith("https://"), store.urls) + self.assertEqual(client_pem, store.certificate_pem_path) + + with store.open_session() as session: + session.store(Person(name="secured"), "people/1") + session.save_changes() + + with store.open_session() as session: + self.assertEqual("secured", session.load("people/1", Person).name) + + +@skip_without_own_server +class TestWaitForIndexingOption(TestCase): + def test_a_zero_wait_for_indexing_timeout_still_waits(self): + # `if options.wait_for_indexing_timeout:` skipped the wait entirely for timedelta(0). + recorded = [] + + class _RecordingDriver(RavenTestDriver): + @staticmethod + def wait_for_indexing(store, database=None, timeout=None): + recorded.append(timeout) + + with _RecordingDriver() as driver: + with driver.get_document_store(GetDocumentStoreOptions.with_timeout(timedelta(0))): + pass + + self.assertEqual([timedelta(0)], recorded) + + +class People_ByName(AbstractIndexCreationTask): + def __init__(self): + super().__init__() + self.map = "from p in docs.People select new { p.name }" + + +class _SeedingDriver(RavenTestDriver): + def setup_database(self, store) -> None: + store.execute_index(People_ByName()) + with store.open_session() as session: + session.store(Person(name="Seeded"), "people/1") + session.save_changes() + + +@skip_without_own_server +class TestSeedingAndIndexing(TestCase): + def test_seeded_data_is_queryable_through_a_real_index(self): + # wait_for_indexing is unit-tested against canned statistics; this proves it against a + # real indexer, on an in-memory server, through setup_database. + with _SeedingDriver() as driver: + with driver.get_document_store() as store: + driver.wait_for_indexing(store) + + with store.open_session() as session: + hits = list(session.query_index_type(People_ByName, Person).where_equals("name", "Seeded")) + + self.assertEqual(1, len(hits)) + self.assertEqual("Seeded", hits[0].name) + + +@skip_without_own_server +class TestProcessExitCleanup(TestCase): + def test_a_finished_test_run_leaves_no_temporary_files(self): + # atexit can only be observed from the outside, so this runs a real child interpreter. + temporary = tempfile.gettempdir() + data_before = set(glob.glob(os.path.join(temporary, "ravendb-test-driver-*"))) + settings_before = set(glob.glob(os.path.join(temporary, "settings-*.json"))) + + child = subprocess.run( + [ + sys.executable, + "-c", + "from ravendb_test_driver import RavenTestDriver\n" + "with RavenTestDriver() as driver:\n" + " with driver.get_document_store() as store:\n" + " with store.open_session() as session:\n" + " session.store({'name': 'child'}, 'people/1')\n" + " session.save_changes()\n", + ], + capture_output=True, + text=True, + timeout=300, + cwd=str(Path(__file__).resolve().parent.parent), + ) + + self.assertEqual(0, child.returncode, child.stderr) + self.assertEqual(set(), set(glob.glob(os.path.join(temporary, "ravendb-test-driver-*"))) - data_before) + self.assertEqual(set(), set(glob.glob(os.path.join(temporary, "settings-*.json"))) - settings_before) diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py new file mode 100644 index 0000000..f85fb8a --- /dev/null +++ b/tests/test_error_handling.py @@ -0,0 +1,120 @@ +"""Error paths: message wording, swallowed teardown failures, and browser launch failures. + +All hermetic: no server is started. +""" + +import io +import webbrowser +from contextlib import redirect_stdout +from types import SimpleNamespace +from unittest import TestCase +from unittest.mock import patch + +from ravendb import Lazy +from ravendb.exceptions.cluster import NoLoaderException +from ravendb.exceptions.exceptions import DatabaseDoesNotExistException +from ravendb.exceptions.raven_exceptions import RavenException +from ravendb_embedded import ServerOptions + +from ravendb_test_driver import RavenTestDriver + + +class _StoreThatRaises: + def __init__(self, error): + self.sent = [] + + def send(operation): + self.sent.append(operation) + if error is not None: + raise error + + self.maintenance = SimpleNamespace(server=SimpleNamespace(send=send)) + + +class TestDeleteTestDatabase(TestCase): + def test_deletes_the_database_it_was_given(self): + store = _StoreThatRaises(None) + + RavenTestDriver._delete_test_database(store, "test_1") + + self.assertEqual(1, len(store.sent)) + + def test_a_database_that_is_already_gone_is_not_an_error(self): + RavenTestDriver._delete_test_database(_StoreThatRaises(DatabaseDoesNotExistException("gone")), "test_1") + + def test_a_typed_no_leader_failure_is_ignored(self): + RavenTestDriver._delete_test_database(_StoreThatRaises(NoLoaderException("no leader")), "test_1") + + def test_an_untyped_no_leader_failure_is_ignored(self): + # The client maps the server's NoLeaderException under a misspelled key, so today a real + # no-leader failure arrives as a plain RavenException carrying the name in its message. + error = RavenException("Raven.Client.Exceptions.Cluster.NoLeaderException: no leader elected") + + RavenTestDriver._delete_test_database(_StoreThatRaises(error), "test_1") + + def test_any_other_failure_still_propagates(self): + with self.assertRaises(RavenException): + RavenTestDriver._delete_test_database(_StoreThatRaises(RavenException("disk on fire")), "test_1") + + +class TestConfigurationMessages(TestCase): + def setUp(self): + started = Lazy(lambda: object()) + started.value # force creation, so the driver believes the server is already up + self.addCleanup(setattr, RavenTestDriver, "_TEST_SERVER_STORE", RavenTestDriver._TEST_SERVER_STORE) + RavenTestDriver._TEST_SERVER_STORE = started + + def test_configure_server_names_the_python_api(self): + # The message used to carry the Java driver's camelCase names. + with self.assertRaises(RuntimeError) as caught: + RavenTestDriver.configure_server(ServerOptions()) + + message = str(caught.exception) + self.assertIn("configure_server", message) + self.assertIn("get_document_store", message) + self.assertNotIn("configureServer", message) + self.assertNotIn("getDocumentStore", message) + + def test_configure_external_server_names_the_python_api(self): + with self.assertRaises(RuntimeError) as caught: + RavenTestDriver.configure_external_server("http://localhost:8080") + + message = str(caught.exception) + self.assertIn("configure_external_server", message) + self.assertIn("get_document_store", message) + + +class TestOpenBrowser(TestCase): + def test_a_launch_failure_names_the_url_and_keeps_the_cause(self): + cause = OSError("no display") + + with patch.object(webbrowser, "open", side_effect=cause): + with self.assertRaises(RuntimeError) as caught: + with redirect_stdout(io.StringIO()): + RavenTestDriver().open_browser("http://127.0.0.1:8080/studio") + + self.assertIn("http://127.0.0.1:8080/studio", str(caught.exception)) + self.assertIs(cause, caught.exception.__cause__) + + def test_a_headless_machine_is_reported_rather_than_ignored(self): + # webbrowser.open returns False instead of raising when there is no browser. + output = io.StringIO() + + with patch.object(webbrowser, "open", return_value=False): + with redirect_stdout(output): + RavenTestDriver().open_browser("http://127.0.0.1:8080/studio") + + self.assertIn("No browser could be opened", output.getvalue()) + + def test_open_browser_can_be_overridden(self): + # It is the driver's override hook, the analogue of C#'s protected virtual OpenBrowser. + opened = [] + + class _Driver(RavenTestDriver): + @staticmethod + def open_browser(url): + opened.append(url) + + _Driver().open_browser("http://127.0.0.1:8080/studio") + + self.assertEqual(["http://127.0.0.1:8080/studio"], opened) diff --git a/tests/test_server_options.py b/tests/test_server_options.py new file mode 100644 index 0000000..5759713 --- /dev/null +++ b/tests/test_server_options.py @@ -0,0 +1,256 @@ +"""Test-server option normalization, server selection precedence, and shared-server teardown. + +Everything except the last class is hermetic: no server is started, so these run anywhere. +""" + +import os +import tempfile +import warnings +from datetime import timedelta +from types import SimpleNamespace +from unittest import TestCase +from unittest.mock import patch + +from ravendb.exceptions.exceptions import TimeoutException +from ravendb.exceptions.raven_exceptions import RavenException +from ravendb_embedded import ServerOptions + +from ravendb_test_driver import RavenTestDriver, TestServerOptions +from tests.support import attach_mode_is_active, isolate_environment + + +def _run_in_memory_args(options): + return [arg for arg in options.command_line_args if arg.startswith("--RunInMemory")] + + +class TestOptionNormalization(TestCase): + def test_applies_test_defaults_to_a_plain_server_options(self): + options = RavenTestDriver._normalize_test_server_options(ServerOptions()) + + self.assertEqual(["--RunInMemory=true"], _run_in_memory_args(options)) + self.assertEqual("-c", options.command_line_args[0]) + self.assertTrue(options.command_line_args[1].endswith(".json")) + + def test_is_idempotent(self): + options = TestServerOptions() + RavenTestDriver._normalize_test_server_options(options) + first_pass = list(options.command_line_args) + data_directory = options.data_directory + + RavenTestDriver._normalize_test_server_options(options) + + self.assertEqual(first_pass, options.command_line_args) + self.assertEqual(data_directory, options.data_directory) + + def test_keeps_a_caller_supplied_run_in_memory_value(self): + options = ServerOptions() + options.command_line_args.append("--RunInMemory=false") + + RavenTestDriver._normalize_test_server_options(options) + + self.assertEqual(["--RunInMemory=false"], _run_in_memory_args(options)) + + def test_run_in_memory_can_be_switched_off_on_the_options(self): + options = TestServerOptions() + options.run_in_memory = False + + RavenTestDriver._normalize_test_server_options(options) + + self.assertEqual([], _run_in_memory_args(options)) + + def test_does_not_mutate_the_callers_argument_list(self): + options = ServerOptions() + caller_list = options.command_line_args + + RavenTestDriver._normalize_test_server_options(options) + + self.assertEqual([], caller_list) + self.assertIsNot(caller_list, options.command_line_args) + + def test_redirects_the_packaged_default_data_directory(self): + options = ServerOptions() + packaged_default = options.data_directory + + RavenTestDriver._normalize_test_server_options(options) + + self.assertNotEqual(packaged_default, options.data_directory) + # Logs follow the data directory, so one temp root covers both. + self.assertTrue(options.logs_path.startswith(options.data_directory)) + + def test_leaves_an_explicit_data_directory_alone(self): + options = ServerOptions() + options.data_directory = os.path.join(os.getcwd(), "chosen-by-the-user") + + RavenTestDriver._normalize_test_server_options(options) + + self.assertTrue(options.data_directory.endswith("chosen-by-the-user")) + + def test_rejects_a_secured_server_the_client_cannot_authenticate_to(self): + options = ServerOptions() + options.secured("server.pfx") + + with self.assertRaisesRegex(RavenException, "client certificate"): + RavenTestDriver._normalize_test_server_options(options) + + +class TestServerSelectionPrecedence(TestCase): + def setUp(self): + self.addCleanup(setattr, RavenTestDriver, "_GLOBAL_SERVER_OPTIONS", RavenTestDriver._GLOBAL_SERVER_OPTIONS) + self.addCleanup(setattr, RavenTestDriver, "_EXTERNAL_SERVER_URL", RavenTestDriver._EXTERNAL_SERVER_URL) + isolate_environment(self) + + def test_configure_external_server_wins_over_the_environment(self): + RavenTestDriver._GLOBAL_SERVER_OPTIONS = None + RavenTestDriver._EXTERNAL_SERVER_URL = "http://from-the-api:8080" + os.environ["RAVENDB_TEST_SERVER_URL"] = "http://from-the-environment:8080" + + self.assertEqual("http://from-the-api:8080", RavenTestDriver._resolve_external_server_url()) + + def test_environment_is_used_when_nothing_was_configured(self): + RavenTestDriver._GLOBAL_SERVER_OPTIONS = None + RavenTestDriver._EXTERNAL_SERVER_URL = None + os.environ["RAVENDB_TEST_SERVER_URL"] = "http://from-the-environment:8080" + + self.assertEqual("http://from-the-environment:8080", RavenTestDriver._resolve_external_server_url()) + + def test_configure_server_wins_over_the_environment_and_warns(self): + # The environment used to silently redirect a suite pinned to the embedded server onto + # someone else's, where the driver then creates and hard-deletes databases. + RavenTestDriver._EXTERNAL_SERVER_URL = None + RavenTestDriver._GLOBAL_SERVER_OPTIONS = TestServerOptions() + os.environ["RAVENDB_TEST_SERVER_URL"] = "http://from-the-environment:8080" + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + resolved = RavenTestDriver._resolve_external_server_url() + + self.assertIsNone(resolved) + self.assertEqual(1, len(caught)) + self.assertIn("configure_server", str(caught[0].message)) + + +class _FakeSession: + def __init__(self, exists): + self.advanced = SimpleNamespace(exists=lambda key: exists) + self.deleted = [] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + return False + + def delete(self, key): + self.deleted.append(key) + + def save_changes(self): + pass + + +class _FakeStore: + def __init__(self, exists=False): + self.database = "test_1" + self.urls = ["http://127.0.0.1:8080"] + self.sessions = [] + self._exists = exists + + def open_session(self): + session = _FakeSession(self._exists) + self.sessions.append(session) + return session + + +class _SilentDriver(RavenTestDriver): + opened = [] + + @staticmethod + def open_browser(url: str) -> None: + _SilentDriver.opened.append(url) + + +class TestWaitForUserToContinueTheTest(TestCase): + def setUp(self): + _SilentDriver.opened = [] + isolate_environment(self) + + def test_environment_kill_switch_skips_the_wait_entirely(self): + os.environ["RAVENDB_TEST_WAIT_FOR_USER"] = "0" + store = _FakeStore() + + _SilentDriver().wait_for_user_to_continue_the_test(store) + + self.assertEqual([], _SilentDriver.opened) + self.assertEqual([], store.sessions) + + def test_times_out_instead_of_hanging_a_ci_job(self): + store = _FakeStore(exists=False) + + with self.assertRaisesRegex(TimeoutException, "Debug/Done"): + _SilentDriver().wait_for_user_to_continue_the_test(store, timeout=timedelta(milliseconds=1)) + + self.assertEqual(1, len(_SilentDriver.opened)) + + def test_deletes_the_marker_and_returns(self): + store = _FakeStore(exists=True) + + _SilentDriver().wait_for_user_to_continue_the_test(store, timeout=timedelta(seconds=5)) + + self.assertEqual(["Debug/Done"], store.sessions[-1].deleted) + + +class TestDeprecatedHelperAliases(TestCase): + def test_default_server_options_alias_still_works(self): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + options = RavenTestDriver.default_server_options() + + self.assertEqual(["--RunInMemory=true"], _run_in_memory_args(options)) + self.assertEqual(1, len(caught)) + self.assertIs(DeprecationWarning, caught[0].category) + self.assertIn("_default_server_options", str(caught[0].message)) + + def test_run_server_alias_still_works(self): + with patch.object(RavenTestDriver, "_run_server", return_value="store") as run_server: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = RavenTestDriver.run_server() + + self.assertEqual("store", result) + self.assertEqual(1, run_server.call_count) + self.assertIs(DeprecationWarning, caught[0].category) + self.assertIn("_run_server", str(caught[0].message)) + + def test_cleanup_temp_dirs_alias_still_works(self): + directory = tempfile.mkdtemp() + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + RavenTestDriver.cleanup_temp_dirs(directory) + + self.assertFalse(os.path.exists(directory)) + self.assertIs(DeprecationWarning, caught[0].category) + + +class TestSharedServerTeardown(TestCase): + """Runs last on purpose: it stops the server the rest of the suite shares.""" + + def test_stop_test_server_is_idempotent_and_the_server_comes_back(self): + if attach_mode_is_active(): + self.skipTest("attach mode: the driver does not own the server") + + with RavenTestDriver() as driver: + with driver.get_document_store(): + pass + + RavenTestDriver.stop_test_server() + RavenTestDriver.stop_test_server() + + self.assertFalse(RavenTestDriver._TEST_SERVER_STORE.is_value_created) + + with RavenTestDriver() as driver: + with driver.get_document_store() as store: + with store.open_session() as session: + session.store({"name": "after restart"}, "people/1") + session.save_changes() + + RavenTestDriver.stop_test_server()