diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index 53ff5155..e3617ef8 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -23,10 +23,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v7 - name: Setup .NET SDK - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v6 with: dotnet-version: 10.x @@ -66,10 +66,10 @@ jobs: echo "]" >> docs/api/toc.yaml - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: './docs/_site' - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/unittest-verification.yml b/.github/workflows/unittest-verification.yml index 5d52cdb5..f0f640db 100644 --- a/.github/workflows/unittest-verification.yml +++ b/.github/workflows/unittest-verification.yml @@ -15,23 +15,23 @@ jobs: working-directory: src steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v6 with: dotnet-version: 10.x - name: Restore dependencies - run: dotnet restore ./WebExpress.WebCore.sln + run: dotnet restore ./WebExpress.WebCore.slnx - name: Build project - run: dotnet build ./WebExpress.WebCore.sln --no-restore --configuration Release + run: dotnet build ./WebExpress.WebCore.slnx --no-restore --configuration Release - name: Run xUnit tests id: tests continue-on-error: true - run: dotnet test ./WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj --no-build --configuration Release -- --report-trx --results-directory ./TestResults + run: dotnet test --project ./WebExpress.WebCore.Test/WebExpress.WebCore.Test.csproj --no-build --configuration Release --report-trx --results-directory ./TestResults - name: Print failing tests on failure if: steps.tests.outcome == 'failure' @@ -91,7 +91,7 @@ jobs: - name: Upload TRX report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: trx-results path: src/TestResults/**/*.trx diff --git a/README.md b/README.md index ae3401cc..1c2b9967 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,47 @@ ![WebExpress-Framework](https://raw.githubusercontent.com/webexpress-framework/.github/main/docs/assets/img/banner.png) # WebExpress -`WebExpress` is a lightweight web server optimized for use in low-performance environments (e.g. Raspberry PI). By providing a powerful plugin system and a comprehensive API, web applications can be easily and quickly integrated into a .net language (e.g. C#). Some advantages of `WebExpress` are: +**WebExpress** is a lightweight, high-performance web server designed to scale seamlessly from resource-constrained environments to larger production systems. Through its extensible plugin framework and comprehensive API, web applications can be developed and integrated quickly using .NET languages such as C#. Some of the key benefits of **WebExpress** are: - It is easy to use. - It offers a variety of features and tools that can help you build and manage your website. - It is fast and efficient and can help you save time and money. - It is flexible and can be customized to meet your specific requirements. -The `WebExpress` family includes the following projects: +The **WebExpress** family includes the following projects: -- [WebExpress](https://github.com/webexpress-framework/WebExpress#readme) - The web server for `WebExpress` applications and the documentation. -- [WebExpress.WebCore](https://github.com/webexpress-framework/WebExpress.WebCore#readme) - The core for `WebExpress` applications. -- [WebExpress.WebUI](https://github.com/webexpress-framework/WebExpress.WebUI#readme) - Common templates and controls for `WebExpress` applications. -- [WebExpress.WebIndex](https://github.com/webexpress-framework/WebExpress.WebIndex#readme) - Reverse index for `WebExpress` applications. -- [WebExpress.WebApp](https://github.com/webexpress-framework/WebExpress.WebApp#readme) - Business application template for `WebExpress` applications. +- [WebExpress](https://github.com/webexpress-framework/WebExpress#readme) - The web server for **WebExpress** applications and the documentation. +- [WebExpress.WebCore](https://github.com/webexpress-framework/WebExpress.WebCore#readme) - The core for **WebExpress** applications. +- [WebExpress.WebUI](https://github.com/webexpress-framework/WebExpress.WebUI#readme) - Common templates and controls for **WebExpress** applications. +- [WebExpress.WebIndex](https://github.com/webexpress-framework/WebExpress.WebIndex#readme) - Reverse index for **WebExpress** applications. +- [WebExpress.WebApp](https://github.com/webexpress-framework/WebExpress.WebApp#readme) - Business application template for **WebExpress** applications. # WebExpress.WebCore -`WebCore` is part of the `WebExpress` family and includes the basic elements of a `WebExpress` application. +`WebCore` is part of the **WebExpress** family and includes the basic elements of a **WebExpress** application. # Download The current binaries are available for download [here](https://github.com/webexpress-framework/WebExpress/releases). # Start -If you're looking to get started with `WebExpress`, we would recommend using the following documentation. It can help you understand the platform. +If you're looking to get started with **WebExpress**, we would recommend using the following documentation. It can help you understand the platform. -- [Installation Guide](https://github.com/webexpress-framework/WebExpress/blob/main/doc/installation_guide.md) -- [Development Guide](https://github.com/webexpress-framework/WebExpress/blob/main/doc/development_guide.md) +- [Installation Guide](https://github.com/webexpress-framework/WebExpress/blob/main/docs/installation_guide.md) +- [Development Guide](https://github.com/webexpress-framework/WebExpress/blob/main/docs/development_guide.md) - [WebExpress.WebCore API Documentation](https://webexpress-framework.github.io/WebExpress.WebCore/) - [WebExpress.WebUI API Documentation](https://webexpress-framework.github.io/WebExpress.WebUI/) - [WebExpress.WebApp API Documentation](https://webexpress-framework.github.io/WebExpress.WebApp/) - [WebExpress.WebIndex API Documentation](https://webexpress-framework.github.io/WebExpress.WebIndex/) # Learning -The following tutorials illustrate the essential techniques of `WebExpress`. These tutorials are designed to assist you, as a developer, in understanding the various aspects of `WebExpress`. Each tutorial provides a detailed, step-by-step guide that you can work through using an example. If you re interested in beginning the development of `WebExpress` components, we would recommend you to complete some of these tutorials. +The following tutorials illustrate the essential techniques of **WebExpress**. These tutorials are designed to assist you, as a developer, in understanding the various aspects of **WebExpress**. Each tutorial provides a detailed, step-by-step guide that you can work through using an example. If you're interested in beginning the development of **WebExpress** components, we would recommend you to complete some of these tutorials. - [HelloWorld](https://github.com/webexpress-framework/WebExpress.Tutorial.HelloWorld#readme) - [WebUI](https://github.com/webexpress-framework/WebExpress.Tutorial.WebUI#readme) - [WebApp](https://github.com/webexpress-framework/WebExpress.Tutorial.WebApp#readme) - [WebIndex](https://github.com/webexpress-framework/WebExpress.Tutorial.WebIndex#readme) +## AI transparency notice +Parts of this software, its documentation, and its assets were created with the assistance of AI-based tools, including large language models. AI-assisted contributions are reviewed by the project maintainer before they are included. + # Tags #WebCore #WebExpress #DotNet #NETCore diff --git a/docs/index.md b/docs/index.md index caf4880c..df85c6bb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,7 @@ -![WebExpress](https://raw.githubusercontent.com/webexpress-framework/WebExpress/main/assets/banner.png) +![WebExpress](https://raw.githubusercontent.com/webexpress-framework/.github/main/docs/assets/img/banner.png) # WebExpress -WebExpress is a lightweight web server optimized for use in low-performance environments (e.g. Raspberry PI). By providing +WebExpress is a lightweight web server optimized for use in low-performance environments (e.g. Raspberry Pi). By providing a powerful plugin system and a comprehensive API, web applications can be easily and quickly integrated into a .NET language (e.g. C#). Some advantages of WebExpress are: diff --git a/docs/toc.yml b/docs/toc.yml index 42dd2426..cfa06b00 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -4,5 +4,7 @@ href: api/ - name: User Guide href: user-guide.md +- name: Health Checks + href: https://github.com/webexpress-framework/WebExpress/blob/main/docs/development_guide.md#health-model - name: Tutorials href: tutorials.md diff --git a/docs/tutorials.md b/docs/tutorials.md index f7e2e418..0606f9f7 100644 --- a/docs/tutorials.md +++ b/docs/tutorials.md @@ -1,4 +1,4 @@ -![WebExpress](https://raw.githubusercontent.com/webexpress-framework/WebExpress/main/assets/banner.png) +![WebExpress](https://raw.githubusercontent.com/webexpress-framework/.github/main/docs/assets/img/banner.png) # Tutorials Welcome to the `WebExpress` Tutorials! Here, you'll find step-by-step guides and helpful resources to get the most out diff --git a/docs/user-guide.md b/docs/user-guide.md index 7f6360b5..25f23266 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -1,4 +1,4 @@ -![WebExpress](https://raw.githubusercontent.com/webexpress-framework/WebExpress/main/assets/banner.png) +![WebExpress](https://raw.githubusercontent.com/webexpress-framework/.github/main/docs/assets/img/banner.png) # User guide Welcome to the `WebExpress.WebCore` User Guide. This guide will help you get started with `WebExpress.WebCore` and make the most out of its @@ -7,11 +7,171 @@ features. Follow the links below to begin your journey. # Getting started To get started with `WebExpress.WebCore`, use the following guides: -- [Installation Guide](https://github.com/webexpress-framework/WebExpress/blob/main/doc/installation_guide.md) -- [Development Guide](https://github.com/webexpress-framework/WebExpress/blob/main/doc/development_guide.md) +- [Installation Guide](https://github.com/webexpress-framework/WebExpress/blob/main/docs/installation_guide.md) +- [Development Guide](https://github.com/webexpress-framework/WebExpress/blob/main/docs/development_guide.md) - [WebExpress.WebCore API Documentation](https://webexpress-framework.github.io/WebExpress.WebCore/) - [WebExpress.WebUI API Documentation](https://webexpress-framework.github.io/WebExpress.WebUI/) - [WebExpress.WebApp API Documentation](https://webexpress-framework.github.io/WebExpress.WebApp/) - [WebExpress.WebIndex API Documentation](https://webexpress-framework.github.io/WebExpress.WebIndex/) We hope you enjoy using `WebExpress.WebCore` and find it valuable for your projects. Happy coding! + +## Health checks + +The container health endpoint is documented in the [Health model](https://github.com/webexpress-framework/WebExpress/blob/main/docs/development_guide.md#health-model) section of the Development Guide. WebCore provides `/health` globally, and applications contribute critical dependency checks as public sealed `IHealth` components in `WebExpress.WebCore.WebHealth`. The `HealthManager` discovers these components through the plugin and application lifecycle. The guide includes the component model, a database example, the HTTP contract, and Docker and Kubernetes probe configuration. + +## Metrics + +The Prometheus endpoint is documented in the [Metrics model](https://github.com/webexpress-framework/WebExpress/blob/main/docs/development_guide.md#metrics-model) section of the Development Guide. WebCore provides `/metrics` globally with request, error, login, active user, process, and runtime metrics. Applications record their own values with `MetricCounter`, `MetricGauge`, and `MetricHistogram` instruments, or report them at scrape time as public sealed `IMetric` components in `WebExpress.WebCore.WebMetrics`. The `MetricsManager` discovers these components through the plugin and application lifecycle and labels every series with its application. The guide includes an LDAP example, the list of framework metrics, the HTTP contract, and Prometheus, Kubernetes, and alerting configuration. + +The endpoint is switched off by default, because the metrics reveal load and login failures to anyone who can reach the listener. Enable it explicitly, and set a scrape token when the listener is reachable from outside the cluster: + +```json +{ + "WebExpress": { + "Metrics": { + "Enabled": true, + "BearerToken": "a-long-random-secret" + } + } +} +``` + +The equivalent environment variables are `WEBEXPRESS_WebExpress__Metrics__Enabled` and `WEBEXPRESS_WebExpress__Metrics__BearerToken`. While the endpoint is off, `/metrics` is left to application routing. `Metrics:ActiveUserWindowMinutes` sets how recently a user must have sent an authenticated request to count as active (default `5`). + +## Graceful shutdown + +The container lifecycle is described in the [Graceful shutdown guide](graceful-shutdown.md). It covers `Shutdown: "graceful"`, the shared drain budget, background synchronization, resource ownership, and Docker and Kubernetes termination settings. + +## Horizontal scaling + +Several instances can serve one deployment behind a load balancer. The [Cluster model](https://github.com/webexpress-framework/WebExpress/blob/main/docs/development_guide.md#cluster-model) section of the Development Guide lists what each subsystem shares, the `WebExpress:Cluster` settings (shared state directory, peers, secret), `ShutdownDelaySeconds`, the store and transport extensions in `WebExpress.WebCore.WebCluster`, and Kubernetes and Docker Compose examples. + +## Public server URI + +Configure `WebExpress:ExternalUri` when the listener binding is not the URL used by clients, for example when WebExpress runs behind a reverse proxy. The server continues to bind to the addresses in `Endpoints`, while applications and components can obtain the public URL through `IHttpServerContext.ExternalUri`. + +```json +{ + "WebExpress": { + "Endpoints": [ + { "Uri": "http://0.0.0.0:8080/" } + ], + "ExternalUri": "https://www.example.com/" + } +} +``` + +`ExternalUri` is optional. Leave it unset when the listener address is also the public URL. + +## Server entry point + +By default, a `GET` or `HEAD` request to `/` redirects to the application path when exactly one application is registered. With several applications, the server displays an overview containing their current names and links. With no applications, the overview displays an empty state. The overview supports English and German according to the request culture and requires no UI plugin. + +For deployments with a `ContextPath`, the same behavior is available at that prefix with or without a trailing slash. Generated links and redirect targets include the prefix. Application URLs and their authentication checks remain unchanged. An application already mounted directly at an entry point retains that route, including when redirects are disabled. + +To select one application even when several are installed, configure `WebExpress:Root:ApplicationId` in the active host's `settings/webexpress.settings.json`. The value is the registered application identifier exposed by `IApplicationContext.ApplicationId`, which is the application class's fully qualified name in lowercase. It is not the display name or URL. An unknown identifier displays the overview instead of redirecting to an arbitrary target. + +```json +{ + "WebExpress": { + "Root": { + "ApplicationId": "example.plugin.application" + } + } +} +``` + +To always display the overview, set `WebExpress:Root:RedirectEnabled` to `false`. This setting takes precedence over `ApplicationId`. Omitting the `Root` block restores automatic selection. Apply configuration changes by restarting the host. + +```json +{ + "WebExpress": { + "Root": { + "RedirectEnabled": false + } + } +} +``` + +For container configuration, the equivalent environment variables are `WEBEXPRESS_WebExpress__Root__ApplicationId` and `WEBEXPRESS_WebExpress__Root__RedirectEnabled`. Redirects use HTTP 302 with `Cache-Control: no-store` so application changes are reflected on the next visit. Root query parameters do not override the configured target or propagate to application links. + +## Email delivery + +For administration in a WebApp application, open **Settings > System > Email**. The page and its navigation entry are available only when the running manager has `WebExpress:Email:Enabled` set to `true`, and require the existing system access policy. An omitted or disabled email block hides the entry and makes the route unavailable. The page displays effective policy values, SMTP profiles and current provider registration without account names, passwords or provider-specific options. It remains read-only; configuration changes require a restart. Local status does not test network connectivity or delivery. + +For application mail, use `IComponentHub.EmailManager` from `WebExpress.WebCore.WebEmail`. The shared manager supports plain text, HTML, alternative text and HTML bodies, To, Cc, Bcc, Reply-To and binary attachments. The built-in SMTP provider uses MailKit, and plugins can register other delivery providers through `IEmailProvider`. The [Email model](https://github.com/webexpress-framework/WebExpress/blob/main/docs/development_guide.md#email-model) describes the contracts and lifecycle. + +For SMTP configuration, add an `Email` block inside `WebExpress` in the host's settings JSON. Email is disabled when the block is omitted or `Enabled` is false. Profile names and provider names are case insensitive. Settings are bound when the component hub is created, so changes require a host restart. + +```json +{ + "WebExpress": { + "Email": { + "Enabled": true, + "DefaultProfile": "default", + "DeduplicationHours": 168, + "MaxAttachmentBytes": 26214400, + "Profiles": { + "default": { + "Provider": "smtp", + "From": "Example Service ", + "Host": "smtp.example.org", + "Port": 587, + "Security": "StartTls", + "TimeoutSeconds": 60 + } + } + } + } +} +``` + +For authentication, supply `WEBEXPRESS_WebExpress__Email__Profiles__default__UserName` and `WEBEXPRESS_WebExpress__Email__Profiles__default__Password` through deployment secrets. Leave both unset for a relay that does not require authentication. The built-in provider uses username and password authentication; OAuth token acquisition and provider HTTP APIs belong in custom providers. `Options` holds provider-specific string values and is passed only to the selected implementation. + +For transport protection, `StartTls` requires the server to advertise and complete STARTTLS. `SslOnConnect` starts TLS immediately and normally uses port 465. Normal server certificate and hostname validation remain enabled. `None` is intended for an explicitly selected local development relay and rejects configured credentials. The mail TLS settings do not change the HTTP development listener. + +For submitting a notification, inject `IEmailManager` into a framework component or use the component hub property. Pass `applicationContext.ApplicationId.ToString()` as the stable application namespace. Persist and reuse a business delivery identifier when several replicas can process the same event. A newly constructed `EmailMessage` otherwise gets a new random identifier. + +```csharp +using WebExpress.WebCore.WebEmail; + +var message = new EmailMessage +{ + DeliveryId = "invoice-1042-notification-v1", + To = ["Customer "], + Subject = "Your invoice", + TextBody = "Your invoice is attached.", + HtmlBody = "

Your invoice is attached.

", + Attachments = + [ + new EmailAttachment + { + FileName = "invoice.txt", + ContentType = "text/plain", + Content = System.Text.Encoding.UTF8.GetBytes("Invoice 1042") + } + ] +}; + +EmailSendResult result = await emailManager.SendAsync +( + applicationContext.ApplicationId.ToString(), + message, + cancellationToken: cancellationToken +); +``` + +For content ownership, the manager copies attachment bytes and recipient values before asynchronous work starts. Callers retain ownership of their data and must not mutate it during the call that creates the snapshot. Set only `TextBody` for plain text, only `HtmlBody` for HTML, or both for a MIME alternative. The application escapes untrusted values when generating HTML. Attachment filenames cannot contain directory separators or header control characters. The combined unencoded attachment limit defaults to 25 MiB; MIME encoding increases the transmitted size, and the provider can impose a smaller limit. + +For named profiles, supply `profile: "transactional"` to `SendAsync` and configure the corresponding entry under `Profiles`. A message can override `From` and set `ReplyTo`. Profile selection is a routing convenience for trusted application code, not a permission boundary. Changing profiles does not bypass a claim for the same application and delivery identifier. + +For delivery results, `Accepted` means the provider accepted the submission, not that it reached an inbox. `AlreadyAttempted` means the same logical delivery was already claimed and may still be running, may have succeeded, or may have an uncertain outcome. The API performs direct asynchronous submission and does not provide a durable outbox, background queue, bounce processing or automatic retries. + +For failure handling, catch `EmailException` and inspect `Error`. `Disabled`, `Configuration`, `InvalidMessage`, `StoreUnavailable` and `Stopping` stop submission before calling the provider. `DeliveryFailed` and `Timeout` may follow partial recipient acceptance or a lost server acknowledgement. Caller cancellation uses `OperationCanceledException`. Do not expose `InnerException` to HTTP clients because provider diagnostics can contain sensitive data. The central log records `email.started`, `email.accepted`, `email.duplicate`, `email.cancelled` and `email.failed` with an opaque correlation hash; error entries contain the category and cause type without bodies, addresses, subject lines or credentials. + +For cluster deployments, configure a shared `Cluster:StatePath` or install a shared `IClusterStore` on every replica. The manager atomically claims the application and delivery identifier before contacting the provider. A cluster configured with peers but without a shared store rejects sending. Standalone deployments use the in-memory store unless configured otherwise, so claims disappear on process restart. A shared file store preserves claims across restarts and must use a filesystem with the atomic operations described in the Cluster model. + +For duplicate protection, claims remain for `DeduplicationHours`, including failed, cancelled and interrupted attempts. The default is 168 hours and the supported range is 1 through 8760 hours. A crash after claiming but before submission can therefore suppress an unsent message. After expiry the same identifier can be sent again. This policy prevents repeated attempts during the retention window but does not promise exactly-once delivery. Applications requiring durable recovery should retain an outbox in their primary database and reconcile uncertain outcomes with the provider before creating a new delivery identifier. + +For graceful shutdown, admitted sends participate in `ServerLifetime` draining, and new sends are rejected when draining starts. The per-profile delivery deadline defaults to 60 seconds and accepts values from 1 through 300 seconds. Select `Shutdown: "graceful"` and a sufficient shutdown budget when accepted messages should finish before shared resources are released. Immediate termination or an exhausted shutdown budget can leave the delivery outcome unknown. diff --git a/global.json b/global.json new file mode 100644 index 00000000..3140116d --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +} diff --git a/src/WebExpress.WebCore.Test/Data/MockIdentityFactory.cs b/src/WebExpress.WebCore.Test/Data/MockIdentityFactory.cs index afb77fc9..5194cb4e 100644 --- a/src/WebExpress.WebCore.Test/Data/MockIdentityFactory.cs +++ b/src/WebExpress.WebCore.Test/Data/MockIdentityFactory.cs @@ -1,5 +1,4 @@ -using System.Security; -using WebExpress.WebCore.WebIdentity; +using WebExpress.WebCore.WebIdentity; namespace WebExpress.WebCore.Test.Data { @@ -38,12 +37,12 @@ public static IIdentityGroup GetIdentityGroup(string name) private static IEnumerable CreateTestGroups() { var group1 = new MockIdentityGroup(id: Guid.NewGuid(), name: "Admins"); - group1.Assign(["webexpress.webcore.test.testidentitypolicya", "webexpress.webcore.test.testidentitypolicyb"]); + group1.Assign([new TestIdentityPolicyA(), new TestIdentityPolicyB()]); yield return group1; var group2 = new MockIdentityGroup(id: Guid.NewGuid(), name: "Users"); - group2.Assign(["webexpress.webcore.test.testidentitypolicyb"]); + group2.Assign([new TestIdentityPolicyB()]); yield return group2; @@ -59,58 +58,54 @@ private static IEnumerable CreateTestGroups() /// A list of identities. private static IEnumerable CreateTestUsers() { - var password = new SecureString(); - password.AppendChar('a'); - password.AppendChar('b'); - password.AppendChar('c'); - password.MakeReadOnly(); + const string password = "abc"; - var user = new MockIdentity(Guid.NewGuid(), "Alice", "alice@example.com", IdentityManager.ComputeHash(password)); + var user = new MockIdentity(Guid.NewGuid(), "Alice", "alice@example.com", IdentityManager.HashPassword(password)); user.Assign([_groups.ElementAt(0)]); yield return user; - user = new MockIdentity(Guid.NewGuid(), "Bob", "bob@example.com", IdentityManager.ComputeHash(password)); + user = new MockIdentity(Guid.NewGuid(), "Bob", "bob@example.com", IdentityManager.HashPassword(password)); user.Assign([_groups.ElementAt(1)]); yield return user; - user = new MockIdentity(Guid.NewGuid(), "Charlie", "charlie@example.com", IdentityManager.ComputeHash(password)); + user = new MockIdentity(Guid.NewGuid(), "Charlie", "charlie@example.com", IdentityManager.HashPassword(password)); user.Assign([_groups.ElementAt(2)]); yield return user; - user = new MockIdentity(Guid.NewGuid(), "David", "david@example.com", IdentityManager.ComputeHash(password)); + user = new MockIdentity(Guid.NewGuid(), "David", "david@example.com", IdentityManager.HashPassword(password)); user.Assign([_groups.ElementAt(1), _groups.ElementAt(2)]); yield return user; - user = new MockIdentity(Guid.NewGuid(), "Eve", "eve@example.com", IdentityManager.ComputeHash(password)); + user = new MockIdentity(Guid.NewGuid(), "Eve", "eve@example.com", IdentityManager.HashPassword(password)); user.Assign([_groups.ElementAt(1)]); yield return user; - user = new MockIdentity(Guid.NewGuid(), "Frank", "frank@example.com", IdentityManager.ComputeHash(password)); + user = new MockIdentity(Guid.NewGuid(), "Frank", "frank@example.com", IdentityManager.HashPassword(password)); user.Assign([_groups.ElementAt(1)]); yield return user; - user = new MockIdentity(Guid.NewGuid(), "Grace", "grace@example.com", IdentityManager.ComputeHash(password)); + user = new MockIdentity(Guid.NewGuid(), "Grace", "grace@example.com", IdentityManager.HashPassword(password)); user.Assign([_groups.ElementAt(1)]); yield return user; - user = new MockIdentity(Guid.NewGuid(), "Heidi", "heidi@example.com", IdentityManager.ComputeHash(password)); + user = new MockIdentity(Guid.NewGuid(), "Heidi", "heidi@example.com", IdentityManager.HashPassword(password)); user.Assign([_groups.ElementAt(1)]); yield return user; - user = new MockIdentity(Guid.NewGuid(), "Ivan", "ivan@example.com", IdentityManager.ComputeHash(password)); + user = new MockIdentity(Guid.NewGuid(), "Ivan", "ivan@example.com", IdentityManager.HashPassword(password)); user.Assign([_groups.ElementAt(1)]); yield return user; - user = new MockIdentity(Guid.NewGuid(), "Judy", "judy@example.com", IdentityManager.ComputeHash(password)); + user = new MockIdentity(Guid.NewGuid(), "Judy", "judy@example.com", IdentityManager.HashPassword(password)); user.Assign([_groups.ElementAt(1)]); yield return user; diff --git a/src/WebExpress.WebCore.Test/Data/MockIdentityGroup.cs b/src/WebExpress.WebCore.Test/Data/MockIdentityGroup.cs index 0c30ec15..0ec0957f 100644 --- a/src/WebExpress.WebCore.Test/Data/MockIdentityGroup.cs +++ b/src/WebExpress.WebCore.Test/Data/MockIdentityGroup.cs @@ -7,7 +7,7 @@ namespace WebExpress.WebCore.Test.Data /// internal class MockIdentityGroup : IIdentityGroup { - private readonly List _roles = []; + private readonly List _policies = []; /// /// Gets or sets the id of the group. @@ -20,9 +20,9 @@ internal class MockIdentityGroup : IIdentityGroup public string Name { get; set; } /// - /// Gets the roles associated with the group. + /// Gets the policies associated with the group. /// - public IEnumerable Policies => _roles; + public IEnumerable Policies => _policies; /// /// Initializes a new instance of the class with the specified id and name. @@ -36,12 +36,12 @@ public MockIdentityGroup(Guid id, string name) } /// - /// Assigns roles. + /// Assigns policies. /// - /// The list of roles to assign group to. - public void Assign(IEnumerable roles) + /// The list of policies to assign to the group. + public void Assign(IEnumerable policies) { - _roles.AddRange(roles); + _policies.AddRange(policies); } } } diff --git a/src/WebExpress.WebCore.Test/Data/MockIdentityProvider.cs b/src/WebExpress.WebCore.Test/Data/MockIdentityProvider.cs index 48e08125..7b68e362 100644 --- a/src/WebExpress.WebCore.Test/Data/MockIdentityProvider.cs +++ b/src/WebExpress.WebCore.Test/Data/MockIdentityProvider.cs @@ -106,7 +106,7 @@ public IResponse CreateAuthenticationPrompt(IRequest request, IPageContext initi /// A response representing the forbidden page if this provider can handle the forbidden /// scenario; otherwise, null. /// - public IResponse CreateForbiddenPage(IRequest request, IPageContext initiator, IIdentity identity) + public IResponse CreateForbiddenResponse(IRequest request, IPageContext initiator, IIdentity identity) { return null; } diff --git a/src/WebExpress.WebCore.Test/Data/TestHealth.cs b/src/WebExpress.WebCore.Test/Data/TestHealth.cs new file mode 100644 index 00000000..f28c486e --- /dev/null +++ b/src/WebExpress.WebCore.Test/Data/TestHealth.cs @@ -0,0 +1,127 @@ +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebAttribute; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebHealth; + +namespace WebExpress.WebCore.Test.Data +{ + /// + /// Exercises public sealed component discovery, dependency injection, and optional disposal. + /// + public sealed class TestHealth : IHealth, IDisposable + { + private readonly HealthTestPluginContext _plugin; + + /// + /// Verifies that component activation supplies the binding and framework dependencies. + /// + /// The plugin and application binding under test. + /// The application injected independently of the health context. + /// The hub supplying framework managers. + /// The host configuration and diagnostic context. + /// The shared manager injected through the component registry. + /// The discovered component identifier. + private TestHealth(IHealthContext healthContext, IApplicationContext applicationContext, + IComponentHub componentHub, IHttpServerContext httpServerContext, IHealthManager healthManager, + IComponentId componentId) + { + Assert.Same(healthContext.ApplicationContext, applicationContext); + Assert.Same(componentHub.HealthManager, healthManager); + Assert.Same(healthContext.HealthId, componentId); + Assert.NotNull(httpServerContext); + _plugin = healthContext.PluginContext as HealthTestPluginContext; + if (_plugin is not null) + { + lock (_plugin) + { + _plugin.Created++; + _plugin.ActivatedContexts.Add(healthContext); + } + + if (_plugin.FailConstruction) + { + throw new InvalidOperationException("private-constructor-diagnostic"); + } + } + } + + /// + /// Executes configurable fixture behavior while default plugin discovery remains healthy. + /// + /// The budget token supplied by the health manager. + /// The configured dependency result or a successful default. + public Task CheckAsync(CancellationToken cancellationToken) + { + return _plugin is null ? Task.FromResult(HealthCheckResult.Healthy()) : _plugin.Check(cancellationToken); + } + + /// + /// Records cleanup so tests can detect premature or duplicate disposal. + /// + public void Dispose() + { + if (_plugin is not null) + { + lock (_plugin) + { + _plugin.Disposed++; + _plugin.DisposedSignal.TrySetResult(); + } + } + } + } + + /// + /// Supplies closed generic fixture types for discovery filtering and invalid timeout tests. + /// + /// The fixture type argument used to close the otherwise undiscoverable type. + [HealthTimeout(0)] + public sealed class InvalidTimeoutHealth : IHealth + { + /// + /// Fails the test if invalid component metadata is silently ignored. + /// + /// The unused dependency cancellation token. + /// No result because invalid metadata must prevent invocation. + public Task CheckAsync(CancellationToken cancellationToken) + { + throw new InvalidOperationException("Invalid metadata must prevent execution."); + } + } + + /// + /// Supplies an explicitly bounded component without polluting ordinary assembly discovery. + /// + /// The fixture type argument used to close the component. + [HealthTimeout(25)] + public sealed class TimedHealth : IHealth + { + /// + /// Waits for the declared budget to demonstrate attribute-driven cancellation. + /// + /// The token governed by the timeout attribute. + /// A task cancelled by the component's declared budget. + public async Task CheckAsync(CancellationToken cancellationToken) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return HealthCheckResult.Healthy(); + } + } + + /// + /// Ensures the public sealed discovery convention rejects extensible helper classes. + /// + public class UnsealedHealth : IHealth + { + /// + /// Makes accidental discovery visible as a failed probe. + /// + /// The unused check cancellation token. + /// An unhealthy result that must never participate in normal discovery. + public Task CheckAsync(CancellationToken cancellationToken) + { + return Task.FromResult(HealthCheckResult.Unhealthy()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Data/TestMetric.cs b/src/WebExpress.WebCore.Test/Data/TestMetric.cs new file mode 100644 index 00000000..02bdda22 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Data/TestMetric.cs @@ -0,0 +1,133 @@ +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebAttribute; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebMetrics; + +namespace WebExpress.WebCore.Test.Data +{ + /// + /// Exercises public sealed component discovery, dependency injection, and optional disposal. + /// + public sealed class TestMetric : IMetric, IDisposable + { + private readonly MetricTestPluginContext _plugin; + + /// + /// Verifies that component activation supplies the binding and framework dependencies. + /// + /// The plugin and application binding under test. + /// The application injected independently of the metric context. + /// The hub supplying framework managers. + /// The shared manager injected through the component registry. + /// The discovered component identifier. + private TestMetric(IMetricContext metricContext, IApplicationContext applicationContext, + IComponentHub componentHub, IMetricsManager metricsManager, IComponentId componentId) + { + Assert.Same(metricContext.ApplicationContext, applicationContext); + Assert.Same(componentHub.MetricsManager, metricsManager); + Assert.Same(metricContext.MetricId, componentId); + _plugin = metricContext.PluginContext as MetricTestPluginContext; + if (_plugin is not null) + { + lock (_plugin) + { + _plugin.Created++; + } + + if (_plugin.FailConstruction) + { + throw new InvalidOperationException("private-constructor-diagnostic"); + } + } + } + + /// + /// Executes configurable fixture behavior; under ordinary plugin discovery it reports one gauge. + /// + /// The collector receiving the values. + /// The budget token supplied by the metrics manager. + /// A task that completes once all values have been reported. + public Task CollectAsync(IMetricCollector collector, CancellationToken cancellationToken) + { + if (_plugin is null) + { + collector.Gauge("test_metric_value", "A value reported by the test plugin.", 1); + return Task.CompletedTask; + } + + return _plugin.Collect(collector, cancellationToken); + } + + /// + /// Records cleanup so tests can detect premature or duplicate disposal. + /// + public void Dispose() + { + if (_plugin is not null) + { + lock (_plugin) + { + _plugin.Disposed++; + _plugin.DisposedSignal.TrySetResult(); + } + } + } + } + + /// + /// Supplies a closed generic fixture type whose invalid timeout must surface as a failed collector. + /// + /// The fixture type argument used to close the otherwise undiscoverable type. + [MetricTimeout(0)] + public sealed class InvalidTimeoutMetric : IMetric + { + /// + /// Fails the test if invalid component metadata is silently ignored. + /// + /// The unused collector. + /// The unused cancellation token. + /// No result because invalid metadata must prevent invocation. + public Task CollectAsync(IMetricCollector collector, CancellationToken cancellationToken) + { + throw new InvalidOperationException("Invalid metadata must prevent execution."); + } + } + + /// + /// Supplies an explicitly bounded component without polluting ordinary assembly discovery. + /// + /// The fixture type argument used to close the component. + [MetricTimeout(25)] + public sealed class TimedMetric : IMetric + { + /// + /// Waits beyond the declared budget to demonstrate attribute-driven cancellation. + /// + /// The unused collector. + /// The token governed by the timeout attribute. + /// A task cancelled by the component's declared budget. + public async Task CollectAsync(IMetricCollector collector, CancellationToken cancellationToken) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + } + + /// + /// Ensures the public sealed discovery convention rejects extensible helper classes. + /// + public class UnsealedMetric : IMetric + { + /// + /// Makes accidental discovery visible as an unexpected series. + /// + /// The collector receiving the values. + /// The unused cancellation token. + /// A completed task. + public Task CollectAsync(IMetricCollector collector, CancellationToken cancellationToken) + { + collector.Gauge("unsealed_metric", "Must never be discovered.", 1); + return Task.CompletedTask; + } + } +} diff --git a/src/WebExpress.WebCore.Test/Fixture/AuthenticationFixture.cs b/src/WebExpress.WebCore.Test/Fixture/AuthenticationFixture.cs new file mode 100644 index 00000000..b4dc6956 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Fixture/AuthenticationFixture.cs @@ -0,0 +1,120 @@ +using Microsoft.Extensions.Configuration; +using System.Security.Cryptography; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebIdentity; +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebPlugin; +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.Test.Fixture +{ + /// + /// Provides isolated authentication configuration and durable markers for regression tests. + /// + internal sealed class AuthenticationFixture : IDisposable + { + internal AuthenticationSettings Settings { get; } = new() + { + Issuer = "https://webexpress.test", Audience = "webexpress-tests", + SigningKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)), + TokenStorePath = Path.Combine(Path.GetTempPath(), "webexpress-auth-" + Guid.NewGuid().ToString("N")) + }; + internal ComponentHub Hub { get; } + internal IHttpServerContext Server { get; } + internal IApplicationContext Application { get; } + internal IdentityManager Manager => (IdentityManager)Hub.IdentityManager; + internal TestClock Clock { get; } = new(); + + /// + /// Creates an isolated signing authority and durable token directory for each test. + /// + /// Whether the test retains the production transport requirement. + /// Whether the deployment configures a location for the default file store. + internal AuthenticationFixture(bool requireHttps = true, bool withTokenStorePath = true) + { + Settings.RequireHttps = requireHttps; + if (!withTokenStorePath) { Settings.TokenStorePath = null; } + var configuration = Configuration(); + Server = UnitTestFixture.CreateHttpServerContextMock(configuration: configuration); + Hub = UnitTestFixture.CreateComponentHubMock(Server); + ((PluginManager)Hub.PluginManager).Register(); + Application = Hub.ApplicationManager.GetApplications(typeof(TestApplicationA)).First(); + configuration["WebExpress:Authentication:ApplicationId"] = Application.ApplicationId; + Manager.Clock = Clock; + } + + /// + /// Simulates another server instance that shares the hub's token store but reads the current settings, + /// so a changed issuer or signing key yields an independent signing authority. + /// + /// A separate identity manager driven by the fixture clock. + internal IdentityManager Instance() + { + var server = UnitTestFixture.CreateHttpServerContextMock(configuration: Configuration()); + var manager = ComponentActivator.CreateInstance(typeof(IdentityManager), server, Hub); + manager.Clock = Clock; + return manager; + } + + /// + /// Projects the current settings into the configuration section read by the identity manager. + /// + /// The configuration containing the authentication section. + private IConfigurationRoot Configuration() + { + return new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["WebExpress:Authentication:Issuer"] = Settings.Issuer, + ["WebExpress:Authentication:Audience"] = Settings.Audience, + ["WebExpress:Authentication:SigningKey"] = Settings.SigningKey, + ["WebExpress:Authentication:RequireHttps"] = Settings.RequireHttps.ToString(), + ["WebExpress:Authentication:TokenStorePath"] = Settings.TokenStorePath + }).Build(); + } + + /// + /// Builds a request with explicit transport semantics without allocating application session state. + /// + /// The HTTP headers required for the boundary scenario. + /// The HTTP method used to exercise the endpoint contract. + /// The request route or cookie path that constrains credential use. + /// The payload serialized or supplied for the authentication request. + /// Whether the request uses HTTPS rather than development HTTP. + /// The request configured for the isolated application and selected transport. + internal RequestBase Request(string headers = "", string method = "GET", string path = "/", string body = null, bool https = true) + { + var content = $"{method} {path} HTTP/1.1\r\nCookie:\r\n{headers}"; + if (body is not null) { content += $"Content-Type: application/json\r\nContent-Length: {System.Text.Encoding.UTF8.GetByteCount(body)}\r\n"; } + var request = (RequestBase)UnitTestFixture.CreateRequestMock(content + "\r\n" + body); + request.ApplicationContext = Application; + var scheme = https ? global::WebExpress.WebCore.WebUri.UriScheme.Https : global::WebExpress.WebCore.WebUri.UriScheme.Http; + typeof(RequestBase).GetProperty(nameof(RequestBase.Scheme)).SetValue(request, scheme); + request.Uri.Scheme = scheme; + return request; + } + + /// + /// Removes only the isolated marker directory created by this fixture. + /// + public void Dispose() + { + Hub.IdentityProviderManager.Dispose(); + Hub.IdentityTokenStoreManager.Dispose(); + if (Directory.Exists(Settings.TokenStorePath)) { Directory.Delete(Settings.TokenStorePath, true); } + } + + /// + /// Makes signed token expiration deterministic without delaying tests. + /// + internal sealed class TestClock : TimeProvider + { + internal DateTimeOffset Now = DateTimeOffset.UtcNow; + /// + /// Allows expiry boundaries to be exercised without sleeping. + /// + /// The current simulated UTC time. + public override DateTimeOffset GetUtcNow() => Now; + } + } +} diff --git a/src/WebExpress.WebCore.Test/Fixture/HealthTestFixture.cs b/src/WebExpress.WebCore.Test/Fixture/HealthTestFixture.cs new file mode 100644 index 00000000..695437ea --- /dev/null +++ b/src/WebExpress.WebCore.Test/Fixture/HealthTestFixture.cs @@ -0,0 +1,187 @@ +using System.Reflection; +using System.Reflection.Emit; +using WebExpress.WebCore.Test.Data; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebHealth; +using WebExpress.WebCore.WebPlugin; + +namespace WebExpress.WebCore.Test.Fixture +{ + /// + /// Supplies isolated plugin assemblies and dependency behavior while exercising real component discovery. + /// + internal static class HealthTestFixture + { + /// + /// Binds a configurable test component through the same discovery method used by lifecycle events. + /// + /// The health manager under test. + /// The application owning the test dependency. + /// The diagnostic identifier of the isolated contributing plugin. + /// The behavior executed by the discovered test component. + /// An optional short budget for testing timeout behavior. + /// The plugin binding whose disposal simulates plugin removal. + internal static HealthTestRegistration Register(IHealthManager manager, IApplicationContext application, + string name, Func> check, TimeSpan? timeout = null) + { + var plugin = new HealthTestPluginContext(name, typeof(TestHealth)) { Check = check }; + ((HealthManager)manager).Register(plugin, [application]); + var context = manager.HealthChecks.SingleOrDefault(x => x.PluginContext == plugin); + if (timeout.HasValue && context is HealthContext metadata) + { + metadata.Timeout = timeout.Value; + } + + return new HealthTestRegistration((HealthManager)manager, plugin); + } + + /// + /// Emits a public sealed application whose constructor throws. + /// + /// + /// The application manager only discovers public top-level types, and such a type in the + /// test assembly would be registered - and fail - in every test that loads the test plugin. + /// A type in its own dynamic assembly is visible only to the plugin context it is handed to. + /// + /// The message of the exception the constructor throws. + /// The emitted application type. + internal static Type CreateThrowingApplication(string message) + { + var assembly = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName("WebExpress.WebCore.Test.ThrowingApplication"), + AssemblyBuilderAccess.RunAndCollect); + var type = assembly.DefineDynamicModule("ThrowingApplication").DefineType("WebExpress.WebCore.Test.ThrowingApplication", + TypeAttributes.Public | TypeAttributes.Sealed | TypeAttributes.Class, typeof(object), [typeof(IApplication), typeof(IDisposable)]); + + var constructor = type.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard, Type.EmptyTypes).GetILGenerator(); + constructor.Emit(OpCodes.Ldstr, message); + constructor.Emit(OpCodes.Newobj, typeof(InvalidOperationException).GetConstructor([typeof(string)])); + constructor.Emit(OpCodes.Throw); + + foreach (var declaration in new[] { typeof(IApplication).GetMethod(nameof(IApplication.Run)), typeof(IDisposable).GetMethod(nameof(IDisposable.Dispose)) }) + { + var method = type.DefineMethod(declaration.Name, MethodAttributes.Public | MethodAttributes.Virtual | MethodAttributes.Final | + MethodAttributes.HideBySig | MethodAttributes.NewSlot, typeof(void), Type.EmptyTypes); + method.GetILGenerator().Emit(OpCodes.Ret); + type.DefineMethodOverride(method, declaration); + } + + return type.CreateType(); + } + } + + /// + /// Associates controllable dependency behavior with an otherwise normal plugin context. + /// + internal sealed class HealthTestPluginContext : PluginContext + { + /// + /// Gets or sets the behavior executed by the test component. + /// + internal Func> Check { get; set; } = + _ => Task.FromResult(HealthCheckResult.Healthy()); + + /// + /// Gets or sets whether component construction must fail before a check can execute. + /// + internal bool FailConstruction { get; set; } + + /// + /// Gets or sets the number of component instances created for this plugin. + /// + internal int Created { get; set; } + + /// + /// Gets or sets the number of component instances disposed for this plugin. + /// + internal int Disposed { get; set; } + + /// + /// Gets the injected contexts recorded when each application binding is activated. + /// + internal List ActivatedContexts { get; } = []; + + /// + /// Signals that a component was released after its active check finished. + /// + internal TaskCompletionSource DisposedSignal { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// + /// Restricts discovery to explicit fixture types without loading another physical assembly. + /// + /// The unique plugin identifier used by the test. + /// The component types exposed by this isolated assembly. + internal HealthTestPluginContext(string name, params Type[] types) + { + PluginId = new ComponentId(name); + Assembly = new HealthTestAssembly(types); + } + } + + /// + /// Exposes a controlled type inventory to the real discovery pipeline. + /// + internal sealed class HealthTestAssembly : Assembly + { + private readonly Type[] _types; + + /// + /// Keeps unrelated test components out of an isolated discovery scenario. + /// + /// The types visible to the plugin's discovery operation. + internal HealthTestAssembly(Type[] types) + { + _types = types; + } + + /// + /// Provides the declared fixture inventory to component discovery. + /// + /// The types supplied by the test. + public override Type[] GetTypes() + { + return _types; + } + + /// + /// Provides the declared fixture inventory to application discovery, which reads exported types only. + /// + /// The types supplied by the test. + public override Type[] GetExportedTypes() + { + return _types; + } + } + + /// + /// Simulates removal of an isolated contributing plugin without modifying other bindings. + /// + internal sealed class HealthTestRegistration : IDisposable + { + private readonly HealthManager _manager; + + /// + /// Gets the plugin state used to inspect activation and disposal. + /// + internal HealthTestPluginContext Plugin { get; } + + /// + /// Associates cleanup with the manager that owns the test plugin's bindings. + /// + /// The manager owning the bindings. + /// The isolated plugin removed during cleanup. + internal HealthTestRegistration(HealthManager manager, HealthTestPluginContext plugin) + { + _manager = manager; + Plugin = plugin; + } + + /// + /// Runs the manager's normal plugin removal path. + /// + public void Dispose() + { + _manager.Remove(Plugin); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Fixture/MetricTestFixture.cs b/src/WebExpress.WebCore.Test/Fixture/MetricTestFixture.cs new file mode 100644 index 00000000..412758dd --- /dev/null +++ b/src/WebExpress.WebCore.Test/Fixture/MetricTestFixture.cs @@ -0,0 +1,122 @@ +using WebExpress.WebCore.Test.Data; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebMetrics; +using WebExpress.WebCore.WebPlugin; + +namespace WebExpress.WebCore.Test.Fixture +{ + /// + /// Supplies isolated plugin assemblies and collection behavior while exercising real component discovery. + /// + internal static class MetricTestFixture + { + /// + /// Binds a configurable test component through the same discovery method used by lifecycle events. + /// + /// The metrics manager under test. + /// The application owning the test component. + /// The diagnostic identifier of the isolated contributing plugin. + /// The behavior executed by the discovered test component. + /// An optional short budget for testing timeout behavior. + /// The plugin binding whose disposal simulates plugin removal. + internal static MetricTestRegistration Register(IMetricsManager manager, IApplicationContext application, + string name, Func collect, TimeSpan? timeout = null) + { + var plugin = new MetricTestPluginContext(name, typeof(TestMetric)) { Collect = collect }; + ((MetricsManager)manager).Register(plugin, [application]); + var context = manager.Metrics.SingleOrDefault(x => x.PluginContext == plugin); + if (timeout.HasValue && context is MetricContext metadata) + { + metadata.Timeout = timeout.Value; + } + + return new MetricTestRegistration((MetricsManager)manager, plugin); + } + + /// + /// Renders a scrape the way the endpoint does, so assertions read like the exposition text. + /// + /// The metrics manager to scrape. + /// The exposition text. + internal static async Task ScrapeAsync(IMetricsManager manager) + { + var families = await manager.CollectAsync(TestContext.Current.CancellationToken); + return WebMetrics.Model.MetricText.Format(families); + } + } + + /// + /// Associates controllable collection behavior with an otherwise normal plugin context. + /// + internal sealed class MetricTestPluginContext : PluginContext + { + /// + /// Gets or sets the behavior executed by the test component. + /// + internal Func Collect { get; set; } = (_, _) => Task.CompletedTask; + + /// + /// Gets or sets whether component construction must fail before a collection can execute. + /// + internal bool FailConstruction { get; set; } + + /// + /// Gets or sets the number of component instances created for this plugin. + /// + internal int Created { get; set; } + + /// + /// Gets or sets the number of component instances disposed for this plugin. + /// + internal int Disposed { get; set; } + + /// + /// Signals that a component was released after its active collection finished. + /// + internal TaskCompletionSource DisposedSignal { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// + /// Restricts discovery to explicit fixture types without loading another physical assembly. + /// + /// The unique plugin identifier used by the test. + /// The component types exposed by this isolated assembly. + internal MetricTestPluginContext(string name, params Type[] types) + { + PluginId = new ComponentId(name); + Assembly = new HealthTestAssembly(types); + } + } + + /// + /// Simulates removal of an isolated contributing plugin without modifying other bindings. + /// + internal sealed class MetricTestRegistration : IDisposable + { + private readonly MetricsManager _manager; + + /// + /// Gets the plugin state used to inspect activation and disposal. + /// + internal MetricTestPluginContext Plugin { get; } + + /// + /// Associates cleanup with the manager that owns the test plugin's bindings. + /// + /// The manager owning the bindings. + /// The isolated plugin removed during cleanup. + internal MetricTestRegistration(MetricsManager manager, MetricTestPluginContext plugin) + { + _manager = manager; + Plugin = plugin; + } + + /// + /// Runs the manager's normal plugin removal path. + /// + public void Dispose() + { + _manager.Remove(Plugin); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs b/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs index 5e42c772..835ac107 100644 --- a/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs +++ b/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Configuration; using System.Globalization; using System.Net; using System.Reflection; @@ -32,8 +33,14 @@ public UnitTestFixture() /// /// Create a fake server context. /// + /// + /// The settings directory. Defaults to a directory of its own, so a test that deploys a + /// settings file never sees one left behind by another. + /// + /// The configuration. Defaults to an empty one. + /// The optional public base URI. /// The server context. - public static IHttpServerContext CreateHttpServerContextMock() + public static IHttpServerContext CreateHttpServerContextMock(string settingsPath = null, IConfigurationRoot configuration = null, string externalUri = null) { return new HttpServerContext ( @@ -42,10 +49,12 @@ public static IHttpServerContext CreateHttpServerContextMock() Path.Combine(Environment.CurrentDirectory, Guid.NewGuid().ToString()), Environment.CurrentDirectory, Environment.CurrentDirectory, - Environment.CurrentDirectory, + settingsPath ?? Path.Combine(Environment.CurrentDirectory, Guid.NewGuid().ToString()), + configuration ?? new ConfigurationBuilder().Build(), CultureInfo.GetCultureInfo("en"), new Log() { LogMode = LogMode.Off }, - null + null, + externalUri: externalUri ); } @@ -82,9 +91,9 @@ public static ComponentHub CreateComponentHubMock(IHttpServerContext httpServerC /// Create a component hub and register the plugins. /// /// The component hub. - public static ComponentHub CreateAndRegisterComponentHubMock() + public static ComponentHub CreateAndRegisterComponentHubMock(IHttpServerContext httpServerContext = null) { - var componentHub = CreateComponentHubMock(); + var componentHub = CreateComponentHubMock(httpServerContext); var pluginManager = componentHub.PluginManager as PluginManager; pluginManager.Register(); @@ -196,8 +205,10 @@ public static WebMessage.HttpContext CreateHttpContextMock(string content = "") foreach (var line in filteredLines) { - var key = line.Split(':').FirstOrDefault().Trim(); - var value = line.Split(':').Skip(1).FirstOrDefault().Trim(); + // split at the first colon only: values such as an origin contain further ones + var colon = line.IndexOf(':'); + var key = (colon < 0 ? line : line[..colon]).Trim(); + var value = colon < 0 ? "" : line[(colon + 1)..].Trim(); requestFeature.Headers[key] = value; } diff --git a/src/WebExpress.WebCore.Test/Html/Parser/UnitTestHtmlElementFactory.cs b/src/WebExpress.WebCore.Test/Html/Parser/UnitTestHtmlElementFactory.cs index b0b162f6..a223dd73 100644 --- a/src/WebExpress.WebCore.Test/Html/Parser/UnitTestHtmlElementFactory.cs +++ b/src/WebExpress.WebCore.Test/Html/Parser/UnitTestHtmlElementFactory.cs @@ -115,14 +115,13 @@ public void NullTagName_Throws() } /// - /// Both 'kbd' and 'kdb' map to the existing . + /// Both 'kbd' and the legacy 'kdb' map to . /// [Fact] - public void KbdTag_MapsToKdbElement() + public void KbdTag_MapsToKbdElement() { - var element = HtmlElementFactory.Create("kbd"); - - Assert.IsType(element); + Assert.IsType(HtmlElementFactory.Create("kbd")); + Assert.IsType(HtmlElementFactory.Create("kdb")); } /// diff --git a/src/WebExpress.WebCore.Test/Html/Parser/UnitTestHtmlParser.cs b/src/WebExpress.WebCore.Test/Html/Parser/UnitTestHtmlParser.cs index 57387524..7291ac3a 100644 --- a/src/WebExpress.WebCore.Test/Html/Parser/UnitTestHtmlParser.cs +++ b/src/WebExpress.WebCore.Test/Html/Parser/UnitTestHtmlParser.cs @@ -486,15 +486,66 @@ public void FormWithInputs_IsReconstructed() } /// - /// The kbd tag (standard HTML) maps to HtmlElementTextSemanticsKdb. + /// The kbd tag (standard HTML) maps to HtmlElementTextSemanticsKbd. /// [Fact] - public void KbdTag_MapsToKdbElement() + public void KbdTag_MapsToKbdElement() { var nodes = Parser.Parse("Ctrl+C"); - var kbd = nodes.OfType().Single(); + var kbd = nodes.OfType().Single(); Assert.NotNull(kbd); } + + /// + /// Elements with content models keep their children instead of ending the parse at + /// their closing tag. + /// + [Theory] + [InlineData("
Address
")] + [InlineData("")] + [InlineData("")] + [InlineData("

fallback

")] + [InlineData("")] + [InlineData("")] + public void ContainerElement_KeepsChildrenAndFollowingSibling(string markup) + { + var nodes = Parser.Parse(markup + "

after

"); + var container = nodes.OfType().First(); + + Assert.Equal(2, nodes.Count()); + Assert.Single(container.Elements); + Assert.IsType(nodes.Last()); + } + + /// + /// Void elements are written back without a closing tag and leave their + /// following sibling outside. + /// + [Theory] + [InlineData("", typeof(HtmlElementEmbeddedEmbed))] + [InlineData("", typeof(HtmlElementFormKeygen))] + public void VoidElement_RoundTripsWithoutClosingTag(string markup, System.Type type) + { + var nodes = Parser.Parse(markup + "

after

"); + var element = nodes.First(); + + Assert.IsType(type, element); + Assert.Empty(((HtmlElement)element).Elements); + Assert.DoesNotContain(" + /// A col inside a colgroup is written back as a void element. + ///
+ [Fact] + public void ColInColgroup_RoundTripsWithoutClosingTag() + { + var nodes = Parser.Parse(""); + var colgroup = nodes.OfType().Single(); + + Assert.Equal(2, colgroup.Elements.OfType().Count()); + Assert.DoesNotContain("", colgroup.ToString()); + } } } diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestDeterministicId.cs b/src/WebExpress.WebCore.Test/Html/UnitTestDeterministicId.cs index d2aec909..8dc19912 100644 --- a/src/WebExpress.WebCore.Test/Html/UnitTestDeterministicId.cs +++ b/src/WebExpress.WebCore.Test/Html/UnitTestDeterministicId.cs @@ -55,6 +55,25 @@ public void CreateDifferentCallsites() } + /// + /// Tests that repeated calls from the same call site (as happens when controls are created + /// in a loop) all yield distinct ids. The previous call-stack based implementation produced + /// duplicates here because the stack is identical across iterations. + /// + [Fact] + public void CreateInLoopAreUnique() + { + // act + var ids = new HashSet(); + for (var i = 0; i < 1000; i++) + { + ids.Add(DeterministicId.Create()); + } + + // validation + Assert.Equal(1000, ids.Count); + } + /// /// Generates a deterministic identifier. /// diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlAttribute.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlAttribute.cs new file mode 100644 index 00000000..82b8c281 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlAttribute.cs @@ -0,0 +1,111 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the escaping an attribute value goes through on its way into the markup. + /// + /// + /// The writer used to emit the value verbatim, so a value carrying a double quote ended its + /// attribute early and the rest of it landed in the markup as stray attributes. Controls + /// worked around it by encoding before handing the value over; the escaping belongs to the + /// writer, and these tests pin it there. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlAttribute + { + /// + /// Tests that a double quote cannot end the attribute early. This is the case json in a + /// data attribute runs into, which is how a control hands structured state to its client. + /// + [Fact] + public void EscapesTheDoubleQuote() + { + // arrange + var html = new HtmlElementTextContentDiv() + .AddUserAttribute("data-payload", @"{""object"":""SD-1""}"); + + // act + var res = html.ToString().Trim(); + + // validation + Assert.Equal(@"
", res); + } + + /// + /// Tests that an ampersand cannot turn the text following it into an entity - a query + /// string of ?a=1&copy=2 would otherwise read a copyright sign. + /// + [Fact] + public void EscapesTheAmpersand() + { + // arrange + var html = new HtmlElementTextContentDiv() + .AddUserAttribute("href", "/search?a=1©=2"); + + // act + var res = html.ToString().Trim(); + + // validation + Assert.Equal(@"
", res); + } + + /// + /// Tests that nothing else is escaped: the apostrophe, the angle brackets and a + /// non-ascii character are all legal inside a double-quoted value on a utf-8 document, + /// and escaping them would only make the markup harder to read. + /// + /// The attribute value under test. + [Theory] + [InlineData("Guybrush's quest")] + [InlineData("a < b > c")] + [InlineData("Grüße aus Mêlée")] + public void LeavesEverythingElseAlone(string value) + { + // arrange + var html = new HtmlElementTextContentDiv() + .AddUserAttribute("title", value); + + // act + var res = html.ToString().Trim(); + + // validation + Assert.Equal($@"
", res); + } + + /// + /// Tests that the standard attributes are escaped as well - they travel through the same + /// writer, so a class is no more verbatim than a data attribute. + /// + [Fact] + public void EscapesTheStandardAttributes() + { + // arrange + var html = new HtmlElementTextContentDiv() { Class = @"a ""b"" c" }; + + // act + var res = html.ToString().Trim(); + + // validation + Assert.Equal(@"
", res); + } + + /// + /// Tests that an empty value stays an empty attribute rather than disappearing into an + /// exception. + /// + [Fact] + public void EmptyValueIsWrittenAsAnEmptyAttribute() + { + // arrange + var builder = new System.Text.StringBuilder(); + var attribute = new HtmlAttribute("data-empty", string.Empty); + + // act + attribute.ToString(builder, 0); + + // validation + Assert.Equal(@"data-empty=""""", builder.ToString()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElement.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElement.cs index bfcc0ef7..adc82dda 100644 --- a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElement.cs +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElement.cs @@ -223,5 +223,55 @@ public void ToStringWithChildrenTest() Assert.Contains("", div.ToString()); } + /// + /// Tests that formatting inside running text is written without a blank around it, + /// so a word formatted in part stays one word, while block elements keep their line. + /// + [Fact] + public void BreakNeverTouchesText() + { + // arrange + var p = new HtmlElementTextContentP + ( + new HtmlText("x"), + new HtmlElementTextSemanticsStrong(new HtmlText("b"), new HtmlElementTextSemanticsEm(new HtmlText("i"))), + new HtmlText("y "), + new HtmlElementTextSemanticsA(new HtmlText("link")) { Href = "/l" }, + new HtmlText(", z"), + new HtmlElementTextSemanticsStrong(new HtmlElementTextSemanticsEm(new HtmlText("w"))), + new HtmlText(".") + ); + var div = new HtmlElementTextContentDiv(p); + + // act + var html = div.ToString(); + + // validation + Assert.Contains(@"

xbiy ", html); + Assert.Contains(@"link, zw.", html); + Assert.Matches(@"

\r?\n\s*

", html); + } + + ///

+ /// Tests that two neighbouring elements keep the break between them, which renders as + /// the blank that controls rely on between an icon and its label. + /// + [Fact] + public void BreakBetweenElementsIsKept() + { + // arrange + var link = new HtmlElementTextSemanticsA + ( + new HtmlElementTextSemanticsI() { Class = "icon" }, + new HtmlElementTextSemanticsSpan(new HtmlText("Label")) + ); + + // act + var html = link.ToString(); + + // validation + Assert.Matches(@"\r?\n\s*Label", html); + } + } } diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEditDel.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEditDel.cs new file mode 100644 index 00000000..89bc015b --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEditDel.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementEditDel class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementEditDel + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementEditDel(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEditIns.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEditIns.cs new file mode 100644 index 00000000..96d8d848 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEditIns.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementEditIns class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementEditIns + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementEditIns(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedEmbed.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedEmbed.cs new file mode 100644 index 00000000..9bb17ad2 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedEmbed.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementEmbeddedEmbed class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementEmbeddedEmbed + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementEmbeddedEmbed(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedIframe.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedIframe.cs new file mode 100644 index 00000000..9c53ad16 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedIframe.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementEmbeddedIframe class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementEmbeddedIframe + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementEmbeddedIframe(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedObject.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedObject.cs new file mode 100644 index 00000000..e5037ee4 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedObject.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementEmbeddedObject class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementEmbeddedObject + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementEmbeddedObject(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedParam.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedParam.cs new file mode 100644 index 00000000..f5b74c2f --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedParam.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementEmbeddedParam class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementEmbeddedParam + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementEmbeddedParam(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedPicture.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedPicture.cs new file mode 100644 index 00000000..979f04f7 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedPicture.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementEmbeddedPicture class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementEmbeddedPicture + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementEmbeddedPicture(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedSource.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedSource.cs new file mode 100644 index 00000000..806e220d --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedSource.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementEmbeddedSource class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementEmbeddedSource + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementEmbeddedSource(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldButton.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldButton.cs new file mode 100644 index 00000000..71568396 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldButton.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFieldButton class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFieldButton + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFieldButton(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldInput.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldInput.cs new file mode 100644 index 00000000..59fd0cad --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldInput.cs @@ -0,0 +1,24 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFieldInput class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFieldInput + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFieldInput(); + + // validation + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldLegend.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldLegend.cs new file mode 100644 index 00000000..4cd19697 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldLegend.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFieldLegend class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFieldLegend + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFieldLegend(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldSelect.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldSelect.cs new file mode 100644 index 00000000..1bf36520 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldSelect.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFieldSelect class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFieldSelect + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFieldSelect(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormDatalist.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormDatalist.cs new file mode 100644 index 00000000..7a11ba00 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormDatalist.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFormDatalist class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFormDatalist + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFormDatalist(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormFieldset.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormFieldset.cs new file mode 100644 index 00000000..b486f26a --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormFieldset.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFormFieldset class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFormFieldset + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFormFieldset(); + + Assert.Equal(@"
", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormForm.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormForm.cs new file mode 100644 index 00000000..aa086fb3 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormForm.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFormForm class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFormForm + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFormForm(); + + Assert.Equal(@"
", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormKeygen.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormKeygen.cs new file mode 100644 index 00000000..25fed375 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormKeygen.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFormKeygen class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFormKeygen + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFormKeygen(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormMeter.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormMeter.cs new file mode 100644 index 00000000..a9af3910 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormMeter.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFormMeter class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFormMeter + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFormMeter(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormOptgroup.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormOptgroup.cs new file mode 100644 index 00000000..4aae7c76 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormOptgroup.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFormOptgroup class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFormOptgroup + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFormOptgroup(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormOption.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormOption.cs new file mode 100644 index 00000000..3a36d4b7 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormOption.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFormOption class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFormOption + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFormOption(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormOutput.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormOutput.cs new file mode 100644 index 00000000..84dd138c --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormOutput.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFormOutput class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFormOutput + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFormOutput(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormProgress.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormProgress.cs new file mode 100644 index 00000000..cdde1f6a --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormProgress.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFormProgress class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFormProgress + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFormProgress(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormTextarea.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormTextarea.cs new file mode 100644 index 00000000..655d6e34 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormTextarea.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFormTextarea class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFormTextarea + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFormTextarea(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveCommand.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveCommand.cs new file mode 100644 index 00000000..98e44409 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveCommand.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementInteractiveCommand class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementInteractiveCommand + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementInteractiveCommand(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveDetails.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveDetails.cs new file mode 100644 index 00000000..9e5944d7 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveDetails.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementInteractiveDetails class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementInteractiveDetails + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementInteractiveDetails(); + + Assert.Equal(@"
", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveDialog.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveDialog.cs new file mode 100644 index 00000000..a175512d --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveDialog.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementInteractiveDialog class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementInteractiveDialog + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementInteractiveDialog(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveMenu.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveMenu.cs new file mode 100644 index 00000000..1eaf7b8d --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveMenu.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementInteractiveMenu class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementInteractiveMenu + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementInteractiveMenu(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveSummary.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveSummary.cs new file mode 100644 index 00000000..79701f70 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveSummary.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementInteractiveSummary class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementInteractiveSummary + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementInteractiveSummary(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataHead.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataHead.cs new file mode 100644 index 00000000..a96a656e --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataHead.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementMetadataHead class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementMetadataHead + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementMetadataHead(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataLink.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataLink.cs new file mode 100644 index 00000000..81750245 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataLink.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementMetadataLink class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementMetadataLink + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementMetadataLink(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataMeta.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataMeta.cs new file mode 100644 index 00000000..2c56844f --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataMeta.cs @@ -0,0 +1,40 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementMetadataMeta class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementMetadataMeta + { + /// + /// Tests the one key that names a declaration by itself and therefore + /// carries its value directly. + /// + [Fact] + public void KeyValue() + { + // act + var html = new HtmlElementMetadataMeta("charset", "utf-8"); + + Assert.Equal(@"", html.Trim()); + } + + /// + /// Tests that every other key becomes a name/content pair. Rendered as a + /// single attribute the declaration is markup no browser acts on, which + /// is what left the viewport without effect on small screens. + /// + [Theory] + [InlineData("viewport", "width=device-width, initial-scale=1")] + [InlineData("description", "A page.")] + public void NamedValue(string key, string value) + { + // act + var html = new HtmlElementMetadataMeta(key, value); + + Assert.Equal($"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataStyle.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataStyle.cs new file mode 100644 index 00000000..6d3fd09e --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataStyle.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementMetadataStyle class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementMetadataStyle + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementMetadataStyle(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataTitle.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataTitle.cs new file mode 100644 index 00000000..57b35d77 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataTitle.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementMetadataTitle class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementMetadataTitle + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementMetadataTitle(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaArea.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaArea.cs new file mode 100644 index 00000000..7b4205bf --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaArea.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementMultimediaArea class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementMultimediaArea + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementMultimediaArea(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaAudio.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaAudio.cs new file mode 100644 index 00000000..78ebfeeb --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaAudio.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementMultimediaAudio class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementMultimediaAudio + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementMultimediaAudio(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaMap.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaMap.cs new file mode 100644 index 00000000..367d7632 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaMap.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementMultimediaMap class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementMultimediaMap + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementMultimediaMap(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaMath.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaMath.cs new file mode 100644 index 00000000..85c70f20 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaMath.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementMultimediaMath class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementMultimediaMath + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementMultimediaMath(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaSvg.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaSvg.cs new file mode 100644 index 00000000..d78ccc5e --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaSvg.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementMultimediaSvg class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementMultimediaSvg + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementMultimediaSvg(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaTrack.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaTrack.cs new file mode 100644 index 00000000..e904e530 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaTrack.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementMultimediaTrack class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementMultimediaTrack + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementMultimediaTrack(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaVideo.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaVideo.cs new file mode 100644 index 00000000..8c4a41c7 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaVideo.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementMultimediaVideo class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementMultimediaVideo + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementMultimediaVideo(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementScriptingCanvas.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementScriptingCanvas.cs new file mode 100644 index 00000000..41f981c3 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementScriptingCanvas.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementScriptingCanvas class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementScriptingCanvas + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementScriptingCanvas(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementScriptingNoscript.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementScriptingNoscript.cs new file mode 100644 index 00000000..09ea4dc0 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementScriptingNoscript.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementScriptingNoscript class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementScriptingNoscript + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementScriptingNoscript(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementScriptingScript.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementScriptingScript.cs new file mode 100644 index 00000000..b196c4a5 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementScriptingScript.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementScriptingScript class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementScriptingScript + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementScriptingScript(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionAddress.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionAddress.cs new file mode 100644 index 00000000..6d115b9c --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionAddress.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementSectionAddress class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementSectionAddress + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementSectionAddress(); + + Assert.Equal(@"
", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionArticle.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionArticle.cs new file mode 100644 index 00000000..25255ca0 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionArticle.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementSectionArticle class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementSectionArticle + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementSectionArticle(); + + Assert.Equal(@"
", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionAside.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionAside.cs new file mode 100644 index 00000000..040fc8b9 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionAside.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementSectionAside class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementSectionAside + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementSectionAside(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionBody.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionBody.cs new file mode 100644 index 00000000..a6a858ea --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionBody.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementSectionBody class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementSectionBody + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementSectionBody(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionFooter.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionFooter.cs new file mode 100644 index 00000000..06aea9c3 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionFooter.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementSectionFooter class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementSectionFooter + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementSectionFooter(); + + Assert.Equal(@"
", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionH1.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionH1.cs new file mode 100644 index 00000000..a3ad8b50 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionH1.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementSectionH1 class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementSectionH1 + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementSectionH1(); + + Assert.Equal(@"

", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionH2.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionH2.cs new file mode 100644 index 00000000..cc3379b4 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionH2.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementSectionH2 class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementSectionH2 + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementSectionH2(); + + Assert.Equal(@"

", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionH3.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionH3.cs new file mode 100644 index 00000000..14f195e8 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionH3.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementSectionH3 class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementSectionH3 + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementSectionH3(); + + Assert.Equal(@"

", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionH4.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionH4.cs new file mode 100644 index 00000000..83c2e511 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionH4.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementSectionH4 class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementSectionH4 + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementSectionH4(); + + Assert.Equal(@"

", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionH5.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionH5.cs new file mode 100644 index 00000000..b6e27dd1 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionH5.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementSectionH5 class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementSectionH5 + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementSectionH5(); + + Assert.Equal(@"
", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionH6.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionH6.cs new file mode 100644 index 00000000..7f8dce7f --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionH6.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementSectionH6 class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementSectionH6 + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementSectionH6(); + + Assert.Equal(@"
", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionHeader.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionHeader.cs new file mode 100644 index 00000000..d04fa3bc --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionHeader.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementSectionHeader class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementSectionHeader + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementSectionHeader(); + + Assert.Equal(@"
", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionHgroup.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionHgroup.cs new file mode 100644 index 00000000..b1403503 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionHgroup.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementSectionHgroup class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementSectionHgroup + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementSectionHgroup(); + + Assert.Equal(@"
", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionMain.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionMain.cs new file mode 100644 index 00000000..ed940d56 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionMain.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementSectionMain class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementSectionMain + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementSectionMain(); + + Assert.Equal(@"
", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionNav.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionNav.cs new file mode 100644 index 00000000..7a8dae31 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionNav.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementSectionNav class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementSectionNav + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementSectionNav(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionSearch.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionSearch.cs new file mode 100644 index 00000000..ed35e0ff --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionSearch.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementSectionSearch class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementSectionSearch + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementSectionSearch(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionSection.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionSection.cs new file mode 100644 index 00000000..892955dd --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionSection.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementSectionSection class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementSectionSection + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementSectionSection(); + + Assert.Equal(@"
", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableCaption.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableCaption.cs new file mode 100644 index 00000000..99e86743 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableCaption.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTableCaption class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTableCaption + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTableCaption(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableCol.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableCol.cs new file mode 100644 index 00000000..9e0b5b73 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableCol.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTableCol class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTableCol + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTableCol(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableColgroup.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableColgroup.cs new file mode 100644 index 00000000..ac175848 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableColgroup.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTableColgroup class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTableColgroup + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTableColgroup(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableTable.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableTable.cs new file mode 100644 index 00000000..64185e43 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableTable.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTableTable class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTableTable + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTableTable(); + + Assert.Equal(@"
", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableTbody.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableTbody.cs new file mode 100644 index 00000000..7908678c --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableTbody.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTableTbody class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTableTbody + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTableTbody(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableTd.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableTd.cs new file mode 100644 index 00000000..532ea307 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableTd.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTableTd class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTableTd + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTableTd(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableTfoot.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableTfoot.cs new file mode 100644 index 00000000..c57562a7 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableTfoot.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTableTfoot class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTableTfoot + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTableTfoot(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableTh.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableTh.cs new file mode 100644 index 00000000..daf2b7cc --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableTh.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTableTh class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTableTh + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTableTh(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableThead.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableThead.cs new file mode 100644 index 00000000..9f05f579 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableThead.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTableThead class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTableThead + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTableThead(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableTr.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableTr.cs new file mode 100644 index 00000000..f67b210c --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableTr.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTableTr class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTableTr + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTableTr(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentBlockquote.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentBlockquote.cs new file mode 100644 index 00000000..e806fa63 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentBlockquote.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextContentBlockquote class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextContentBlockquote + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextContentBlockquote(); + + Assert.Equal(@"
", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentDd.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentDd.cs new file mode 100644 index 00000000..b8e9798b --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentDd.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextContentDd class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextContentDd + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextContentDd(); + + Assert.Equal(@"
", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentDiv.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentDiv.cs new file mode 100644 index 00000000..6f5c6c88 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentDiv.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextContentDiv class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextContentDiv + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextContentDiv(); + + Assert.Equal(@"
", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentDl.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentDl.cs new file mode 100644 index 00000000..e583ba5c --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentDl.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextContentDl class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextContentDl + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextContentDl(); + + Assert.Equal(@"
", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentDt.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentDt.cs new file mode 100644 index 00000000..98dd7a30 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentDt.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextContentDt class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextContentDt + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextContentDt(); + + Assert.Equal(@"
", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentFigcaption.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentFigcaption.cs new file mode 100644 index 00000000..66700be3 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentFigcaption.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextContentFigcaption class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextContentFigcaption + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextContentFigcaption(); + + Assert.Equal(@"
", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentFigure.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentFigure.cs new file mode 100644 index 00000000..5fd1f596 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentFigure.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextContentFigure class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextContentFigure + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextContentFigure(); + + Assert.Equal(@"
", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentHr.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentHr.cs new file mode 100644 index 00000000..0123452a --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentHr.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextContentHr class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextContentHr + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextContentHr(); + + Assert.Equal(@"
", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentLi.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentLi.cs new file mode 100644 index 00000000..788641e5 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentLi.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextContentLi class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextContentLi + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextContentLi(); + + Assert.Equal(@"
  • ", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentOl.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentOl.cs new file mode 100644 index 00000000..ac8dd5dc --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentOl.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextContentOl class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextContentOl + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextContentOl(); + + Assert.Equal(@"
      ", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentPre.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentPre.cs new file mode 100644 index 00000000..f6781aa5 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentPre.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextContentPre class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextContentPre + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextContentPre(); + + Assert.Equal(@"
      ", html.Trim());
      +        }
      +    }
      +}
      diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentUl.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentUl.cs
      new file mode 100644
      index 00000000..7b008764
      --- /dev/null
      +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentUl.cs
      @@ -0,0 +1,23 @@
      +using WebExpress.WebCore.WebHtml;
      +
      +namespace WebExpress.WebCore.Test.Html
      +{
      +    /// 
      +    /// Unit tests for the HtmlElementTextContentUl class.
      +    /// 
      +    [Collection("NonParallelTests")]
      +    public class UnitTestHtmlElementTextContentUl
      +    {
      +        /// 
      +        /// Tests an empty tag.
      +        /// 
      +        [Fact]
      +        public void Empty()
      +        {
      +            // act
      +            var html = new HtmlElementTextContentUl();
      +
      +            Assert.Equal(@"
        ", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsA.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsA.cs new file mode 100644 index 00000000..b95cd286 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsA.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsA class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsA + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsA(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsAbbr.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsAbbr.cs new file mode 100644 index 00000000..107b5f63 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsAbbr.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsAbbr class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsAbbr + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsAbbr(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsB.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsB.cs new file mode 100644 index 00000000..2dcc03d7 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsB.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsB class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsB + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsB(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsBdi.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsBdi.cs new file mode 100644 index 00000000..623bf2ef --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsBdi.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsBdi class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsBdi + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsBdi(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsBdo.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsBdo.cs new file mode 100644 index 00000000..b0c86926 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsBdo.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsBdo class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsBdo + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsBdo(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsBr.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsBr.cs new file mode 100644 index 00000000..4b07a6cd --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsBr.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsBr class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsBr + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsBr(); + + Assert.Equal(@"
        ", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsCite.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsCite.cs new file mode 100644 index 00000000..113e1857 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsCite.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsCite class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsCite + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsCite(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsCode.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsCode.cs new file mode 100644 index 00000000..aa6326be --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsCode.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsCode class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsCode + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsCode(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsData.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsData.cs new file mode 100644 index 00000000..fe863bf9 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsData.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsData class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsData + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsData(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsDfn.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsDfn.cs new file mode 100644 index 00000000..5ce15b0c --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsDfn.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsDfn class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsDfn + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsDfn(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsEm.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsEm.cs new file mode 100644 index 00000000..bd900336 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsEm.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsEm class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsEm + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsEm(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsI.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsI.cs new file mode 100644 index 00000000..50622c91 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsI.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsI class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsI + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsI(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsKbd.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsKbd.cs new file mode 100644 index 00000000..2c6c5d67 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsKbd.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsKbd class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsKbd + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsKbd(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsMark.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsMark.cs new file mode 100644 index 00000000..a4f18fb2 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsMark.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsMark class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsMark + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsMark(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsQ.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsQ.cs new file mode 100644 index 00000000..4ffe1b54 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsQ.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsQ class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsQ + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsQ(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsRp.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsRp.cs new file mode 100644 index 00000000..000d635b --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsRp.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsRp class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsRp + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsRp(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsRt.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsRt.cs new file mode 100644 index 00000000..a145150c --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsRt.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsRt class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsRt + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsRt(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsRuby.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsRuby.cs new file mode 100644 index 00000000..0ff38291 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsRuby.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsRuby class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsRuby + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsRuby(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsS.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsS.cs new file mode 100644 index 00000000..0b0e15c3 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsS.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsS class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsS + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsS(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsSamp.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsSamp.cs new file mode 100644 index 00000000..23e65a32 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsSamp.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsSamp class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsSamp + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsSamp(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsSmall.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsSmall.cs new file mode 100644 index 00000000..96b60a70 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsSmall.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsSmall class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsSmall + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsSmall(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsSpan.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsSpan.cs new file mode 100644 index 00000000..0c2a57a6 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsSpan.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsSpan class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsSpan + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsSpan(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsStrong.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsStrong.cs new file mode 100644 index 00000000..3a8f1143 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsStrong.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsStrong class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsStrong + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsStrong(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsSub.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsSub.cs new file mode 100644 index 00000000..9aea58e9 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsSub.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsSub class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsSub + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsSub(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsSup.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsSup.cs new file mode 100644 index 00000000..9a0d4906 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsSup.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsSup class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsSup + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsSup(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsTime.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsTime.cs new file mode 100644 index 00000000..c0e38cfe --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsTime.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsTime class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsTime + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsTime(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsU.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsU.cs new file mode 100644 index 00000000..368a3a73 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsU.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsU class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsU + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsU(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsVar.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsVar.cs new file mode 100644 index 00000000..42478ca4 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsVar.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsVar class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsVar + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsVar(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsWbr.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsWbr.cs new file mode 100644 index 00000000..7ef76249 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsWbr.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementTextSemanticsWbr class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementTextSemanticsWbr + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementTextSemanticsWbr(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementWebFragmentsSlot.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementWebFragmentsSlot.cs new file mode 100644 index 00000000..f403487e --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementWebFragmentsSlot.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementWebFragmentsSlot class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementWebFragmentsSlot + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementWebFragmentsSlot(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementWebFragmentsTemplate.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementWebFragmentsTemplate.cs new file mode 100644 index 00000000..5be538fb --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementWebFragmentsTemplate.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementWebFragmentsTemplate class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementWebFragmentsTemplate + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementWebFragmentsTemplate(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestApplicationManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestApplicationManager.cs index 92c22a41..e4081246 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestApplicationManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestApplicationManager.cs @@ -1,5 +1,7 @@ -using WebExpress.WebCore.Test.Fixture; +using System.Reflection; +using WebExpress.WebCore.Test.Fixture; using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebApplication.Model; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebPlugin; @@ -49,6 +51,80 @@ public void Remove() Assert.Empty(applicationManager.Applications); } + /// + /// Removing the applications of a plugin disposes them and their cancellation token + /// sources at once, so a hot unload leaves no application running on unloaded code. + /// + [Fact] + public void RemoveDisposesApplicationsImmediately() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var applicationManager = componentHub.ApplicationManager as ApplicationManager; + var plugin = componentHub.PluginManager?.GetPlugin(typeof(TestPlugin)); + var applicationItems = GetApplicationItems(applicationManager, plugin); + + // act + applicationManager.Remove(plugin); + + // validation + Assert.NotEmpty(applicationItems); + Assert.True(applicationItems.Select(x => x.Application).OfType().Single().IsDisposed); + Assert.All(applicationItems, x => + { + Assert.True(x.CancellationTokenSource.IsCancellationRequested); + Assert.Throws(() => x.CancellationTokenSource.Token); + }); + } + + /// + /// The listeners of the removal event see the application before it is disposed, so + /// they can still release what they bound to it. + /// + [Fact] + public void RemoveRaisesEventBeforeDisposing() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var applicationManager = componentHub.ApplicationManager as ApplicationManager; + var plugin = componentHub.PluginManager?.GetPlugin(typeof(TestPlugin)); + var application = GetApplicationItems(applicationManager, plugin) + .Select(x => x.Application) + .OfType() + .Single(); + bool? disposedDuringEvent = null; + + applicationManager.RemoveApplication += (s, e) => + { + if (e.ApplicationId == "webexpress.webcore.test.testapplicationa") + { + disposedDuringEvent = application.IsDisposed; + } + }; + + // act + applicationManager.Remove(plugin); + + // validation + Assert.False(disposedDuringEvent); + Assert.True(application.IsDisposed); + } + + /// + /// Returns the registry entries of the applications of a plugin. + /// + /// The application manager. + /// The plugin. + /// The application entries. + private static List GetApplicationItems(ApplicationManager applicationManager, IPluginContext plugin) + { + var dictionary = typeof(ApplicationManager) + .GetField("_dictionary", BindingFlags.NonPublic | BindingFlags.Instance) + .GetValue(applicationManager) as ApplicationDictionary; + + return [.. dictionary.GetApplicationItems(plugin)]; + } + /// /// Test the id property of the application. /// @@ -117,6 +193,109 @@ public void Icon(Type applicationType, string icon) Assert.Equal(icon, application.Icon.ToString()); } + /// + /// Test that the name of a registered application can be replaced at runtime, and that a + /// blank value puts the declared name back. + /// + [Fact] + public void SetApplicationName() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var applicationManager = componentHub.ApplicationManager; + var application = applicationManager.GetApplications(typeof(TestApplicationA)).FirstOrDefault(); + var declared = application.ApplicationName; + var updated = new List(); + + applicationManager.UpdateApplication += (_, context) => updated.Add(context); + + // act + applicationManager.SetApplicationName(application, "Renamed"); + + // validation + Assert.Equal("Renamed", application.ApplicationName); + Assert.Equal("webexpress.webcore.test.testapplicationa", application.ApplicationId); + Assert.Single(updated); + + // a blank value restores what the application declared + applicationManager.SetApplicationName(application, " "); + + Assert.Equal(declared, application.ApplicationName); + Assert.Equal(2, updated.Count); + } + + /// + /// Test that renaming an application to the name it already carries changes nothing and + /// raises no event. + /// + [Fact] + public void SetApplicationNameUnchanged() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var applicationManager = componentHub.ApplicationManager; + var application = applicationManager.GetApplications(typeof(TestApplicationA)).FirstOrDefault(); + var updated = 0; + + applicationManager.UpdateApplication += (_, _) => updated++; + + // act + applicationManager.SetApplicationName(application, application.ApplicationName); + + // validation + Assert.Equal(0, updated); + } + + /// + /// Test that the icon of a registered application can be replaced at runtime. The value is + /// a path relative to the application, which the manager combines into a route the same + /// way it does at registration. + /// + [Fact] + public void SetApplicationIcon() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var applicationManager = componentHub.ApplicationManager; + var application = applicationManager.GetApplications(typeof(TestApplicationA)).FirstOrDefault(); + var updated = new List(); + + applicationManager.UpdateApplication += (_, context) => updated.Add(context); + + // act + applicationManager.SetApplicationIcon(application, "/assets/img/Custom.svg"); + + // validation + Assert.Equal("/server/appa/assets/img/Custom.svg", application.Icon.ToString()); + Assert.Single(updated); + + // a blank value restores what the application declared + applicationManager.SetApplicationIcon(application, null); + + Assert.Equal("/server/appa/assets/img/Logo.png", application.Icon.ToString()); + Assert.Equal(2, updated.Count); + } + + /// + /// Test that an unknown application context is ignored rather than throwing. + /// + [Fact] + public void SetApplicationNameOfUnknownApplication() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var applicationManager = componentHub.ApplicationManager; + var updated = 0; + + applicationManager.UpdateApplication += (_, _) => updated++; + + // act + applicationManager.SetApplicationName(null, "Renamed"); + + // validation + Assert.Equal(0, updated); + } + /// /// Test the context path property of the application. /// diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestAssetManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestAssetManager.cs index 4549eaf5..77dae537 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestAssetManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestAssetManager.cs @@ -94,6 +94,55 @@ public void Uri(Type applicationType, string route) Assert.Equal(route, asset?.Route.ToString()); } + /// + /// Tests that the route resolved for a file of a plugin is the route the asset is + /// really mounted on. Consumers that link an embedded file - the includes a page + /// renders as link and script elements - resolve it this way instead of composing + /// the route themselves, because a route that disagrees with the mount answers 404 + /// and a browser accepts that html error page as a stylesheet with no rules. + /// + [Theory] + [InlineData(typeof(TestApplicationA), "/assets/css/mycss.css", "/server/appa/assets/css/mycss.css")] + [InlineData(typeof(TestApplicationA), "/assets/js/myjavascript.js", "/server/appa/assets/js/myjavascript.js")] + [InlineData(typeof(TestApplicationB), "/assets/css/mycss.css", "/server/appb/assets/css/mycss.css")] + [InlineData(typeof(TestApplicationC), "/assets/css/mycss.css", "/server/assets/css/mycss.css")] + public void AssetRoute(Type applicationType, string file, string expected) + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); + var plugin = componentHub.PluginManager?.GetPlugin(typeof(TestPlugin)); + + // act + var route = componentHub.AssetManager.GetAssetRoute(application, plugin, file); + + // validation + Assert.Equal(expected, route?.ToString()); + Assert.Contains + ( + expected, + componentHub.AssetManager.GetAssets(application).Select(x => x.Route.ToString()) + ); + } + + /// + /// Tests that a resolution which does not describe a file answers with nothing rather + /// than with a route that leads nowhere. + /// + [Fact] + public void AssetRouteWithoutFile() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplicationA))?.FirstOrDefault(); + var plugin = componentHub.PluginManager?.GetPlugin(typeof(TestPlugin)); + + // act & validation + Assert.Null(componentHub.AssetManager.GetAssetRoute(application, plugin, null)); + Assert.Null(componentHub.AssetManager.GetAssetRoute(application, null, "/assets/css/mycss.css")); + Assert.Null(componentHub.AssetManager.GetAssetRoute(null, plugin, "/assets/css/mycss.css")); + } + /// /// Test the request of the asset. /// @@ -134,6 +183,104 @@ public void Request(string uri, string resource) Assert.Equal(embeddedResource, Encoding.UTF8.GetString(response.Content as byte[])); } + /// + /// Tests that the asset response carries the content type derived from the file extension. + /// + [Theory] + [InlineData("http://localhost:8080/server/appa/assets/css/mycss.css", "text/css")] + [InlineData("http://localhost:8080/server/appa/assets/js/myjavascript.js", "text/javascript")] + public void ContentType(string uri, string expectedContentType) + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); + var context = UnitTestFixture.CreateHttpContextMock(); + componentHub.SitemapManager.Refresh(); + + var searchResult = componentHub.SitemapManager.SearchResource(new System.Uri(uri), new SearchContext() + { + HttpServerContext = httpServerContext, + Culture = httpServerContext.Culture, + HttpContext = context + }); + + // act + var response = componentHub + .EndpointManager + .HandleRequest(UnitTestFixture.CreateRequestMock("", uri), searchResult.EndpointContext); + + // validation + Assert.Equal(expectedContentType, response.Header.ContentType); + } + + /// + /// Tests that the asset response exposes a non-empty ETag for cache validation. + /// + [Fact] + public void ETagIsSet() + { + // arrange + var uri = "http://localhost:8080/server/appa/assets/css/mycss.css"; + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); + var context = UnitTestFixture.CreateHttpContextMock(); + componentHub.SitemapManager.Refresh(); + + var searchResult = componentHub.SitemapManager.SearchResource(new System.Uri(uri), new SearchContext() + { + HttpServerContext = httpServerContext, + Culture = httpServerContext.Culture, + HttpContext = context + }); + + // act + var response = componentHub + .EndpointManager + .HandleRequest(UnitTestFixture.CreateRequestMock("", uri), searchResult.EndpointContext); + + // validation + Assert.True(response.Header.CustomHeader.ContainsKey("ETag")); + Assert.False(string.IsNullOrEmpty(response.Header.CustomHeader["ETag"])); + } + + /// + /// Tests that a request whose If-None-Match matches the current ETag is answered with + /// 304 Not Modified instead of resending the payload. + /// + [Fact] + public void ConditionalRequestReturnsNotModified() + { + // arrange + var uri = "http://localhost:8080/server/appa/assets/css/mycss.css"; + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); + var context = UnitTestFixture.CreateHttpContextMock(); + componentHub.SitemapManager.Refresh(); + + var searchResult = componentHub.SitemapManager.SearchResource(new System.Uri(uri), new SearchContext() + { + HttpServerContext = httpServerContext, + Culture = httpServerContext.Culture, + HttpContext = context + }); + + // act - first request returns the payload together with its ETag + var first = componentHub + .EndpointManager + .HandleRequest(UnitTestFixture.CreateRequestMock("", uri), searchResult.EndpointContext); + var eTag = first.Header.CustomHeader["ETag"]; + + // act - second request presents the ETag via If-None-Match + var conditional = $"GET {uri} HTTP/1.1\nIf-None-Match: {eTag}\n\n"; + var second = componentHub + .EndpointManager + .HandleRequest(UnitTestFixture.CreateRequestMock(conditional, uri), searchResult.EndpointContext); + + // validation + Assert.IsType(first); + Assert.IsType(second); + } + /// /// Tests whether the asset manager implements interface IComponentManager. /// diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestAuthenticationEndpoint.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestAuthenticationEndpoint.cs new file mode 100644 index 00000000..352c6778 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestAuthenticationEndpoint.cs @@ -0,0 +1,316 @@ +using System.Text.Json; +using WebExpress.WebCore.Test.Data; +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebIdentity; +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Exercises the public authentication boundary, including cookie transport and browser-origin checks. + /// + [Collection("NonParallelTests")] + public class UnitTestAuthenticationEndpoint + { + /// + /// Preserves explicit HTTP and HTTPS ports so same-origin checks accept browser requests without accepting other origins. + /// + /// The authority sent in the Host header, including the public port. + /// The browser origin used to validate the authentication request. + /// The HTTP transport scheme used by the host. + /// The local listener port. + /// The expected success or forbidden status. + /// A task that completes after URL construction and authentication have been checked. + [Theory] + [InlineData("localhost:8080", "http://localhost:8080", "http", 8080, 200)] + [InlineData("[::1]:8080", "http://[::1]:8080", "http", 8080, 200)] + [InlineData("localhost:8443", "https://localhost:8443", "https", 8443, 200)] + [InlineData("localhost:443", "https://localhost", "https", 443, 200)] + [InlineData("localhost:8080", "http://localhost:8081", "http", 8080, 403)] + [InlineData("localhost:8080", "http://other.example:8080", "http", 8080, 403)] + public async Task LoginWithExplicitPortEnforcesTheBrowserOrigin(string host, string origin, string scheme, int port, int expectedStatus) + { + // arrange + using var fixture = new AuthenticationFixture(requireHttps: scheme == "https"); + using var endpoint = new AuthenticationEndpoint(fixture.Hub, fixture.Server); + fixture.Hub.IdentityProviderManager.Register(new PasswordProvider(), fixture.Application); + var context = new Microsoft.AspNetCore.Http.DefaultHttpContext(); + context.TraceIdentifier = Guid.NewGuid().ToString("N"); + context.Request.Method = "POST"; + context.Request.Scheme = scheme; + context.Request.Host = new Microsoft.AspNetCore.Http.HostString(host); + context.Request.Path = "/api/auth/login"; + context.Request.Protocol = "HTTP/1.1"; + context.Request.ContentType = "application/json"; + context.Request.Headers.Origin = origin; + context.Request.Headers["X-WebExpress-Auth"] = "1"; + context.Features.Get().RawTarget = "/api/auth/login"; + context.Connection.LocalIpAddress = System.Net.IPAddress.Loopback; + context.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback; + context.Connection.LocalPort = port; + using var body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("{\"username\":\"alice\",\"password\":\"correct\"}")); + context.Request.Body = body; + context.Request.ContentLength = body.Length; + + // act + var request = new WebMessage.HttpContext(context.Features, fixture.Server).Request; + var response = await endpoint.HandleAsync(request); + + // validation + Assert.True(Uri.TryCreate(request.Uri.ToString(), UriKind.Absolute, out var uri)); + Assert.Equal(port, uri.Port); + Assert.Equal(expectedStatus, response.Status); + if (expectedStatus == 200) + { + using var result = JsonDocument.Parse((string)response.Content); + Assert.True(result.RootElement.GetProperty("authenticated").GetBoolean()); + } + else + { + Assert.Contains("invalid_origin", (string)response.Content); + Assert.Empty(response.Header.Cookies.Cast()); + } + } + + /// + /// Behind a reverse proxy every login arrives from the proxy's address. With the proxy + /// trusted, the throttle limits each client it reports, so one client guessing passwords + /// does not lock everyone else behind the same proxy out of signing in. + /// + /// A task that completes after the per-client limits have been verified. + [Fact] + public async Task LoginThrottleLimitsEachClientBehindATrustedProxy() + { + // arrange - the fixture's requests arrive from 127.0.0.1, which plays the proxy + using var fixture = new AuthenticationFixture(requireHttps: false); + var resolver = new ForwardedClientResolver(new SecuritySettings { TrustedProxies = ["127.0.0.1"] }); + using var endpoint = new AuthenticationEndpoint(fixture.Hub, fixture.Server, resolver); + fixture.Hub.IdentityProviderManager.Register(new PasswordProvider(), fixture.Application); + + Task LoginAsync(string client) => endpoint.HandleAsync(fixture.Request( + $"X-WebExpress-Auth: 1\r\nX-Forwarded-For: {client}\r\n", "POST", + "/api/auth/login", "{\"username\":\"alice\",\"password\":\"wrong\"}", https: false)); + + // act - one client uses up its attempts + for (var i = 0; i < 10; i++) + { + Assert.NotEqual(429, (await LoginAsync("203.0.113.7")).Status); + } + + // validation + Assert.Equal(429, (await LoginAsync("203.0.113.7")).Status); + Assert.NotEqual(429, (await LoginAsync("198.51.100.4")).Status); + } + + /// + /// Keeps HTTPS mandatory when a deployment has not explicitly enabled development HTTP. + /// + /// A task that completes after the default transport rejection has been verified. + [Fact] + public async Task HttpIsRejectedWithoutDevelopmentOverride() + { + // arrange + using var fixture = new AuthenticationFixture(); + fixture.Server.Configuration["WebExpress:Authentication:RequireHttps"] = null; + using var endpoint = new AuthenticationEndpoint(fixture.Hub, fixture.Server); + + // act + var response = await endpoint.HandleAsync(fixture.Request("X-WebExpress-Auth: 1\r\n", "POST", + "/api/auth/login", "{}", https: false)); + + // validation + Assert.Equal(400, response.Status); + Assert.Contains("https_required", (string)response.Content); + Assert.Empty(response.Header.Cookies.Cast()); + } + + /// + /// Allows development HTTP without weakening token validation, cookie isolation, rotation, or logout. + /// + /// A task that completes after the development authentication lifecycle has been verified. + [Fact] + public async Task DevelopmentHttpSupportsLoginRefreshAndLogout() + { + using var fixture = new AuthenticationFixture(requireHttps: false); + using var endpoint = new AuthenticationEndpoint(fixture.Hub, fixture.Server); + fixture.Hub.IdentityProviderManager.Register(new PasswordProvider(), fixture.Application); + var login = await endpoint.HandleAsync(fixture.Request("X-WebExpress-Auth: 1\r\n", "POST", + "/api/auth/login", "{\"username\":\"alice\",\"password\":\"correct\"}", https: false)); + + Assert.Equal(200, login.Status); + var access = login.Header.Cookies[IdentityManager.DevelopmentAccessCookieName]; + var refresh = login.Header.Cookies[IdentityManager.DevelopmentRefreshCookieName]; + Assert.NotNull(access); + Assert.NotNull(refresh); + Assert.True(access.HttpOnly && refresh.HttpOnly); + Assert.False(access.Secure || refresh.Secure); + Assert.Equal("/", access.Path); + Assert.Equal(IdentityManager.RefreshPath, refresh.Path); + var authenticatedRequest = fixture.Request($"Cookie: {access.Name}={access.Value}\r\n", https: false); + Assert.Equal("alice", fixture.Manager.GetCurrentIdentity(authenticatedRequest)?.Name); + + // the identity is resolved once per request, so the changed setting governs the next one + fixture.Server.Configuration["WebExpress:Authentication:RequireHttps"] = "true"; + Assert.Null(fixture.Manager.GetCurrentIdentity(fixture.Request($"Cookie: {access.Name}={access.Value}\r\n", https: false))); + fixture.Server.Configuration["WebExpress:Authentication:RequireHttps"] = "false"; + + var renewed = await endpoint.HandleAsync(fixture.Request($"X-WebExpress-Auth: 1\r\nCookie: {refresh.Name}={refresh.Value}\r\n", + "POST", IdentityManager.RefreshPath, https: false)); + Assert.Equal(200, renewed.Status); + var nextAccess = renewed.Header.Cookies[access.Name]; + var nextRefresh = renewed.Header.Cookies[refresh.Name]; + Assert.NotEqual(refresh.Value, nextRefresh.Value); + var logout = await endpoint.HandleAsync(fixture.Request($"X-WebExpress-Auth: 1\r\nCookie: {nextAccess.Name}={nextAccess.Value}\r\n", + "POST", "/api/auth/logout", https: false)); + Assert.Equal(204, logout.Status); + Assert.All(logout.Header.Cookies.Cast(), cookie => Assert.True(cookie.Expires < DateTime.UtcNow)); + var revoked = await endpoint.HandleAsync(fixture.Request($"X-WebExpress-Auth: 1\r\nCookie: {nextRefresh.Name}={nextRefresh.Value}\r\n", + "POST", IdentityManager.RefreshPath, https: false)); + Assert.Equal(401, revoked.Status); + } + + /// + /// The server answers the root login route before sitemap lookup and serializes protected cookies on the wire. + /// + /// Whether the hub is already available when the server is constructed. + /// A task that completes after the asynchronous assertions have finished. + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RootLoginIsIntegratedIntoTheHttpPipeline(bool hubAvailableAtConstruction) + { + using var fixture = new AuthenticationFixture(); + fixture.Hub.IdentityProviderManager.Register(new PasswordProvider(), fixture.Application); + const string body = "{\"username\":\"alice\",\"password\":\"correct\"}"; + var context = UnitTestFixture.CreateHttpContextMock($"POST /api/auth/login HTTP/1.1\r\nCookie:\r\nX-WebExpress-Auth: 1\r\nContent-Type: application/json\r\nContent-Length: {body.Length}\r\n\r\n{body}"); + typeof(WebMessage.RequestBase).GetProperty(nameof(WebMessage.RequestBase.Scheme)) + .SetValue(context.Request, global::WebExpress.WebCore.WebUri.UriScheme.Https); + context.Request.Uri.Scheme = global::WebExpress.WebCore.WebUri.UriScheme.Https; + var head = new Microsoft.AspNetCore.Http.Features.HttpResponseFeature(); + using var output = new MemoryStream(); + context.Features.Set(head); + context.Features.Set( + new Microsoft.AspNetCore.Http.StreamResponseBodyFeature(output)); + var hubField = typeof(WebEx).GetField("_componentHub", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); + HttpServer server; + try + { + if (!hubAvailableAtConstruction) { hubField.SetValue(null, null); } + server = new HttpServer(fixture.Server); + } + finally + { + hubField.SetValue(null, fixture.Hub); + } + await server.ProcessRequestAsync(context); + Assert.Equal(200, head.StatusCode); + Assert.Contains(IdentityManager.AccessCookieName, head.Headers.SetCookie.ToString()); + Assert.Contains("Path=/api/auth/refresh", head.Headers.SetCookie.ToString()); + Assert.Contains("SameSite=Lax", head.Headers.SetCookie.ToString()); + Assert.DoesNotContain("session=", head.Headers.SetCookie.ToString()); + Assert.True(JsonDocument.Parse(output.ToArray()).RootElement.GetProperty("authenticated").GetBoolean()); + } + + /// + /// Local login, renewal, and logout use the same protected cookies and expose no credentials in JSON. + /// + /// A task that completes after the asynchronous assertions have finished. + [Fact] + public async Task LocalLoginRefreshAndLogoutUseTokenCookies() + { + using var fixture = new AuthenticationFixture(); + using var endpoint = new AuthenticationEndpoint(fixture.Hub, fixture.Server); + fixture.Hub.IdentityProviderManager.Register(new PasswordProvider(), fixture.Application); + var request = fixture.Request("X-WebExpress-Auth: 1\r\n", "POST", "/api/auth/login", "{\"username\":\"alice\",\"password\":\"correct\"}"); + var login = await endpoint.HandleAsync(request); + Assert.Equal(200, login.Status); + Assert.True(JsonDocument.Parse((string)login.Content).RootElement.GetProperty("authenticated").GetBoolean()); + Assert.Null(request.ExistingSession); + Assert.Equal(2, login.Header.Cookies.Count); + var refresh = login.Header.Cookies[IdentityManager.RefreshCookieName].Value; + var access = login.Header.Cookies[IdentityManager.AccessCookieName].Value; + Assert.DoesNotContain(access, (string)login.Content); + Assert.DoesNotContain(refresh, (string)login.Content); + var renewed = await endpoint.HandleAsync(fixture.Request($"X-WebExpress-Auth: 1\r\nCookie: {IdentityManager.RefreshCookieName}={refresh}\r\n", "POST", IdentityManager.RefreshPath)); + Assert.Equal(200, renewed.Status); + var rotated = renewed.Header.Cookies[IdentityManager.RefreshCookieName].Value; + Assert.NotEqual(refresh, rotated); + var logout = await endpoint.HandleAsync(fixture.Request($"X-WebExpress-Auth: 1\r\nCookie: {IdentityManager.RefreshCookieName}={rotated}\r\n", "DELETE", IdentityManager.RefreshPath)); + Assert.Equal(204, logout.Status); + var revoked = await endpoint.HandleAsync(fixture.Request($"X-WebExpress-Auth: 1\r\nCookie: {IdentityManager.RefreshCookieName}={rotated}\r\n", "POST", IdentityManager.RefreshPath)); + Assert.Equal(401, revoked.Status); + } + + /// + /// Rejected credentials and browser requests cannot cause token issuance or leak password validation details. + /// + /// The HTTP headers required for the boundary scenario. + /// The HTTP method used to exercise the endpoint contract. + /// The payload serialized or supplied for the authentication request. + /// The expected HTTP status for the rejected boundary input. + /// A task that completes after the asynchronous assertions have finished. + [Theory] + [InlineData("", "POST", "{\"username\":\"alice\",\"password\":\"correct\"}", 403)] + [InlineData("X-WebExpress-Auth: 1\r\nOrigin: https://other.test\r\n", "POST", "{}", 403)] + [InlineData("X-WebExpress-Auth: 1\r\n", "GET", null, 405)] + [InlineData("X-WebExpress-Auth: 1\r\n", "POST", "not-json", 400)] + [InlineData("X-WebExpress-Auth: 1\r\n", "POST", "{\"username\":\"alice\",\"password\":\"wrong\"}", 401)] + [InlineData("X-WebExpress-Auth: 1\r\n", "POST", "{\"username\":\"missing\",\"password\":\"correct\"}", 401)] + public async Task InvalidLoginDoesNotIssueCredentials(string headers, string method, string body, int status) + { + using var fixture = new AuthenticationFixture(); + using var endpoint = new AuthenticationEndpoint(fixture.Hub, fixture.Server); + fixture.Hub.IdentityProviderManager.Register(new PasswordProvider(), fixture.Application); + var response = await endpoint.HandleAsync(fixture.Request(headers, method, "/api/auth/login", body)); + Assert.Equal(status, response.Status); + Assert.Empty(response.Header.Cookies.Cast()); + Assert.Equal("no-store", response.Header.CacheControl); + } + + /// + /// PAT issuance and revocation require a browser identity and preserve the requested permission subset. + /// + /// A task that completes after the asynchronous assertions have finished. + [Fact] + public async Task PersonalCredentialEndpointRequiresOwner() + { + using var fixture = new AuthenticationFixture(); + using var endpoint = new AuthenticationEndpoint(fixture.Hub, fixture.Server); + var owner = new Identity(Guid.NewGuid(), "alice", permissions: ["read"]); + var pair = fixture.Manager.Login(owner, fixture.Request()); + var headers = $"X-WebExpress-Auth: 1\r\nCookie: {IdentityManager.AccessCookieName}={pair.AccessToken}\r\n"; + var response = await endpoint.HandleAsync(fixture.Request(headers, "POST", "/api/auth/pat", "{\"lifetimeSeconds\":3600,\"permissions\":[\"read\"]}")); + Assert.Equal(201, response.Status); + var token = JsonDocument.Parse((string)response.Content).RootElement.GetProperty("token").GetString(); + Assert.Equal(owner.Id, fixture.Manager.GetCurrentIdentity(fixture.Request($"Authorization: Bearer {token}\r\n")).Id); + var forbidden = await endpoint.HandleAsync(fixture.Request(headers, "POST", "/api/auth/pat", "{\"lifetimeSeconds\":3600,\"permissions\":[\"write\"]}")); + Assert.Equal(400, forbidden.Status); + var revoked = await endpoint.HandleAsync(fixture.Request(headers, "DELETE", "/api/auth/pat", JsonSerializer.Serialize(new { token }))); + Assert.Equal(204, revoked.Status); + Assert.Null(fixture.Manager.GetCurrentIdentity(fixture.Request($"Authorization: Bearer {token}\r\n"))); + } + + /// + /// Exercises the production local password verifier with an isolated user directory. + /// + private sealed class PasswordProvider : LocalIdentityProvider + { + private readonly MockIdentity _user; + /// + /// Creates a salted password verifier for the local authentication boundary tests. + /// + internal PasswordProvider() + { + // hashed through the public helper, so every login test proves its format verifies + var hash = IdentityManager.HashPassword("correct"); + _user = new MockIdentity(Guid.NewGuid(), "alice", "alice@example.test", hash); + } + /// + /// Supplies a salted local password verifier to exercise the real provider validation path. + /// + /// The identities supplied by the local directory, or an empty collection for external providers. + public override IEnumerable GetIdentities() => [_user]; + } + } +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestCertificateManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestCertificateManager.cs new file mode 100644 index 00000000..5af054ab --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestCertificateManager.cs @@ -0,0 +1,761 @@ +using Microsoft.Extensions.Configuration; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebCertificate; +using WebExpress.WebCore.WebLog; +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Exercises real PFX loading, provider substitution, validity boundaries and hosting integration. + /// + [Collection("NonParallelTests")] + public sealed class UnitTestCertificateManager : IDisposable + { + private readonly string _directory = Path.Combine(Path.GetTempPath(), "webexpress-certificates-" + Guid.NewGuid().ToString("N")); + private readonly TestClock _clock = new(); + + /// + /// Isolates generated certificates from the repository and the operating system certificate store. + /// + public UnitTestCertificateManager() + { + Directory.CreateDirectory(_directory); + } + + /// + /// Provides deterministic UTC validity boundaries without sleeping or changing the machine clock. + /// + private sealed class TestClock : TimeProvider + { + /// + /// Gets or sets the instant observed by certificate validation. + /// + public DateTimeOffset Now { get; set; } = DateTimeOffset.FromUnixTimeSeconds(DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + + /// + /// Supplies the test instant to the manager. + /// + /// The configured UTC instant. + public override DateTimeOffset GetUtcNow() => Now; + } + + /// + /// Models a module that supplies certificates without any file access. + /// + /// The private material owned by the test provider. + private sealed class MemoryStore(byte[] pfx) : ICertificateStore + { + /// + /// Gets the provider name used to select the in-memory test material. + /// + public string Name => "memory"; + + /// + /// Gets the number of loads so resolution can prove it does not revisit the provider. + /// + public int LoadCount { get; private set; } + + /// + /// Transfers fresh material so cached resolution can be distinguished from repeated provider access. + /// + /// The test reference accepted without filesystem access. + /// The password protecting the in-memory PFX. + /// Newly owned certificate material for the manager. + public CertificateMaterial Load(string reference, string password) + { + LoadCount++; + return new CertificateMaterial(X509CertificateLoader.LoadPkcs12(pfx, password, X509KeyStorageFlags.EphemeralKeySet)); + } + } + + /// + /// Models a provider error that would expose secrets if its exception were logged verbatim. + /// + private sealed class FailingStore : ICertificateStore + { + /// + /// Gets the provider name used to select the failure scenario. + /// + public string Name => "failure"; + + /// + /// Simulates an unsafe provider exception to verify that diagnostics never expose credentials. + /// + /// The unused test reference. + /// The secret deliberately included in the simulated exception. + /// No material because the simulated provider always fails. + public CertificateMaterial Load(string reference, string password) => throw new IOException(password); + } + + /// + /// Creates a short-lived test leaf with controlled identity, key usage and validity. + /// + /// The subject alternative DNS name. + /// The beginning of the validity interval. + /// The end of the validity interval. + /// Whether the EKU permits server authentication. + /// Whether the certificate is a CA certificate. + /// Whether the key usage permits digital signatures. + /// A certificate with its private key owned by the caller. + private static X509Certificate2 CreateCertificate(string host, DateTimeOffset notBefore, DateTimeOffset notAfter, + bool serverUsage = true, bool certificateAuthority = false, bool signingUsage = true) + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var request = new CertificateRequest("CN=" + host, key, HashAlgorithmName.SHA256); + var names = new SubjectAlternativeNameBuilder(); + names.AddDnsName(host); + names.AddIpAddress(IPAddress.Loopback); + request.CertificateExtensions.Add(names.Build()); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(certificateAuthority, false, 0, true)); + request.CertificateExtensions.Add(new X509KeyUsageExtension( + signingUsage ? X509KeyUsageFlags.DigitalSignature : X509KeyUsageFlags.KeyEncipherment, true)); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( + new OidCollection { new Oid(serverUsage ? "1.3.6.1.5.5.7.3.1" : "1.3.6.1.5.5.7.3.2") }, false)); + return request.CreateSelfSigned(notBefore, notAfter); + } + + /// + /// Writes real password-protected material so tests exercise the shipped file store. + /// + /// The file name within the isolated test directory. + /// The subject alternative DNS name. + /// The remaining validity in days. + /// The PFX password used by the loader. + /// The absolute file path. + private string WriteCertificate(string fileName = "server.pfx", string host = "localhost", int days = 90, string password = "test-secret") + { + using var certificate = CreateCertificate(host, _clock.Now.AddDays(-2), _clock.Now.AddDays(days)); + var path = Path.Combine(_directory, fileName); + File.WriteAllBytes(path, certificate.Export(X509ContentType.Pfx, password)); + return path; + } + + /// + /// Builds the smallest inventory that refers to the generated PFX file. + /// + /// The independent settings object for one test. + private HttpServerSettings Settings() => new() + { + Certificates = new CertificateManagerSettings + { + Directory = _directory, + Items = [new CertificateSettings { Alias = "primary", Reference = "server.pfx", Password = "test-secret", HostNames = ["localhost"] }] + } + }; + + /// + /// Proves aliases and normalized hostnames share cached material with inspectable metadata. + /// + [Fact] + public void FileInventoryResolvesAliasesHostsAndMetadata() + { + // arrange + var path = WriteCertificate(); + using var manager = new CertificateManager(timeProvider: _clock); + + // act + manager.Load(Settings()); + File.Delete(path); + var material = manager.Resolve("PRIMARY"); + + // validation + Assert.Same(material, manager.Resolve("LOCALHOST.")); + var info = Assert.Single(manager.GetCertificates()); + Assert.True(info.IsUsable); + Assert.Equal(CertificateStatus.Valid, info.Status); + Assert.Equal(material.Certificate.Issuer, info.Issuer); + Assert.Equal(_clock.Now.AddDays(90), info.NotAfter); + Assert.Equal(material.Certificate.Thumbprint, info.Thumbprint); + } + + /// + /// Keeps two configured names independent while preserving legacy absolute PFX references. + /// + [Fact] + public void MultipleInlineEndpointsResolveDifferentCertificates() + { + // arrange + var first = WriteCertificate("first.pfx", "first.example"); + var second = WriteCertificate("second.pfx", "second.example"); + var settings = new HttpServerSettings + { + Endpoints = + [ + new() { Uri = "https://first.example:443", PfxFile = first, Password = "test-secret", CertificateAlias = "first" }, + new() { Uri = "HTTPS://second.example:8443", PfxFile = second, Password = "test-secret" } + ] + }; + using var manager = new CertificateManager(timeProvider: _clock); + + // act + manager.Load(settings); + + // validation + Assert.Same(manager.Resolve("first"), manager.Resolve("FIRST.EXAMPLE")); + Assert.Same(manager.Resolve(settings.Endpoints[1]), manager.Resolve("second.example")); + Assert.NotEqual(manager.Resolve("first").Certificate.Thumbprint, manager.Resolve("second.example").Certificate.Thumbprint); + } + + /// + /// A binding to every address of the machine - the rule in containers - names no host a + /// certificate could be issued for, so the certificate is accepted without a hostname check. + /// + /// The endpoint binding every address. + [Theory] + [InlineData("https://*:443")] + [InlineData("https://+:443")] + [InlineData("https://0.0.0.0:443")] + [InlineData("https://[::]:443")] + public void AnyAddressBindingSkipsTheHostnameCheck(string uri) + { + // arrange + var pfx = WriteCertificate("any.pfx", "app.example"); + var settings = new HttpServerSettings + { + Endpoints = [new() { Uri = uri, PfxFile = pfx, Password = "test-secret" }] + }; + using var manager = new CertificateManager(timeProvider: _clock); + + // act + manager.Load(settings); + + // validation + Assert.True(manager.Resolve(settings.Endpoints[0]).Certificate.HasPrivateKey); + Assert.True(manager.GetCertificates().Single().IsUsable); + } + + /// + /// Separates TLS suitability failures from provider loading failures. + /// + /// The suitability rule the generated material violates. + /// The status flag callers must be able to inspect. + [Theory] + [InlineData("expired", CertificateStatus.Expired)] + [InlineData("future", CertificateStatus.NotYetValid)] + [InlineData("keyless", CertificateStatus.MissingPrivateKey)] + [InlineData("client", CertificateStatus.InvalidUsage)] + [InlineData("ca", CertificateStatus.InvalidUsage)] + [InlineData("usage", CertificateStatus.InvalidUsage)] + [InlineData("hostname", CertificateStatus.HostNameMismatch)] + public void InvalidCertificatesRemainInspectableButCannotResolve(string failure, CertificateStatus expected) + { + // arrange + using var certificate = CreateCertificate(failure == "hostname" ? "other.example" : "localhost", + _clock.Now.AddDays(failure == "future" ? 1 : -3), _clock.Now.AddDays(failure == "expired" ? -1 : 90), + serverUsage: failure != "client", certificateAuthority: failure == "ca", signingUsage: failure != "usage"); + using var publicOnly = X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert)); + File.WriteAllBytes(Path.Combine(_directory, "server.pfx"), + (failure == "keyless" ? publicOnly : certificate).Export(X509ContentType.Pfx, "test-secret")); + using var manager = new CertificateManager(timeProvider: _clock); + + // act + manager.Load(Settings()); + + // validation + var info = Assert.Single(manager.GetCertificates()); + Assert.True(info.Status.HasFlag(expected)); + Assert.False(info.IsUsable); + Assert.Throws(() => manager.Resolve("primary")); + } + + /// + /// Reports unreadable PFX files without returning unusable material or leaking credentials. + /// + /// The file or credential boundary to violate. + [Theory] + [InlineData("missing")] + [InlineData("password")] + [InlineData("corrupt")] + public void LoadFailuresAreSafeAndInspectable(string failure) + { + // arrange + if (failure != "missing") { WriteCertificate(); } + if (failure == "corrupt") { File.WriteAllText(Path.Combine(_directory, "server.pfx"), "invalid pfx"); } + var settings = Settings(); + settings.Certificates.Items[0].Password = "never-log-this-password"; + var log = new Log { LogMode = LogMode.Off }; + using var manager = new CertificateManager(log, _clock); + + // act + manager.Load(settings); + + // validation + Assert.Equal(CertificateStatus.LoadFailed, Assert.Single(manager.GetCertificates()).Status); + Assert.Throws(() => manager.Resolve("primary")); + Assert.DoesNotContain(log.GetRecentEntries(), x => x.Message.Contains("never-log-this-password")); + Assert.True(log.ErrorCount > 0); + } + + /// + /// Makes expiry warnings configurable and validates the exact validity endpoint on later resolutions. + /// + [Fact] + public void ThresholdsAndClockChangesControlStatus() + { + // arrange + WriteCertificate(days: 10); + var settings = Settings(); + settings.Certificates.WarningThresholdDays = [14, 7]; + var log = new Log { LogMode = LogMode.Off }; + using var manager = new CertificateManager(log, _clock); + + // act + manager.Load(settings); + + // validation + Assert.Equal(CertificateStatus.ExpiringSoon, Assert.Single(manager.GetCertificates()).Status); + Assert.Equal(1, log.WarningCount); + Assert.Contains(log.GetRecentEntries(), x => x.Message.Contains("primary") && x.Message.Contains("14")); + Assert.NotNull(manager.Resolve("primary")); + + // act + _clock.Now = _clock.Now.AddDays(10); + + // validation + Assert.Equal(CertificateStatus.Expired, Assert.Single(manager.GetCertificates()).Status); + Assert.Throws(() => manager.Resolve("primary")); + } + + /// + /// Allows deployments to suppress expiry warnings without disabling certificate validity checks. + /// + [Fact] + public void EmptyThresholdsDisableWarnings() + { + // arrange + WriteCertificate(days: 1); + var settings = Settings(); + settings.Certificates.WarningThresholdDays = []; + var log = new Log { LogMode = LogMode.Off }; + using var manager = new CertificateManager(log, _clock); + + // act + manager.Load(settings); + + // validation + Assert.Equal(CertificateStatus.Valid, Assert.Single(manager.GetCertificates()).Status); + Assert.Equal(0, log.WarningCount); + } + + /// + /// Prevents endpoint aliases from bypassing hostname validation or choosing an arbitrary fallback. + /// + [Fact] + public void EndpointResolutionValidatesHostAndMissingMappings() + { + // arrange + WriteCertificate(); + using var manager = new CertificateManager(timeProvider: _clock); + manager.Load(Settings()); + + // act + var concrete = manager.Resolve(new EndpointSettings { Uri = "https://localhost:443" }); + var wildcard = manager.Resolve(new EndpointSettings { Uri = "https://*:443", CertificateAlias = "primary" }); + + // validation + Assert.NotNull(concrete); + Assert.Same(concrete, wildcard); + Assert.Throws(() => manager.Resolve(new EndpointSettings { Uri = "https://other.example", CertificateAlias = "primary" })); + Assert.Throws(() => manager.Resolve("unknown")); + Assert.Throws(() => manager.Resolve(new EndpointSettings { Uri = "https://*:443" })); + } + + /// + /// Preserves an unambiguous inventory even when configuration is supplied by several files. + /// + /// The invalid configuration to construct. + [Theory] + [InlineData("alias")] + [InlineData("host")] + [InlineData("alias-host")] + [InlineData("store")] + [InlineData("threshold")] + [InlineData("wildcard")] + public void ConflictingOrInvalidConfigurationIsRejected(string failure) + { + // arrange + WriteCertificate(); + var settings = Settings(); + switch (failure) + { + case "alias": settings.Certificates.Items.Add(new() { Alias = "PRIMARY", Reference = "server.pfx" }); break; + case "host": settings.Certificates.Items.Add(new() { Alias = "other", Reference = "server.pfx", HostNames = ["LOCALHOST."] }); break; + case "alias-host": settings.Certificates.Items.Add(new() { Alias = "localhost", Reference = "server.pfx" }); break; + case "store": settings.Certificates.Items[0].Store = "unknown"; break; + case "threshold": settings.Certificates.WarningThresholdDays = [-1]; break; + case "wildcard": settings.Certificates.Items[0].HostNames = ["*.example.com"]; break; + } + using var manager = new CertificateManager(timeProvider: _clock); + + // act + var exception = Record.Exception(() => manager.Load(settings)); + + // validation + Assert.NotNull(exception); + Assert.Empty(manager.GetCertificates()); + } + + /// + /// Demonstrates that providers can change without introducing file access in applications. + /// + [Fact] + public void ExternalStoreSupportsCachedResolutionAndSafeReload() + { + // arrange + using var certificate = CreateCertificate("localhost", _clock.Now.AddDays(-1), _clock.Now.AddDays(90)); + var store = new MemoryStore(certificate.Export(X509ContentType.Pfx, "test-secret")); + using var manager = new CertificateManager(timeProvider: _clock); + manager.RegisterStore(store); + var settings = Settings(); + settings.Certificates.Items[0].Store = "memory"; + settings.Certificates.Items[0].Reference = "provider-reference"; + + // act + manager.Load(settings); + var borrowed = manager.Resolve("primary"); + + // validation + Assert.Same(borrowed, manager.Resolve("localhost")); + Assert.Equal(1, store.LoadCount); + + // act + manager.Load(settings); + + // validation + Assert.NotSame(borrowed, manager.Resolve("primary")); + Assert.True(borrowed.Certificate.HasPrivateKey); + + // act + manager.Dispose(); + + // validation + Assert.Throws(() => manager.Resolve("primary")); + Assert.Equal(IntPtr.Zero, borrowed.Certificate.Handle); + } + + /// + /// A server whose start failed - an endpoint held by another process, say - releases its + /// certificates but must be able to start again, so the manager and its registered stores + /// stay usable for the next load. + /// + [Fact] + public void UnloadReleasesMaterialButAllowsReload() + { + // arrange + using var certificate = CreateCertificate("localhost", _clock.Now.AddDays(-1), _clock.Now.AddDays(90)); + var store = new MemoryStore(certificate.Export(X509ContentType.Pfx, "test-secret")); + using var manager = new CertificateManager(timeProvider: _clock); + manager.RegisterStore(store); + var settings = Settings(); + settings.Certificates.Items[0].Store = "memory"; + settings.Certificates.Items[0].Reference = "provider-reference"; + manager.Load(settings); + var borrowed = manager.Resolve("primary"); + + // act + manager.Unload(); + + // validation + Assert.Equal(IntPtr.Zero, borrowed.Certificate.Handle); + Assert.Empty(manager.GetCertificates()); + + // act - the registered store is still known, so the same settings load again + manager.Load(settings); + + // validation + Assert.True(manager.Resolve("primary").Certificate.HasPrivateKey); + } + + /// + /// Keeps provider exceptions containing credentials out of the shared log. + /// + [Fact] + public void ProviderExceptionDoesNotLeakPassword() + { + // arrange + var log = new Log { LogMode = LogMode.Off }; + using var manager = new CertificateManager(log, _clock); + manager.RegisterStore(new FailingStore()); + var settings = Settings(); + settings.Certificates.Items[0].Store = "failure"; + + // act + manager.Load(settings); + + // validation + Assert.DoesNotContain(log.GetRecentEntries(), x => x.Message.Contains("test-secret")); + Assert.Equal(CertificateStatus.LoadFailed, Assert.Single(manager.GetCertificates()).Status); + } + + /// + /// Verifies that existing configuration binding exposes the complete certificate configuration. + /// + [Fact] + public void SettingsBindCertificatesAndEndpointAliases() + { + // arrange + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(""" + { "WebExpress": { + "Certificates": { "Directory": "./ssl", "WarningThresholdDays": [21, 3], + "Items": [{ "Alias": "site", "Reference": "site.pfx", "Password": "configured", "HostNames": ["site.example"] }] }, + "Endpoints": [{ "Uri": "https://*:443", "CertificateAlias": "site" }] + } } + """)); + var configuration = new ConfigurationBuilder().AddJsonStream(stream).Build(); + + // act + var settings = configuration.GetServerSettings(); + + // validation + Assert.Equal("./ssl", settings.Certificates.Directory); + Assert.Equal([21, 3], settings.Certificates.WarningThresholdDays); + Assert.Equal("configured", settings.Certificates.Items[0].Password); + Assert.Equal("file", settings.Certificates.Items[0].Store); + Assert.Equal("site", settings.Endpoints[0].CertificateAlias); + } + + /// + /// Preserves configured empty arrays so operators can disable expiry warnings through JSON. + /// + [Fact] + public void EmptyWarningArraySurvivesConfigurationBinding() + { + // arrange + using var stream = new MemoryStream(Encoding.UTF8.GetBytes( + """{ "WebExpress": { "Certificates": { "WarningThresholdDays": [] } } }""")); + + // act + var settings = new ConfigurationBuilder().AddJsonStream(stream).Build().GetServerSettings(); + + // validation + Assert.NotNull(settings.Certificates.WarningThresholdDays); + Assert.Empty(settings.Certificates.WarningThresholdDays); + } + + /// + /// Verifies failed replacement configuration leaves the currently served inventory intact. + /// + [Fact] + public void FailedReloadKeepsExistingInventory() + { + // arrange + WriteCertificate(); + using var manager = new CertificateManager(timeProvider: _clock); + var settings = Settings(); + manager.Load(settings); + var current = manager.Resolve("primary"); + settings.Certificates.Items.Add(new() { Alias = "other", Store = "missing", Reference = "anything" }); + + // act + var exception = Record.Exception(() => manager.Load(settings)); + + // validation + Assert.IsType(exception); + Assert.Same(current, manager.Resolve("primary")); + Assert.True(current.Certificate.HasPrivateKey); + } + + /// + /// Keeps one alias usable for several endpoints without reloading its PFX for each listener. + /// + [Fact] + public void InlineAliasCanBeSharedAcrossPorts() + { + // arrange + var path = WriteCertificate(); + var settings = new HttpServerSettings + { + Endpoints = + [ + new() { Uri = "https://localhost:443", CertificateAlias = "site" }, + new() { Uri = "https://127.0.0.1:8443", CertificateAlias = "site", PfxFile = path, Password = "test-secret" } + ] + }; + using var manager = new CertificateManager(timeProvider: _clock); + + // act + manager.Load(settings); + + // validation + Assert.Single(manager.GetCertificates()); + Assert.Same(manager.Resolve(settings.Endpoints[0]), manager.Resolve(settings.Endpoints[1])); + Assert.Same(manager.Resolve("localhost"), manager.Resolve("127.0.0.1")); + } + + /// + /// Preserves supplied issuer certificates and presents the intermediate during the TLS handshake. + /// + /// A task that completes after the client has observed the supplied intermediate. + [Fact] + public async Task HttpsListenerPresentsSuppliedIntermediateChain() + { + // arrange + // unique issuer names prevent windows chain caches from mixing independent test authorities + var chainId = Guid.NewGuid().ToString("N"); + using var rootKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var rootRequest = new CertificateRequest($"CN=WebExpress Test Root {chainId}", rootKey, HashAlgorithmName.SHA256); + rootRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + rootRequest.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign, true)); + using var root = rootRequest.CreateSelfSigned(_clock.Now.AddDays(-2), _clock.Now.AddDays(100)); + + using var issuerKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var issuerRequest = new CertificateRequest($"CN=WebExpress Test Intermediate {chainId}", issuerKey, HashAlgorithmName.SHA256); + issuerRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + issuerRequest.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign, true)); + using var issuerPublic = issuerRequest.Create(root, _clock.Now.AddDays(-1), _clock.Now.AddDays(95), RandomNumberGenerator.GetBytes(16)); + using var issuer = issuerPublic.CopyWithPrivateKey(issuerKey); + + using var leafKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var request = new CertificateRequest("CN=localhost", leafKey, HashAlgorithmName.SHA256); + var names = new SubjectAlternativeNameBuilder(); + names.AddDnsName("localhost"); + request.CertificateExtensions.Add(names.Build()); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true)); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new("1.3.6.1.5.5.7.3.1") }, false)); + using var leafPublic = request.Create(issuer, _clock.Now.AddHours(-1), _clock.Now.AddDays(90), RandomNumberGenerator.GetBytes(16)); + using var leaf = leafPublic.CopyWithPrivateKey(leafKey); + using var rootPublic = X509CertificateLoader.LoadCertificate(root.Export(X509ContentType.Cert)); + var bundle = new X509Certificate2Collection { leaf, issuerPublic, rootPublic }; + File.WriteAllBytes(Path.Combine(_directory, "server.pfx"), bundle.Export(X509ContentType.Pfx, "test-secret")); + + var settings = Settings(); + var port = GetAvailablePort(); + settings.Endpoints = [new() { Uri = $"https://*:{port}", CertificateAlias = "primary" }]; + var server = new HttpServer(UnitTestFixture.CreateHttpServerContextMock()) { Settings = settings }; + try + { + server.Start(); + var material = server.HttpServerContext.CertificateManager.Resolve("primary"); + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, port, TestContext.Current.CancellationToken); + var receivedIntermediate = false; + using var tls = new SslStream(client.GetStream(), false, (_, _, chain, _) => + { + receivedIntermediate = chain.ChainElements.Cast() + .Any(x => x.Certificate.Thumbprint == issuer.Thumbprint); + return true; + }); + // act + await tls.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = "localhost" }, + TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + + // validation + Assert.Equal(2, material.Chain.Count); + Assert.Contains(material.Chain, x => x.Thumbprint == issuer.Thumbprint); + Assert.True(receivedIntermediate); + } + finally { server.Stop(); } + } + + /// + /// Proves the real Kestrel listener receives manager-owned private material and completes TLS. + /// + /// A task that completes after the TLS peer certificate has been inspected. + [Fact] + public async Task HttpsListenerCompletesHandshakeWithManagedCertificate() + { + // arrange + WriteCertificate(); + var settings = Settings(); + var port = GetAvailablePort(); + settings.Endpoints = [new() { Uri = $"https://127.0.0.1:{port}", CertificateAlias = "primary" }]; + var server = new HttpServer(UnitTestFixture.CreateHttpServerContextMock()) { Settings = settings }; + try + { + server.Start(); + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, port, TestContext.Current.CancellationToken).AsTask().WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + using var tls = new SslStream(client.GetStream(), false, (_, _, _, _) => true); + + // act + await tls.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = "localhost" }, TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + + // validation + Assert.True(tls.IsAuthenticated); + Assert.Equal(server.HttpServerContext.CertificateManager.Resolve("primary").Certificate.Thumbprint, + tls.RemoteCertificate.GetCertHashString()); + } + finally { server.Stop(); } + } + + /// + /// Ensures development HTTP never requires a certificate directory or a configured certificate. + /// + /// A task that completes after connecting to the HTTP listener. + [Fact] + public async Task HttpDevelopmentStartsWithoutCertificates() + { + // arrange + var port = GetAvailablePort(); + var server = new HttpServer(UnitTestFixture.CreateHttpServerContextMock()) + { + Settings = new HttpServerSettings { Endpoints = [new() { Uri = $"http://127.0.0.1:{port}" }] } + }; + + // act + try + { + server.Start(); + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, port, TestContext.Current.CancellationToken).AsTask().WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + + // validation + Assert.Empty(server.HttpServerContext.CertificateManager.GetCertificates()); + } + finally { server.Stop(); } + } + + /// + /// Prevents a failed HTTPS configuration from leaving any HTTP listener running. + /// + /// A task that completes after confirming the port remains closed. + [Fact] + public async Task InvalidHttpsAbortsStartupBeforeHttpBinds() + { + // arrange + var port = GetAvailablePort(); + var server = new HttpServer(UnitTestFixture.CreateHttpServerContextMock()) + { + Settings = new HttpServerSettings + { + Endpoints = [new() { Uri = $"http://127.0.0.1:{port}" }, new() { Uri = "https://127.0.0.1:443", CertificateAlias = "missing" }] + } + }; + // act + var exception = Record.Exception(() => server.Start()); + + // validation + Assert.IsType(exception); + using var client = new TcpClient(); + await Assert.ThrowsAsync(async () => await client.ConnectAsync(IPAddress.Loopback, port, TestContext.Current.CancellationToken)); + server.Stop(); + } + + /// + /// Finds an ephemeral loopback port without relying on a machine-specific fixed test port. + /// + /// The port just released for the test listener. + private static int GetAvailablePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + + /// + /// Removes only the isolated directory created by this test instance. + /// + public void Dispose() + { + Directory.Delete(_directory, true); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestClusterIdentityTokenStore.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestClusterIdentityTokenStore.cs new file mode 100644 index 00000000..1b03e829 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestClusterIdentityTokenStore.cs @@ -0,0 +1,145 @@ +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebCluster; +using WebExpress.WebCore.WebIdentity; +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Tests the token store that keeps replay and revocation markers in the cluster store, so a + /// cluster authenticates without a token directory of its own. + /// + [Collection("NonParallelTests")] + public sealed class UnitTestClusterIdentityTokenStore : IDisposable + { + private readonly string _directory = Path.Combine(Path.GetTempPath(), "wx-cluster-" + Guid.NewGuid().ToString("N")); + + /// + /// Removes the state directory. + /// + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, true); + } + } + + /// + /// Creates a cluster manager of one instance over the shared state directory. + /// + /// The cluster manager. + private IClusterManager CreateCluster() + { + var cluster = UnitTestFixture.CreateAndRegisterComponentHubMock().ClusterManager; + + cluster.UseStore(new FileClusterStore(_directory)); + + return cluster; + } + + /// + /// Tests that a refresh token is redeemed once across instances, the guarantee that makes a + /// stolen refresh token detectable. + /// + [Fact] + public void ConsumeOnceAcrossInstances() + { + var stores = Enumerable.Range(0, 4).Select(_ => new ClusterIdentityTokenStore(CreateCluster())).ToArray(); + var expires = DateTimeOffset.UtcNow.AddHours(1); + var wins = 0; + + Parallel.For(0, 32, i => + { + if (stores[i % stores.Length].TryConsume("refresh:abc", expires)) + { + Interlocked.Increment(ref wins); + } + }); + + Assert.Equal(1, wins); + Assert.True(stores[3].IsRevoked("refresh:abc")); + } + + /// + /// Tests that a revocation on one instance denies the credential on another, and that the + /// store holds a hash instead of the credential identifier. + /// + [Fact] + public void RevocationIsClusterWide() + { + var a = CreateCluster(); + var b = CreateCluster(); + var expires = DateTimeOffset.UtcNow.AddDays(1); + + Assert.False(new ClusterIdentityTokenStore(b).IsRevoked("pat:secret-id")); + new ClusterIdentityTokenStore(a).Revoke("pat:secret-id", expires); + new ClusterIdentityTokenStore(a).Revoke("pat:secret-id", expires); + + Assert.True(new ClusterIdentityTokenStore(b).IsRevoked("pat:secret-id")); + var keys = a.Store.List(ClusterIdentityTokenStore.StoreScope).Select(x => x.Key).ToList(); + Assert.Single(keys); + Assert.DoesNotContain("secret-id", keys[0]); + } + + /// + /// Tests that a credential already past its deadline still gets its marker, since a replay + /// must be recorded even when the clock of the instance runs slightly behind. + /// + [Fact] + public void ExpiredDeadlineStillRecords() + { + var store = new ClusterIdentityTokenStore(CreateCluster()); + + Assert.True(store.TryConsume("refresh:late", DateTimeOffset.UtcNow.AddMinutes(-5))); + Assert.False(store.TryConsume("refresh:late", DateTimeOffset.UtcNow.AddMinutes(-5))); + } + + /// + /// Tests that a store only this process sees is refused, since it would let another instance + /// redeem the same refresh token again. + /// + [Fact] + public void RefusesProcessLocalStore() + { + var store = new ClusterIdentityTokenStore(UnitTestFixture.CreateAndRegisterComponentHubMock().ClusterManager); + + Assert.Throws(() => store.TryConsume("refresh:x", DateTimeOffset.UtcNow.AddHours(1))); + } + + /// + /// Tests that without a token directory the authentication of a cluster keeps its markers in + /// the shared cluster store, and that a replayed refresh token is detected. + /// + [Fact] + public void AuthenticationUsesClusterStoreWithoutTokenDirectory() + { + using var fixture = new AuthenticationFixture(withTokenStorePath: false); + ((ClusterManager)fixture.Hub.ClusterManager).Configure(new ClusterSettings { StatePath = _directory }); + var app = fixture.Application; + + Assert.IsType(fixture.Hub.IdentityTokenStoreManager.GetStore(app)); + Assert.True(fixture.Manager.IsAuthenticationConfigured(app)); + + var pair = fixture.Manager.Issue(new Identity(Guid.NewGuid(), "alice"), app); + var next = fixture.Manager.Refresh(pair.RefreshToken, app); + + Assert.NotNull(next); + Assert.Null(fixture.Manager.Refresh(pair.RefreshToken, app)); + Assert.Null(fixture.Manager.Refresh(next.RefreshToken, app)); + } + + /// + /// Tests that a configured token directory keeps precedence over the cluster store, so + /// existing deployments keep their markers where they are. + /// + [Fact] + public void TokenDirectoryKeepsPrecedence() + { + using var fixture = new AuthenticationFixture(); + fixture.Hub.ClusterManager.UseStore(new FileClusterStore(_directory)); + + Assert.IsType(fixture.Hub.IdentityTokenStoreManager.GetStore(fixture.Application)); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestClusterManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestClusterManager.cs new file mode 100644 index 00000000..41042da4 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestClusterManager.cs @@ -0,0 +1,456 @@ +using Microsoft.Extensions.Configuration; +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebCluster; +using WebExpress.WebCore.WebJob; +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebParameter; +using WebExpress.WebCore.WebSession; +using WebExpress.WebCore.WebSession.Model; +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Tests the cluster manager and the subsystems that change behavior once the server is one + /// of several instances: sessions and job runs. + /// + [Collection("NonParallelTests")] + public sealed class UnitTestClusterManager : IDisposable + { + private readonly string _directory = Path.Combine(Path.GetTempPath(), "wx-cluster-" + Guid.NewGuid().ToString("N")); + + /// + /// A transport that hands published messages to the test and lets it inject received ones. + /// + private sealed class TestTransport : IClusterTransport + { + internal readonly List<(string Topic, byte[] Payload)> Sent = []; + + /// + /// Raised for injected messages. + /// + public event EventHandler Received; + + /// + /// Records the message. + /// + /// The topic. + /// The content. + /// The cancellation token. + /// A completed task. + public Task SendAsync(string topic, byte[] payload, CancellationToken cancellationToken = default) + { + Sent.Add((topic, payload)); + + return Task.CompletedTask; + } + + /// + /// Simulates a message from another instance. + /// + /// The message. + internal void Inject(ClusterMessage message) => Received?.Invoke(this, message); + + /// + /// Does nothing. + /// + public void Dispose() + { + } + } + + /// + /// Removes the shared state directory. + /// + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, true); + } + } + + /// + /// Creates a hub whose cluster manager shares the test directory, standing in for one instance. + /// + /// The node id of the instance. + /// The hub. + private WebComponent.ComponentHub CreateInstance(string nodeId) + { + var hub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + + ((ClusterManager)hub.ClusterManager).Configure(new ClusterSettings { NodeId = nodeId, StatePath = _directory }); + + return hub; + } + + /// + /// Creates a request carrying a session cookie. + /// + /// The session id, or null for a request without session. + /// The request. + private static IRequest CreateRequest(Guid? sessionId) + { + return UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={sessionId?.ToString() ?? ""}\n\n"); + } + + /// + /// Tests that without settings the server keeps its single-instance behavior. + /// + [Fact] + public void SingleInstanceByDefault() + { + var hub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + + Assert.NotNull(hub.ClusterManager); + Assert.False(hub.ClusterManager.IsClustered); + Assert.False(hub.ClusterManager.Store.IsShared); + Assert.Null(hub.ClusterManager.Transport); + Assert.False(string.IsNullOrWhiteSpace(hub.ClusterManager.NodeId)); + } + + /// + /// Tests that a state path turns the instance into a cluster member with a shared store. + /// + [Fact] + public void StatePathMakesClustered() + { + var hub = CreateInstance("node-a"); + + Assert.True(hub.ClusterManager.IsClustered); + Assert.True(hub.ClusterManager.Store.IsShared); + Assert.Equal("node-a", hub.ClusterManager.NodeId); + } + + /// + /// Tests that the cluster block is read from the configuration, the way environment + /// variables of a container deliver it. + /// + [Fact] + public void SettingsAreReadFromConfiguration() + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["WebExpress:Cluster:NodeId"] = "pod-7", + ["WebExpress:Cluster:StatePath"] = _directory, + ["WebExpress:Cluster:Peers:0"] = "dns://webexpress-peers:8080", + ["WebExpress:Cluster:Secret"] = Convert.ToBase64String(new byte[32]) + }).Build(); + + var hub = UnitTestFixture.CreateAndRegisterComponentHubMock(UnitTestFixture.CreateHttpServerContextMock(configuration: configuration)); + + Assert.Equal("pod-7", hub.ClusterManager.NodeId); + Assert.True(hub.ClusterManager.Store.IsShared); + Assert.IsType(hub.ClusterManager.Transport); + } + + /// + /// Tests that peers without a strong secret stop the start instead of opening an + /// unauthenticated endpoint. + /// + [Fact] + public void PeersRequireSecret() + { + var cluster = (ClusterManager)UnitTestFixture.CreateAndRegisterComponentHubMock().ClusterManager; + + Assert.Throws(() => cluster.Configure(new ClusterSettings { Peers = ["http://peer/"] })); + Assert.Throws(() => cluster.Configure(new ClusterSettings { Peers = ["http://peer/"], Secret = Convert.ToBase64String(new byte[16]) })); + + cluster.Configure(new ClusterSettings { Peers = ["http://peer/"], Secret = Convert.ToBase64String(new byte[32]) }); + + Assert.IsType(cluster.Transport); + Assert.True(cluster.IsClustered); + } + + /// + /// Tests that the node id falls back to the host name container runtimes set. + /// + [Fact] + public void NodeIdFallsBackToHostName() + { + Assert.Equal("configured", ClusterManager.ResolveNodeId(" configured ", _ => "pod-1")); + Assert.Equal("pod-1", ClusterManager.ResolveNodeId(null, x => x == "HOSTNAME" ? "pod-1" : null)); + Assert.Equal(Environment.MachineName, ClusterManager.ResolveNodeId(null, _ => null)); + } + + /// + /// Tests that a lock is exclusive across instances and free again once released. + /// + [Fact] + public void LockIsExclusive() + { + var a = CreateInstance("node-a").ClusterManager; + var b = CreateInstance("node-b").ClusterManager; + + var held = a.Lock("packages", TimeSpan.FromMinutes(1), TimeSpan.Zero); + + Assert.NotNull(held); + Assert.Null(b.Lock("packages", TimeSpan.FromMinutes(1), TimeSpan.Zero)); + + held.Dispose(); + + using var taken = b.Lock("packages", TimeSpan.FromMinutes(1), TimeSpan.Zero); + Assert.NotNull(taken); + } + + /// + /// Tests that received messages reach the subscribers of their topic only, and that a + /// disposed subscription stops receiving. + /// + [Fact] + public async Task PublishAndSubscribe() + { + var cluster = UnitTestFixture.CreateAndRegisterComponentHubMock().ClusterManager; + var transport = new TestTransport(); + var received = new List(); + cluster.UseTransport(transport); + + var subscription = cluster.Subscribe("topic", x => received.Add(x.Origin)); + cluster.Subscribe("other", _ => received.Add("wrong")); + + await cluster.PublishAsync("topic", [1], TestContext.Current.CancellationToken); + transport.Inject(new ClusterMessage("topic", "node-b", [1])); + subscription.Dispose(); + transport.Inject(new ClusterMessage("topic", "node-c", [1])); + + Assert.Equal(["node-b"], received); + Assert.Equal("topic", Assert.Single(transport.Sent).Topic); + } + + /// + /// Tests that the clock difference to each sending instance is measured from the time its + /// messages carry, ahead positive and behind negative. + /// + [Fact] + public void ClockSkewIsMeasured() + { + var cluster = UnitTestFixture.CreateAndRegisterComponentHubMock().ClusterManager; + var transport = new TestTransport(); + cluster.UseTransport(transport); + + transport.Inject(new ClusterMessage("topic", "ahead", [], DateTimeOffset.UtcNow.AddSeconds(30))); + transport.Inject(new ClusterMessage("topic", "behind", [], DateTimeOffset.UtcNow.AddSeconds(-12))); + transport.Inject(new ClusterMessage("topic", "untimed", [])); + + Assert.InRange(cluster.ClockSkew["ahead"].TotalSeconds, 29, 31); + Assert.InRange(cluster.ClockSkew["behind"].TotalSeconds, -13, -11); + Assert.False(cluster.ClockSkew.ContainsKey("untimed")); + } + + /// + /// Tests that a session created on one instance, with its properties, is found by another + /// instance - the round-robin case every load balancer produces. + /// + [Fact] + public void SessionMovesBetweenInstances() + { + var a = CreateInstance("node-a"); + var session = a.SessionManager.GetSession(CreateRequest(null)); + session.SetProperty(new SessionPropertyParameter(new Parameter("filter", "open", ParameterScope.Session))); + a.SessionManager.Commit(session); + + var b = CreateInstance("node-b"); + var resumed = b.SessionManager.GetSession(CreateRequest(session.Id)); + + Assert.Equal(session.Id, resumed.Id); + Assert.Equal("open", resumed.GetProperty()?.Params["filter"].Value); + Assert.Equal(1, b.SessionManager.Count); + } + + /// + /// Tests that an id the cluster never issued yields a fresh session with a new id, so a + /// planted cookie cannot fix the id of a session. + /// + [Fact] + public void SessionUnknownIdIsNotAdopted() + { + var hub = CreateInstance("node-a"); + var planted = Guid.NewGuid(); + + var session = hub.SessionManager.GetSession(CreateRequest(planted)); + + Assert.NotEqual(planted, session.Id); + } + + /// + /// Tests that the old id stops resolving on every instance once a sign-in replaced it. + /// + [Fact] + public void SessionRegenerateIdIsClusterWide() + { + var a = CreateInstance("node-a"); + var session = a.SessionManager.GetSession(CreateRequest(null)); + a.SessionManager.Commit(session); + var old = session.Id; + + var renewed = a.SessionManager.RegenerateId(session); + + var b = CreateInstance("node-b"); + Assert.NotEqual(old, b.SessionManager.GetSession(CreateRequest(old)).Id); + Assert.Equal(renewed, b.SessionManager.GetSession(CreateRequest(renewed)).Id); + } + + /// + /// Tests that a request that only read its session does not rewrite it, so page views do + /// not turn into writes to shared storage. + /// + [Fact] + public void SessionUnchangedIsNotRewritten() + { + var hub = CreateInstance("node-a"); + var session = hub.SessionManager.GetSession(CreateRequest(null)); + hub.SessionManager.Commit(session); + + var file = Directory.GetFiles(Path.Combine(_directory, SessionManager.StoreScope)).Single(); + var written = File.GetLastWriteTimeUtc(file); + Thread.Sleep(20); + + var again = CreateInstance("node-b").SessionManager; + again.Commit(again.GetSession(CreateRequest(session.Id))); + + Assert.Equal(written, File.GetLastWriteTimeUtc(file)); + } + + /// + /// Tests that a property type unknown to the reading instance is dropped without losing + /// the session, and that no stored type name can make it recreate a type that is not a + /// session property. + /// + [Fact] + public void SessionDropsUnknownPropertyTypes() + { + var id = Guid.NewGuid(); + var json = """ + {"id":"ID","created":"2026-10-04T10:00:00","updated":"2026-10-04T10:00:00","properties":{ + "System.IO.FileInfo, System.Runtime":{"fileName":"x"}, + "Missing.Type, Missing":{}, + "WebExpress.WebCore.WebSession.Model.SessionPropertyParameter, WebExpress.WebCore":{"Params":{}} + }} + """.Replace("ID", id.ToString()); + var dropped = new List(); + + var session = SessionSerializer.Deserialize(System.Text.Encoding.UTF8.GetBytes(json), dropped.Add)?.Session; + + Assert.Equal(id, session.Id); + Assert.Equal(2, dropped.Count); + Assert.Equal([typeof(SessionPropertyParameter)], session.Properties.Keys); + } + + /// + /// Tests that two requests of one session, running at the same time on two instances, + /// keep each other's changes instead of the later one overwriting the earlier. + /// + [Fact] + public void SessionConcurrentChangesAreMerged() + { + // arrange: a session both instances load before either writes + var a = CreateInstance("node-a"); + var created = a.SessionManager.GetSession(CreateRequest(null)); + created.SetProperty(new SessionPropertyParameter(new Parameter("theme", "dark", ParameterScope.Session))); + a.SessionManager.Commit(created); + + var onA = a.SessionManager.GetSession(CreateRequest(created.Id)); + var b = CreateInstance("node-b"); + var onB = b.SessionManager.GetSession(CreateRequest(created.Id)); + + // act: each instance changes something else, then both write + onA.GetProperty().Params["filter"] = new Parameter("filter", "open", ParameterScope.Session); + onB.GetProperty().Params["page"] = new Parameter("page", "3", ParameterScope.Session); + onB.GetProperty().Params.Remove("theme"); + a.SessionManager.Commit(onA); + b.SessionManager.Commit(onB); + + // validation + var merged = CreateInstance("node-c").SessionManager.GetSession(CreateRequest(created.Id)).GetProperty().Params; + Assert.Equal("open", merged["filter"].Value); + Assert.Equal("3", merged["page"].Value); + Assert.False(merged.ContainsKey("theme")); + } + + /// + /// Tests the merge rules: one-sided changes win, sets of elements combine, and on a real + /// conflict the side being written wins. + /// + [Fact] + public void SessionMergeRules() + { + static System.Text.Json.Nodes.JsonNode Parse(string json) => System.Text.Json.Nodes.JsonNode.Parse(json); + + var original = Parse("""{"a":1,"b":{"x":1},"list":[1,2],"gone":1}"""); + var ours = Parse("""{"a":2,"b":{"x":1},"list":[1,2,3],"gone":1}"""); + var theirs = Parse("""{"a":1,"b":{"x":1,"y":2},"list":[2,4]}"""); + + var merged = SessionMerge.Merge(original, ours, theirs); + + Assert.True(System.Text.Json.Nodes.JsonNode.DeepEquals(Parse("""{"a":2,"b":{"x":1,"y":2},"list":[2,4,3]}"""), merged), merged.ToJsonString()); + Assert.Equal(5, SessionMerge.Merge(Parse("1"), Parse("5"), Parse("7")).GetValue()); + Assert.True(System.Text.Json.Nodes.JsonNode.DeepEquals(theirs, SessionMerge.Merge(original, original, theirs))); + } + + /// + /// Tests that a property the serializer cannot handle neither fails the request nor keeps + /// the other properties from being shared. + /// + [Fact] + public void SessionUnserializablePropertyStaysLocal() + { + var a = CreateInstance("node-a"); + var session = a.SessionManager.GetSession(CreateRequest(null)); + session.SetProperty(new SessionPropertyParameter(new Parameter("kept", "yes", ParameterScope.Session))); + session.SetProperty(new CyclicProperty()); + + a.SessionManager.Commit(session); + + var resumed = CreateInstance("node-b").SessionManager.GetSession(CreateRequest(session.Id)); + Assert.Equal("yes", resumed.GetProperty().Params["kept"].Value); + Assert.Null(resumed.GetProperty()); + } + + /// + /// A session property referring to itself, which no json serializer can write. + /// + public sealed class CyclicProperty : ISessionProperty + { + /// + /// Gets the property itself. + /// + public CyclicProperty Self => this; + } + + /// + /// Tests that of two instances evaluating the same due minute only one runs the job, while + /// a job declared node-local runs on both. + /// + [Fact] + public void JobRunsOncePerCluster() + { + var a = (JobManager)CreateInstance("node-a").JobManager; + var b = (JobManager)CreateInstance("node-b").JobManager; + var clock = new Clock(new DateTime(2026, 10, 4, 12, 0, 0)); + var cluster = new JobContext { JobId = new WebComponent.ComponentId("test.job"), Scope = JobScope.Cluster }; + var node = new JobContext { JobId = new WebComponent.ComponentId("test.cache"), Scope = JobScope.Node }; + + Assert.True(a.Claim(cluster, clock)); + Assert.False(b.Claim(cluster, clock)); + Assert.True(b.Claim(cluster, new Clock(new DateTime(2026, 10, 4, 12, 1, 0)))); + Assert.True(a.Claim(node, clock)); + Assert.True(b.Claim(node, clock)); + } + + /// + /// Tests that the scope attribute is read when a job is registered. + /// + [Fact] + public void JobScopeAttributeIsRead() + { + var hub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var jobs = hub.JobManager.Jobs.Where(x => x.JobId.ToString().EndsWith("testjoba", StringComparison.OrdinalIgnoreCase)).ToList(); + + Assert.NotEmpty(jobs); + Assert.All(jobs, x => Assert.Equal(JobScope.Node, x.Scope)); + + // a job without the attribute acts on shared data and runs once per cluster + Assert.Equal(JobScope.Cluster, new JobContext().Scope); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestClusterStore.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestClusterStore.cs new file mode 100644 index 00000000..6c365526 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestClusterStore.cs @@ -0,0 +1,234 @@ +using System.Text; +using WebExpress.WebCore.WebCluster; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Tests both cluster stores against the same contract, since subsystems switch between them + /// only by configuration and must not notice the difference. + /// + public sealed class UnitTestClusterStore : IDisposable + { + private readonly string _directory = Path.Combine(Path.GetTempPath(), "wx-cluster-" + Guid.NewGuid().ToString("N")); + private readonly TestClock _clock = new(); + + /// + /// Moves time forward on demand, so expiry is tested without waiting. + /// + private sealed class TestClock : TimeProvider + { + internal DateTimeOffset Now = DateTimeOffset.UtcNow; + + /// + /// Returns the simulated time. + /// + /// The current simulated UTC time. + public override DateTimeOffset GetUtcNow() => Now; + } + + /// + /// Lists the store kinds every contract test runs against. + /// + public static TheoryData Kinds => ["memory", "file"]; + + /// + /// Removes the store directory. + /// + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, true); + } + } + + /// + /// Creates a store of the given kind. + /// + /// The kind. + /// The store. + private IClusterStore Create(string kind) + { + return kind == "file" ? new FileClusterStore(_directory, _clock) : new MemoryClusterStore(_clock); + } + + /// + /// Tests that a stored value reads back and an absent one reads as null. + /// + [Theory] + [MemberData(nameof(Kinds))] + public void SetAndGet(string kind) + { + var store = Create(kind); + + store.Set("session", "a", Encoding.UTF8.GetBytes("value"), TimeSpan.FromMinutes(1)); + + Assert.Equal("value", Encoding.UTF8.GetString(store.Get("session", "a"))); + Assert.Null(store.Get("session", "b")); + Assert.Null(store.Get("other", "a")); + } + + /// + /// Tests that an expired entry behaves exactly like an absent one. + /// + [Theory] + [MemberData(nameof(Kinds))] + public void ExpiredEntryIsAbsent(string kind) + { + var store = Create(kind); + + store.Set("session", "a", [1], TimeSpan.FromMinutes(1)); + _clock.Now += TimeSpan.FromMinutes(2); + + Assert.Null(store.Get("session", "a")); + Assert.Empty(store.List("session")); + Assert.False(store.Remove("session", "a")); + } + + /// + /// Tests that only the first add of a key wins while it is live. + /// + [Theory] + [MemberData(nameof(Kinds))] + public void TryAddWinsOnce(string kind) + { + var store = Create(kind); + + Assert.True(store.TryAdd("job", "run", [1], TimeSpan.FromMinutes(1))); + Assert.False(store.TryAdd("job", "run", [2], TimeSpan.FromMinutes(1))); + Assert.Equal([1], store.Get("job", "run")); + } + + /// + /// Tests that an expired entry no longer blocks an add, so a lock whose owner died frees up. + /// + [Theory] + [MemberData(nameof(Kinds))] + public void TryAddReplacesExpired(string kind) + { + var store = Create(kind); + + store.TryAdd("lock", "packages", [1], TimeSpan.FromMinutes(1)); + _clock.Now += TimeSpan.FromMinutes(2); + + Assert.True(store.TryAdd("lock", "packages", [2], TimeSpan.FromMinutes(1))); + Assert.Equal([2], store.Get("lock", "packages")); + } + + /// + /// Tests that of many concurrent adds of one key exactly one succeeds - the guarantee job + /// claims rely on. Separate store instances over one directory stand in for instances. + /// + [Fact] + public void TryAddIsAtomicAcrossInstances() + { + var stores = Enumerable.Range(0, 8).Select(_ => new FileClusterStore(_directory)).ToArray(); + var wins = 0; + + Parallel.For(0, 64, i => + { + if (stores[i % stores.Length].TryAdd("job", "app|job|202610041200", [(byte)i], TimeSpan.FromMinutes(5))) + { + Interlocked.Increment(ref wins); + } + }); + + Assert.Equal(1, wins); + } + + /// + /// Tests that removing returns whether a live entry existed. + /// + [Theory] + [MemberData(nameof(Kinds))] + public void Remove(string kind) + { + var store = Create(kind); + + store.Set("session", "a", [1], TimeSpan.FromMinutes(1)); + + Assert.True(store.Remove("session", "a")); + Assert.False(store.Remove("session", "a")); + Assert.Null(store.Get("session", "a")); + } + + /// + /// Tests that a listing returns the original keys of the live entries of one scope. + /// + [Theory] + [MemberData(nameof(Kinds))] + public void ListAndCount(string kind) + { + var store = Create(kind); + + store.Set("notification", "x", [1], TimeSpan.FromMinutes(1)); + store.Set("notification", "y", [2], TimeSpan.FromMinutes(1)); + store.Set("session", "z", [3], TimeSpan.FromMinutes(1)); + + var list = store.List("notification").OrderBy(x => x.Key).ToList(); + + Assert.Equal(["x", "y"], list.Select(x => x.Key)); + Assert.Equal([2], list[1].Value); + Assert.Equal(2, store.Count("notification")); + Assert.Equal(0, store.Count("empty")); + } + + /// + /// Tests that the file store reports itself shared and the memory store does not, which + /// is what every subsystem decides its behavior on. + /// + [Fact] + public void IsShared() + { + Assert.True(new FileClusterStore(_directory).IsShared); + Assert.False(new MemoryClusterStore().IsShared); + } + + /// + /// Tests that a key cannot leave its scope directory and a scope cannot leave the store. + /// + [Fact] + public void PathsStayInsideTheStore() + { + var store = new FileClusterStore(_directory); + + store.Set("session", "../../escape", [1], TimeSpan.FromMinutes(1)); + + Assert.Single(Directory.GetFiles(Path.Combine(_directory, "session"), "*.entry")); + Assert.Throws(() => store.Set("../escape", "a", [1], TimeSpan.FromMinutes(1))); + Assert.Throws(() => store.Get("Upper", "a")); + } + + /// + /// Tests that the sweep deletes expired entries and keeps live ones. + /// + [Fact] + public void PruneRemovesExpired() + { + var store = new FileClusterStore(_directory, _clock); + + store.Set("session", "old", [1], TimeSpan.FromMinutes(1)); + store.Set("session", "new", [2], TimeSpan.FromHours(1)); + _clock.Now += TimeSpan.FromMinutes(2); + + store.PruneExpired(); + + Assert.Equal(1, store.Count("session")); + Assert.NotNull(store.Get("session", "new")); + } + + /// + /// Tests that a foreign file in a scope directory is neither listed nor fatal. + /// + [Fact] + public void CorruptEntryIsIgnored() + { + var store = new FileClusterStore(_directory); + + store.Set("session", "a", [1], TimeSpan.FromMinutes(1)); + File.WriteAllText(Path.Combine(_directory, "session", "garbage.entry"), "not an entry"); + + Assert.Single(store.List("session")); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestClusterTransport.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestClusterTransport.cs new file mode 100644 index 00000000..18adbcea --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestClusterTransport.cs @@ -0,0 +1,249 @@ +using System.Net; +using System.Text; +using WebExpress.WebCore.WebCluster; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Tests the http transport between instances. The receiving endpoint sits on the public + /// listener, so most tests are about what it must refuse. + /// + public sealed class UnitTestClusterTransport + { + private static readonly byte[] Secret = Enumerable.Range(1, 32).Select(x => (byte)x).ToArray(); + + /// + /// Captures what a transport posts and optionally hands it to a receiving transport, as + /// the endpoint of a peer would. + /// + private sealed class LoopbackHandler : HttpMessageHandler + { + internal readonly List<(Uri Uri, byte[] Body, string Signature)> Posted = []; + internal HttpClusterTransport Target; + + /// + /// Records the request and delivers it. + /// + /// The request. + /// The cancellation token. + /// 204 when the target accepted the message, 403 otherwise. + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = await request.Content.ReadAsByteArrayAsync(cancellationToken); + var signature = request.Headers.GetValues(HttpClusterTransport.SignatureHeader).Single(); + + lock (Posted) + { + Posted.Add((request.RequestUri, body, signature)); + } + + var accepted = Target?.Receive(body, signature) ?? true; + + return new HttpResponseMessage(accepted ? HttpStatusCode.NoContent : HttpStatusCode.Forbidden); + } + } + + /// + /// Moves time on demand. + /// + private sealed class TestClock : TimeProvider + { + internal DateTimeOffset Now = DateTimeOffset.UtcNow; + + /// + /// Returns the simulated time. + /// + /// The current simulated UTC time. + public override DateTimeOffset GetUtcNow() => Now; + } + + /// + /// Tests that a message reaches the subscriber of the receiving instance with topic, + /// origin and payload intact. + /// + [Fact] + public async Task DeliversToPeer() + { + var handler = new LoopbackHandler(); + var sender = new HttpClusterTransport("node-a", Secret, ["http://peer:8080/"], handler: handler); + var receiver = new HttpClusterTransport("node-b", Secret, ["http://peer:8080/"], handler: new LoopbackHandler()); + var received = new List(); + receiver.Received += (_, message) => received.Add(message); + handler.Target = receiver; + + await sender.SendAsync("topic", Encoding.UTF8.GetBytes("hello"), TestContext.Current.CancellationToken); + + var message = Assert.Single(received); + Assert.Equal("topic", message.Topic); + Assert.Equal("node-a", message.Origin); + Assert.Equal("hello", Encoding.UTF8.GetString(message.Payload)); + Assert.InRange((DateTimeOffset.UtcNow - message.Sent.Value).TotalSeconds, 0, 5); + Assert.Equal(new Uri("http://peer:8080/_cluster/bus"), handler.Posted.Single().Uri); + } + + /// + /// Tests that a message signed with another secret is refused. + /// + [Fact] + public async Task RefusesForeignSecret() + { + var handler = new LoopbackHandler(); + var sender = new HttpClusterTransport("node-a", [.. Secret.Reverse()], ["http://peer/"], handler: handler); + var receiver = new HttpClusterTransport("node-b", Secret, [], handler: new LoopbackHandler()); + var received = 0; + receiver.Received += (_, _) => received++; + handler.Target = receiver; + + await sender.SendAsync("topic", [1], TestContext.Current.CancellationToken); + + Assert.Equal(0, received); + } + + /// + /// Tests that changing a single byte of a signed body breaks the signature. + /// + [Fact] + public async Task RefusesTamperedBody() + { + var handler = new LoopbackHandler(); + var sender = new HttpClusterTransport("node-a", Secret, ["http://peer/"], handler: handler); + var receiver = new HttpClusterTransport("node-b", Secret, [], handler: new LoopbackHandler()); + + await sender.SendAsync("topic", Encoding.UTF8.GetBytes("popup"), TestContext.Current.CancellationToken); + + var (_, body, signature) = handler.Posted.Single(); + body[^3] ^= 1; + + Assert.False(receiver.Receive(body, signature)); + Assert.False(receiver.Receive(body, null)); + Assert.False(receiver.Receive(body, "not base64")); + } + + /// + /// Tests that a captured message is accepted once and refused when replayed. + /// + [Fact] + public async Task RefusesReplay() + { + var handler = new LoopbackHandler(); + var sender = new HttpClusterTransport("node-a", Secret, ["http://peer/"], handler: handler); + var receiver = new HttpClusterTransport("node-b", Secret, [], handler: new LoopbackHandler()); + + await sender.SendAsync("topic", [1], TestContext.Current.CancellationToken); + + var (_, body, signature) = handler.Posted.Single(); + + Assert.True(receiver.Receive(body, signature)); + Assert.False(receiver.Receive(body, signature)); + } + + /// + /// Tests that a message older than the replay window is refused, so a captured message + /// cannot be replayed to an instance that never saw it - e.g. one started later. + /// + [Fact] + public async Task RefusesStaleMessage() + { + var clock = new TestClock(); + var handler = new LoopbackHandler(); + var sender = new HttpClusterTransport("node-a", Secret, ["http://peer/"], handler: handler, timeProvider: clock); + var receiver = new HttpClusterTransport("node-b", Secret, [], handler: new LoopbackHandler(), timeProvider: clock); + + await sender.SendAsync("topic", [1], TestContext.Current.CancellationToken); + clock.Now += HttpClusterTransport.ReplayWindow + TimeSpan.FromSeconds(1); + + var (_, body, signature) = handler.Posted.Single(); + + Assert.False(receiver.Receive(body, signature)); + } + + /// + /// Tests that an instance drops the echo of its own message, which a dns entry listing + /// every instance produces. + /// + [Fact] + public async Task DropsOwnEcho() + { + var handler = new LoopbackHandler(); + var transport = new HttpClusterTransport("node-a", Secret, ["http://self/"], handler: handler); + var received = 0; + transport.Received += (_, _) => received++; + handler.Target = transport; + + await transport.SendAsync("topic", [1], TestContext.Current.CancellationToken); + + Assert.Equal(0, received); + } + + /// + /// Tests that a dns entry expands to every address it resolves to. + /// + [Fact] + public async Task ResolvesDnsPeers() + { + var handler = new LoopbackHandler(); + var transport = new HttpClusterTransport("node-a", Secret, ["dns://webexpress-headless:8080"], handler: handler, + resolve: _ => Task.FromResult(new[] { IPAddress.Parse("10.0.0.1"), IPAddress.Parse("10.0.0.2"), IPAddress.Parse("fd00::1") })); + + await transport.SendAsync("topic", [1], TestContext.Current.CancellationToken); + + Assert.Equal + ( + ["http://10.0.0.1:8080/_cluster/bus", "http://10.0.0.2:8080/_cluster/bus", "http://[fd00::1]:8080/_cluster/bus"], + handler.Posted.Select(x => x.Uri.ToString()).Order(StringComparer.Ordinal) + ); + } + + /// + /// Tests that an unreachable peer neither throws nor keeps the others from receiving. + /// + [Fact] + public async Task SurvivesUnreachablePeer() + { + var handler = new FailingHandler(); + var transport = new HttpClusterTransport("node-a", Secret, ["http://down/", "http://up/"], handler: handler); + + await transport.SendAsync("topic", [1], TestContext.Current.CancellationToken); + + Assert.Equal(2, handler.Attempts); + } + + /// + /// Tests that a weak secret and a malformed peer are refused at construction, before the + /// server listens. + /// + [Fact] + public void RefusesWeakConfiguration() + { + Assert.Throws(() => new HttpClusterTransport("node", new byte[16], ["http://peer/"])); + Assert.Throws(() => new HttpClusterTransport("node", Secret, ["ftp://peer/"])); + Assert.Throws(() => new HttpClusterTransport("node", Secret, ["dns://service"])); + } + + /// + /// Fails the first peer and counts every attempt. + /// + private sealed class FailingHandler : HttpMessageHandler + { + internal int Attempts; + + /// + /// Fails requests to the host "down". + /// + /// The request. + /// The cancellation token. + /// A successful response for every other host. + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Interlocked.Increment(ref Attempts); + + if (request.RequestUri.Host == "down") + { + throw new HttpRequestException("connection refused"); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent)); + } + } + } +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestEmailManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestEmailManager.cs new file mode 100644 index 00000000..eb212fd8 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestEmailManager.cs @@ -0,0 +1,746 @@ +using Microsoft.Extensions.Configuration; +using MimeKit; +using System.Net; +using System.Net.Sockets; +using System.Text; +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebCluster; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebEmail; +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Verifies shared delivery policy and actual SMTP envelopes without contacting external mail systems. + /// + [Collection("NonParallelTests")] + public sealed class UnitTestEmailManager + { + /// + /// Verifies that every hub exposes the manager and leaves sending disabled unless configured. + /// + [Fact] + public async Task HubOwnsManagerAndDisablesUnconfiguredSending() + { + // arrange + using var hub = UnitTestFixture.CreateComponentHubMock(); + + // act + var error = await Assert.ThrowsAsync(() => hub.EmailManager.SendAsync("app", Message(), cancellationToken: TestContext.Current.CancellationToken)); + + // validation + Assert.Contains(hub.EmailManager, hub.Managers); + Assert.Equal(EmailError.Disabled, error.Error); + } + + /// + /// Keeps diagnostics current when providers load or unload without leaking credential configuration. + /// + [Fact] + public void StatusTracksProviderLifecycleWithoutMutatingEarlierSnapshots() + { + // arrange + using var hub = CreateHub(new() + { + ["WebExpress:Email:Profiles:default:Options:apiKey"] = "secret-api-key", + ["WebExpress:Email:Profiles:default:UserName"] = "private-account", + ["WebExpress:Email:Profiles:default:Password"] = "secret-password" + }); + var provider = new TestProvider(); + var before = hub.EmailManager.GetStatus(); + + // act + hub.EmailManager.RegisterProvider(provider); + var registered = hub.EmailManager.GetStatus(); + hub.EmailManager.UnregisterProvider(provider); + var removed = hub.EmailManager.GetStatus(); + var json = System.Text.Json.JsonSerializer.Serialize(registered); + + // validation + Assert.False(Assert.Single(before.Profiles).ProviderRegistered); + Assert.True(Assert.Single(registered.Profiles).ProviderRegistered); + Assert.False(Assert.Single(removed.Profiles).ProviderRegistered); + Assert.DoesNotContain("secret-api-key", json); + Assert.DoesNotContain("private-account", json); + Assert.DoesNotContain("secret-password", json); + Assert.DoesNotContain("Password", json); + Assert.DoesNotContain("Options", json); + } + + /// + /// Verifies both body alternatives, copied binary data and provider-specific profile selection. + /// + [Fact] + public async Task SnapshotPreservesBodiesRecipientsAndAttachmentBytes() + { + // arrange + using var hub = CreateHub(new() { ["WebExpress:Email:Profiles:default:Options:region"] = "eu" }); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + byte[] received = null; + var provider = new TestProvider(async (message, profile, token) => + { + entered.SetResult(); + await release.Task.WaitAsync(token); + Assert.Equal("Plain München", message.TextBody); + Assert.Equal("

        HTML München

        ", message.HtmlBody); + Assert.Single(message.To); + Assert.Single(message.Cc); + Assert.Single(message.Bcc); + Assert.Equal("reply@example.org", message.ReplyTo.Mailboxes.Single().Address); + Assert.Equal("eu", profile.Options["region"]); + using var output = new MemoryStream(); + ((MimePart)message.Attachments.Single()).Content.DecodeTo(output, TestContext.Current.CancellationToken); + received = output.ToArray(); + }); + hub.EmailManager.RegisterProvider(provider); + byte[] bytes = [0, 128, 255]; + var input = new EmailMessage + { + To = ["receiver@example.org"], Cc = ["copy@example.org"], Bcc = ["hidden@example.org"], + ReplyTo = "reply@example.org", Subject = "München", TextBody = "Plain München", + HtmlBody = "

        HTML München

        ", + Attachments = [new() { FileName = "report.bin", Content = bytes }] + }; + + // act + var sending = hub.EmailManager.SendAsync("app", input, "DEFAULT", cancellationToken: TestContext.Current.CancellationToken); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + bytes[0] = 99; + release.SetResult(); + var result = await sending; + + // validation + Assert.Equal(EmailSendResult.Accepted, result); + Assert.Equal(new byte[] { 0, 128, 255 }, received); + } + + /// + /// Verifies that malformed content is rejected before creating a deduplication claim. + /// + /// The invalid input category. + [Theory] + [InlineData("recipient")] + [InlineData("header")] + [InlineData("body")] + [InlineData("attachment")] + [InlineData("size")] + [InlineData("sender")] + public async Task InvalidInputDoesNotClaimDelivery(string kind) + { + // arrange + using var hub = CreateHub(new() { ["WebExpress:Email:MaxAttachmentBytes"] = "2" }); + var provider = new TestProvider(); + hub.EmailManager.RegisterProvider(provider); + var invalid = new EmailMessage + { + DeliveryId = "business-event", + To = kind == "recipient" ? ["bad\r\nBcc: stolen@example.org"] : ["receiver@example.org"], + From = kind == "sender" ? "not-an-address" : null, + Subject = kind == "header" ? "subject\r\nX-Injected: yes" : "subject", + TextBody = kind == "body" ? null : "body", + Attachments = kind == "attachment" ? [new() { FileName = "../file", Content = [1] }] + : kind == "size" ? [new() { FileName = "file", Content = [1, 2, 3] }] : [] + }; + + // act + var error = await Assert.ThrowsAsync(() => hub.EmailManager.SendAsync("app", invalid, cancellationToken: TestContext.Current.CancellationToken)); + var corrected = await hub.EmailManager.SendAsync("app", Message("business-event"), cancellationToken: TestContext.Current.CancellationToken); + + // validation + Assert.Equal(EmailError.InvalidMessage, error.Error); + Assert.Equal(EmailSendResult.Accepted, corrected); + Assert.Equal(1, provider.Calls); + } + + /// + /// Verifies that shared atomic claims suppress concurrent replicas and survive manager recreation. + /// + [Fact] + public async Task ReplicasAndRestartShareClaimsButApplicationsRemainIndependent() + { + // arrange + var directory = Path.Combine(Path.GetTempPath(), "wx-email-" + Guid.NewGuid().ToString("N")); + try + { + using var first = CreateHub(); + using var second = CreateHub(); + first.ClusterManager.UseStore(new FileClusterStore(directory)); + second.ClusterManager.UseStore(new FileClusterStore(directory)); + var provider = new TestProvider(); + first.EmailManager.RegisterProvider(provider); + second.EmailManager.RegisterProvider(provider); + + // act + var attempts = await Task.WhenAll(Enumerable.Range(0, 20).Select(index => + (index % 2 == 0 ? first : second).EmailManager.SendAsync("app", Message("event"), cancellationToken: TestContext.Current.CancellationToken))); + using var restarted = CreateHub(); + restarted.ClusterManager.UseStore(new FileClusterStore(directory)); + restarted.EmailManager.RegisterProvider(provider); + var duplicate = await restarted.EmailManager.SendAsync("app", Message("event"), cancellationToken: TestContext.Current.CancellationToken); + var independent = await restarted.EmailManager.SendAsync("other-app", Message("event"), cancellationToken: TestContext.Current.CancellationToken); + + // validation + Assert.Single(attempts, result => result == EmailSendResult.Accepted); + Assert.Equal(19, attempts.Count(result => result == EmailSendResult.AlreadyAttempted)); + Assert.Equal(EmailSendResult.AlreadyAttempted, duplicate); + Assert.Equal(EmailSendResult.Accepted, independent); + Assert.Equal(2, provider.Calls); + } + finally + { + Directory.Delete(directory, true); + } + } + + /// + /// Verifies that uncertain provider failures stay claimed and sensitive exception text stays out of logs. + /// + [Fact] + public async Task FailureIsConsistentAndNeverAutomaticallyRetried() + { + // arrange + var configuration = Configuration(); + var context = UnitTestFixture.CreateHttpServerContextMock(configuration: configuration); + using var hub = UnitTestFixture.CreateComponentHubMock(context); + var provider = new TestProvider((_, _, _) => throw new IOException("password=secret recipient@example.org")); + hub.EmailManager.RegisterProvider(provider); + + // act + var error = await Assert.ThrowsAsync(() => hub.EmailManager.SendAsync("app", Message("event"), cancellationToken: TestContext.Current.CancellationToken)); + var duplicate = await hub.EmailManager.SendAsync("app", Message("event"), cancellationToken: TestContext.Current.CancellationToken); + + // validation + Assert.Equal(EmailError.DeliveryFailed, error.Error); + Assert.IsType(error.InnerException); + Assert.Equal(EmailSendResult.AlreadyAttempted, duplicate); + Assert.Equal(1, provider.Calls); + var logs = string.Join('\n', context.Log.GetRecentEntries().Select(entry => entry.Message)); + Assert.Contains("category=DeliveryFailed", logs); + Assert.DoesNotContain("secret", logs); + Assert.DoesNotContain("recipient@example.org", logs); + Assert.DoesNotContain("subject", logs); + } + + /// + /// Verifies cancellation before submission leaves the logical message available for a later call. + /// + [Fact] + public async Task PreCancelledSubmissionDoesNotClaim() + { + // arrange + using var hub = CreateHub(); + var provider = new TestProvider(); + hub.EmailManager.RegisterProvider(provider); + + // act + await Assert.ThrowsAnyAsync(() => + hub.EmailManager.SendAsync("app", Message("event"), cancellationToken: new CancellationToken(true))); + var result = await hub.EmailManager.SendAsync("app", Message("event"), cancellationToken: TestContext.Current.CancellationToken); + + // validation + Assert.Equal(EmailSendResult.Accepted, result); + Assert.Equal(1, provider.Calls); + } + + /// + /// Verifies that deadlines have a stable error category and caller cancellation retains its native contract. + /// + /// Whether to cancel the caller instead of waiting for the deadline. + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CancellationAndDeadlineRetainUncertainClaims(bool cancelCaller) + { + // arrange + using var hub = CreateHub(new() { ["WebExpress:Email:Profiles:default:TimeoutSeconds"] = "1" }); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + hub.EmailManager.RegisterProvider(new TestProvider(async (_, _, token) => + { + entered.SetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, token); + })); + using var cancellation = new CancellationTokenSource(); + + // act + var pending = hub.EmailManager.SendAsync("app", Message("event"), cancellationToken: cancellation.Token); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + if (cancelCaller) cancellation.Cancel(); + var error = await Record.ExceptionAsync(async () => await pending); + var duplicate = await hub.EmailManager.SendAsync("app", Message("event"), cancellationToken: TestContext.Current.CancellationToken); + + // validation + if (cancelCaller) Assert.IsAssignableFrom(error); + else Assert.Equal(EmailError.Timeout, Assert.IsType(error).Error); + Assert.Equal(EmailSendResult.AlreadyAttempted, duplicate); + } + + /// + /// Verifies that graceful shutdown drains admitted mail and refuses subsequent submissions. + /// + [Fact] + public async Task GracefulShutdownWaitsForAdmittedMail() + { + // arrange + var context = UnitTestFixture.CreateHttpServerContextMock(configuration: Configuration()); + using var hub = UnitTestFixture.CreateComponentHubMock(context); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + hub.EmailManager.RegisterProvider(new TestProvider(async (_, _, token) => + { + entered.SetResult(); + await release.Task.WaitAsync(token); + })); + + // act + var pending = hub.EmailManager.SendAsync("app", Message(), cancellationToken: TestContext.Current.CancellationToken); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + var stopping = context.Lifetime.StopAsync(TestContext.Current.CancellationToken); + var rejected = await Assert.ThrowsAsync(() => hub.EmailManager.SendAsync("app", Message(), cancellationToken: TestContext.Current.CancellationToken)); + var waited = !stopping.IsCompleted; + release.SetResult(); + await stopping.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + // validation + Assert.True(waited); + Assert.Equal(EmailError.Stopping, rejected.Error); + Assert.Equal(EmailSendResult.Accepted, await pending); + } + + /// + /// Verifies provider registration, unload, replacement and manager disposal boundaries. + /// + [Fact] + public async Task ProviderLifecycleRejectsCollisionsAndMissingProviders() + { + // arrange + using var hub = CreateHub(); + var first = new TestProvider(); + var second = new TestProvider(); + hub.EmailManager.RegisterProvider(first); + + // act + Assert.Throws(() => hub.EmailManager.RegisterProvider(second)); + Assert.False(hub.EmailManager.UnregisterProvider(second)); + Assert.True(hub.EmailManager.UnregisterProvider(first)); + var missing = await Assert.ThrowsAsync(() => hub.EmailManager.SendAsync("app", Message(), cancellationToken: TestContext.Current.CancellationToken)); + hub.EmailManager.RegisterProvider(second); + var sent = await hub.EmailManager.SendAsync("app", Message(), cancellationToken: TestContext.Current.CancellationToken); + hub.EmailManager.Dispose(); + + // validation + Assert.Equal(EmailError.Configuration, missing.Error); + Assert.Equal(EmailSendResult.Accepted, sent); + Assert.Throws(() => hub.EmailManager.RegisterProvider(first)); + await Assert.ThrowsAsync(() => hub.EmailManager.SendAsync("app", Message(), cancellationToken: TestContext.Current.CancellationToken)); + } + + /// + /// Verifies that a cluster with only a message bus cannot silently use local deduplication. + /// + [Fact] + public async Task ClusterWithoutSharedStoreFailsClosed() + { + // arrange + using var hub = CreateHub(new() + { + ["WebExpress:Cluster:Peers:0"] = "http://127.0.0.1:1/", + ["WebExpress:Cluster:Secret"] = Convert.ToBase64String(new byte[32]) + }); + var provider = new TestProvider(); + hub.EmailManager.RegisterProvider(provider); + + // act + var error = await Assert.ThrowsAsync(() => hub.EmailManager.SendAsync("app", Message(), cancellationToken: TestContext.Current.CancellationToken)); + + // validation + Assert.Equal(EmailError.StoreUnavailable, error.Error); + Assert.Equal(0, provider.Calls); + } + + /// + /// Verifies that profile errors stop before any external submission is attempted. + /// + /// The invalid configuration key. + /// The invalid configuration value. + [Theory] + [InlineData("TimeoutSeconds", "0")] + [InlineData("TimeoutSeconds", "301")] + [InlineData("Port", "0")] + [InlineData("Security", "99")] + [InlineData("Host", "")] + [InlineData("UserName", "account-without-password")] + public async Task InvalidProfileFailsBeforeClaim(string setting, string value) + { + // arrange + var settings = SmtpSettings(25); + settings["WebExpress:Email:Profiles:default:" + setting] = value; + using var hub = CreateHub(settings); + + // act + var error = await Assert.ThrowsAsync(() => hub.EmailManager.SendAsync("app", Message(), cancellationToken: TestContext.Current.CancellationToken)); + + // validation + Assert.Equal(EmailError.Configuration, error.Error); + Assert.Empty(hub.ClusterManager.Store.List("email-attempt")); + } + + /// + /// Verifies that a failed shared store cannot degrade into an unprotected delivery. + /// + [Fact] + public async Task StoreFailurePreventsProviderCall() + { + // arrange + var directory = Path.Combine(Path.GetTempPath(), "wx-email-" + Guid.NewGuid().ToString("N")); + using var hub = CreateHub(); + var provider = new TestProvider(); + hub.EmailManager.RegisterProvider(provider); + hub.ClusterManager.UseStore(new FileClusterStore(directory)); + Directory.Delete(directory, true); + File.WriteAllText(directory, "blocks directory access"); + try + { + // act + var error = await Assert.ThrowsAsync(() => hub.EmailManager.SendAsync("app", Message(), cancellationToken: TestContext.Current.CancellationToken)); + + // validation + Assert.Equal(EmailError.StoreUnavailable, error.Error); + Assert.Equal(0, provider.Calls); + } + finally + { + File.Delete(directory); + } + } + + /// + /// Verifies profile routing without allowing a profile switch to bypass a logical delivery claim. + /// + [Fact] + public async Task ProfileSwitchDoesNotBypassDeduplication() + { + // arrange + using var hub = CreateHub(new() + { + ["WebExpress:Email:Profiles:other:Provider"] = "test", + ["WebExpress:Email:Profiles:other:From"] = "other@example.org" + }); + string sender = null; + var provider = new TestProvider((message, _, _) => + { + sender = message.From.Mailboxes.Single().Address; + return Task.CompletedTask; + }); + hub.EmailManager.RegisterProvider(provider); + + // act + var accepted = await hub.EmailManager.SendAsync("app", Message("event"), "other", cancellationToken: TestContext.Current.CancellationToken); + var duplicate = await hub.EmailManager.SendAsync("app", Message("event"), cancellationToken: TestContext.Current.CancellationToken); + var missing = await Assert.ThrowsAsync(() => hub.EmailManager.SendAsync("app", Message(), "missing", cancellationToken: TestContext.Current.CancellationToken)); + + // validation + Assert.Equal(EmailSendResult.Accepted, accepted); + Assert.Equal("other@example.org", sender); + Assert.Equal(EmailSendResult.AlreadyAttempted, duplicate); + Assert.Equal(EmailError.Configuration, missing.Error); + Assert.Equal(1, provider.Calls); + } + + /// + /// Verifies that the documented retention boundary permits a fresh attempt after expiry. + /// + [Fact] + public async Task ClaimExpiresAfterConfiguredRetention() + { + // arrange + using var hub = CreateHub(new() { ["WebExpress:Email:DeduplicationHours"] = "1" }); + var clock = new TestClock(); + hub.ClusterManager.UseStore(new MemoryClusterStore(clock)); + var provider = new TestProvider(); + hub.EmailManager.RegisterProvider(provider); + + // act + var first = await hub.EmailManager.SendAsync("app", Message("event"), cancellationToken: TestContext.Current.CancellationToken); + clock.Now += TimeSpan.FromMinutes(59); + var retained = await hub.EmailManager.SendAsync("app", Message("event"), cancellationToken: TestContext.Current.CancellationToken); + clock.Now += TimeSpan.FromMinutes(2); + var expired = await hub.EmailManager.SendAsync("app", Message("event"), cancellationToken: TestContext.Current.CancellationToken); + + // validation + Assert.Equal(EmailSendResult.Accepted, first); + Assert.Equal(EmailSendResult.AlreadyAttempted, retained); + Assert.Equal(EmailSendResult.Accepted, expired); + Assert.Equal(2, provider.Calls); + } + + /// + /// Verifies that a real SMTP exchange preserves Unicode MIME content and keeps Bcc out of headers. + /// + /// Whether to include the plain text alternative. + /// Whether to include the HTML alternative. + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + public async Task SmtpRoundTripPreservesMimeAndHidesBcc(bool plainText, bool html) + { + // arrange + await using var server = new SmtpServer(); + using var hub = CreateHub(SmtpSettings(server.Port)); + var message = new EmailMessage + { + To = ["receiver@example.org"], Bcc = ["hidden@example.org"], + Subject = "Grüße", TextBody = plainText ? "Plain München" : null, + HtmlBody = html ? "

        HTML München

        " : null, + Attachments = [new() { FileName = "Prüfung.bin", Content = [0, 128, 255] }] + }; + + // act + var result = await hub.EmailManager.SendAsync("app", message, cancellationToken: TestContext.Current.CancellationToken); + var wire = await server.Content.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + using var mime = MimeMessage.Load(new MemoryStream(Encoding.UTF8.GetBytes(wire)), TestContext.Current.CancellationToken); + using var attachment = new MemoryStream(); + ((MimePart)mime.Attachments.Single()).Content.DecodeTo(attachment, TestContext.Current.CancellationToken); + + // validation + Assert.Equal(EmailSendResult.Accepted, result); + Assert.Equal("Grüße", mime.Subject); + Assert.Equal(plainText ? "Plain München" : null, mime.TextBody?.TrimEnd()); + Assert.Equal(html ? "

        HTML München

        " : null, mime.HtmlBody?.TrimEnd()); + Assert.Empty(mime.Bcc); + Assert.Contains(server.Commands, command => command.Contains("hidden@example.org")); + Assert.Equal(new byte[] { 0, 128, 255 }, attachment.ToArray()); + } + + /// + /// Verifies that mandatory STARTTLS fails without exposing a message to a plaintext-only server. + /// + [Fact] + public async Task SmtpNeverDowngradesRequiredTls() + { + // arrange + await using var server = new SmtpServer(); + var settings = SmtpSettings(server.Port); + settings["WebExpress:Email:Profiles:default:Security"] = "StartTls"; + using var hub = CreateHub(settings); + + // act + var error = await Assert.ThrowsAsync(() => hub.EmailManager.SendAsync("app", Message(), cancellationToken: TestContext.Current.CancellationToken)); + + // validation + Assert.Equal(EmailError.DeliveryFailed, error.Error); + Assert.DoesNotContain(server.Commands, command => command.StartsWith("MAIL FROM", StringComparison.Ordinal)); + } + + /// + /// Verifies that an actual SMTP refusal is normalized and cannot trigger a second attempt. + /// + [Fact] + public async Task SmtpRecipientRefusalIsNormalized() + { + // arrange + await using var server = new SmtpServer(rejectRecipient: true); + using var hub = CreateHub(SmtpSettings(server.Port)); + + // act + var error = await Assert.ThrowsAsync(() => hub.EmailManager.SendAsync("app", Message("event"), cancellationToken: TestContext.Current.CancellationToken)); + var duplicate = await hub.EmailManager.SendAsync("app", Message("event"), cancellationToken: TestContext.Current.CancellationToken); + + // validation + Assert.Equal(EmailError.DeliveryFailed, error.Error); + Assert.Equal(EmailSendResult.AlreadyAttempted, duplicate); + Assert.False(server.Content.IsCompletedSuccessfully); + } + + /// + /// Creates a fresh hub so manager state never leaks across test cases. + /// + /// The settings replacing the standard test profile. + /// A hub owned by the caller. + private static ComponentHub CreateHub(Dictionary overrides = null) + { + return UnitTestFixture.CreateComponentHubMock(UnitTestFixture.CreateHttpServerContextMock(configuration: Configuration(overrides))); + } + + /// + /// Supplies settings through the same binder used by production hosts. + /// + /// The optional settings replacing the test defaults. + /// The complete configuration snapshot. + private static IConfigurationRoot Configuration(Dictionary overrides = null) + { + var values = new Dictionary + { + ["WebExpress:Email:Enabled"] = "true", + ["WebExpress:Email:Profiles:default:Provider"] = "test", + ["WebExpress:Email:Profiles:default:From"] = "sender@example.org" + }; + foreach (var entry in overrides ?? []) values[entry.Key] = entry.Value; + return new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + } + + /// + /// Configures only the ephemeral loopback server for protocol tests. + /// + /// The port assigned by the operating system. + /// The profile overrides for local SMTP. + private static Dictionary SmtpSettings(int port) + { + return new() + { + ["WebExpress:Email:Profiles:default:Provider"] = "smtp", + ["WebExpress:Email:Profiles:default:Host"] = "127.0.0.1", + ["WebExpress:Email:Profiles:default:Port"] = port.ToString(), + ["WebExpress:Email:Profiles:default:Security"] = "None" + }; + } + + /// + /// Creates minimal valid content with an optional stable identifier. + /// + /// The logical identifier reused for deduplication tests. + /// The application-owned message. + private static EmailMessage Message(string id = null) + { + return new() { DeliveryId = id ?? Guid.NewGuid().ToString("N"), To = ["receiver@example.org"], Subject = "subject", TextBody = "body" }; + } + + /// + /// Captures calls at the provider boundary while allowing deterministic delays and failures. + /// + private sealed class TestProvider : IEmailProvider + { + private readonly Func _send; + + /// Counts submissions independently of concurrent caller attempts. + public int Calls; + + /// Gets the profile name reserved for this test implementation. + public string Name => "test"; + + /// Configures the provider behavior without replacing manager logic. + /// The optional controlled submission behavior. + public TestProvider(Func send = null) + { + _send = send ?? ((_, _, _) => Task.CompletedTask); + } + + /// Counts actual submissions independently of duplicate caller attempts. + /// The validated MIME snapshot. + /// The selected profile copy. + /// The combined deadline and caller cancellation. + /// The configured submission task. + public Task SendAsync(MimeMessage message, EmailProfileSettings profile, CancellationToken cancellationToken) + { + Interlocked.Increment(ref Calls); + return _send(message, profile, cancellationToken); + } + } + + /// + /// Advances claim expiration without sleeping for the configured retention period. + /// + private sealed class TestClock : TimeProvider + { + /// Stores the controlled instant used by the cluster store. + internal DateTimeOffset Now = DateTimeOffset.UtcNow; + + /// Supplies deterministic UTC time for claim expiration. + /// The test-controlled instant. + public override DateTimeOffset GetUtcNow() => Now; + } + + /// + /// Implements a bounded local SMTP conversation to verify the shipped transport over a real socket. + /// + private sealed class SmtpServer : IAsyncDisposable + { + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _stop = new(TimeSpan.FromSeconds(10)); + private readonly TaskCompletionSource _content = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Task _worker; + + /// Gets the ephemeral listener port. + public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; + + /// Gets the payload after the SMTP data terminator arrives. + public Task Content => _content.Task; + + /// Gets the observed envelope commands for recipient assertions. + public System.Collections.Concurrent.ConcurrentQueue Commands { get; } = new(); + + /// Starts one bounded SMTP session on an isolated loopback port. + /// Whether recipient commands return a permanent refusal. + public SmtpServer(bool rejectRecipient = false) + { + _listener.Start(); + _worker = RunAsync(rejectRecipient); + } + + /// Serves the minimum SMTP dialogue needed to observe real client serialization. + /// Whether recipient commands return a permanent refusal. + /// A task ending when the connection closes or the test deadline expires. + private async Task RunAsync(bool rejectRecipient) + { + try + { + using var client = await _listener.AcceptTcpClientAsync(_stop.Token); + await using var stream = client.GetStream(); + using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true); + await using var writer = new StreamWriter(stream, new UTF8Encoding(false), leaveOpen: true) + { NewLine = "\r\n", AutoFlush = true }; + await writer.WriteLineAsync("220 localhost SMTP"); + while (await reader.ReadLineAsync(_stop.Token) is { } line) + { + Commands.Enqueue(line); + if (line.StartsWith("EHLO", StringComparison.Ordinal)) + { + await writer.WriteLineAsync("250-localhost\r\n250 SIZE 10000000"); + } + else if (line.StartsWith("RCPT", StringComparison.Ordinal) && rejectRecipient) + { + await writer.WriteLineAsync("550 recipient refused"); + } + else if (line == "DATA") + { + await writer.WriteLineAsync("354 send data"); + var content = new StringBuilder(); + while (await reader.ReadLineAsync(_stop.Token) is { } data && data != ".") + { + content.Append(data.StartsWith("..", StringComparison.Ordinal) ? data[1..] : data).Append("\r\n"); + } + _content.TrySetResult(content.ToString()); + await writer.WriteLineAsync("250 accepted"); + } + else if (line == "QUIT") + { + await writer.WriteLineAsync("221 bye"); + return; + } + else await writer.WriteLineAsync("250 ok"); + } + } + catch (OperationCanceledException) when (_stop.IsCancellationRequested) + { + // bounded test shutdown owns this cancellation + } + finally + { + _content.TrySetCanceled(); + } + } + + /// Stops the listener and joins the session before releasing test resources. + /// A task completing after the session exits. + public async ValueTask DisposeAsync() + { + await _stop.CancelAsync(); + _listener.Stop(); + await _worker; + _stop.Dispose(); + } + } + } +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestFileIdentityTokenStore.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestFileIdentityTokenStore.cs new file mode 100644 index 00000000..d6f04626 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestFileIdentityTokenStore.cs @@ -0,0 +1,83 @@ +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebIdentity; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Every login, refresh and revocation leaves a marker in the file store. Markers whose + /// credential has expired decide nothing any more and must not accumulate forever. + /// + [Collection("NonParallelTests")] + public class UnitTestFileIdentityTokenStore : IDisposable + { + private readonly string _directory = Path.Combine(Path.GetTempPath(), "webexpress-store-" + Guid.NewGuid().ToString("N")); + private readonly AuthenticationFixture.TestClock _clock = new(); + + /// + /// A sweep removes exactly the markers past their deadline: live replay and revocation + /// markers stay in force, and a marker that cannot be read is left alone. + /// + [Fact] + public void PruneRemovesOnlyExpiredMarkers() + { + var store = new FileIdentityTokenStore(_directory, _clock); + store.TryConsume("refresh:expired", _clock.Now.AddMinutes(1)); + store.TryConsume("refresh:live", _clock.Now.AddHours(2)); + store.Revoke("grant:revoked", _clock.Now.AddHours(2)); + var unreadable = Path.Combine(_directory, "unreadable.token"); + File.WriteAllText(unreadable, "not a deadline"); + + // what a sweep sees of a marker caught mid-write: the first digits of a live deadline + var partial = Path.Combine(_directory, "partial.token"); + File.WriteAllText(partial, _clock.Now.AddHours(2).ToUnixTimeSeconds().ToString()[..4]); + + _clock.Now = _clock.Now.AddHours(1); + store.PruneExpired(); + + Assert.False(store.IsRevoked("refresh:expired")); + Assert.True(store.IsRevoked("refresh:live")); + Assert.True(store.IsRevoked("grant:revoked")); + Assert.True(File.Exists(unreadable)); + Assert.True(File.Exists(partial)); + } + + /// + /// The sweep needs no caller: writing a marker starts it once the interval has passed, and + /// only once per interval however many markers are written. + /// + [Fact] + public void WritingMarkersSweepsOncePerInterval() + { + var store = new FileIdentityTokenStore(_directory, _clock); + + // a new store waits an interval before its first sweep, so this expired marker remains + store.TryConsume("challenge:first", _clock.Now.AddSeconds(-1)); + Assert.False(SpinWait.SpinUntil(() => !store.IsRevoked("challenge:first"), TimeSpan.FromMilliseconds(500))); + + // once the interval has passed, the next write sweeps + _clock.Now += FileIdentityTokenStore.SweepInterval; + store.TryConsume("challenge:second", _clock.Now.AddHours(1)); + Assert.True(SpinWait.SpinUntil(() => !store.IsRevoked("challenge:first"), TimeSpan.FromSeconds(10))); + Assert.True(store.IsRevoked("challenge:second")); + + // within the next interval a further write starts no sweep + store.TryConsume("challenge:third", _clock.Now.AddSeconds(-1)); + Assert.False(SpinWait.SpinUntil(() => !store.IsRevoked("challenge:third"), TimeSpan.FromMilliseconds(500))); + } + + /// + /// Removes the isolated marker directory. + /// + public void Dispose() + { + try + { + Directory.Delete(_directory, true); + } + catch (IOException) + { + // a background sweep may still hold a marker; the temp directory is left behind then + } + } + } +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestFragmentCheck.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestFragmentCheck.cs new file mode 100644 index 00000000..b5b3f718 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestFragmentCheck.cs @@ -0,0 +1,97 @@ +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebFragment; +using WebExpress.WebCore.WebIdentity; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Guards the visibility check every fragment runs before it renders, so a link to a + /// protected page is not offered to identities the server would turn away. + /// + [Collection("NonParallelTests")] + public class UnitTestFragmentCheck + { + /// + /// A fragment without policies stays visible to anonymous requests. + /// + [Fact] + public void FragmentWithoutPolicyIsShownToAnyone() + { + // arrange + using var fixture = new AuthenticationFixture(); + var fragmentContext = new FragmentContext(); + + // act + var visible = fragmentContext.Check(fixture.Request()); + + // validation + Assert.True(visible); + } + + /// + /// A fragment with a policy is shown only to an identity that holds the policy. + /// + /// Whether a real signed login supplies the request identity. + /// Whether the login grants the fragment's policy. + /// Whether the fragment is expected to be visible. + [Theory] + [InlineData(false, false, false)] + [InlineData(true, false, false)] + [InlineData(true, true, true)] + public void FragmentWithPolicyIsShownOnlyToGrantedIdentity(bool authenticated, bool grantPolicy, bool expected) + { + // arrange + using var fixture = new AuthenticationFixture(); + var request = fixture.Request(); + var fragmentContext = new FragmentContext { Policies = [new TestIdentityPolicyA()] }; + if (authenticated) + { + fixture.Manager.Login(new Identity(Guid.NewGuid(), "test-user", + policyNames: grantPolicy ? [typeof(TestIdentityPolicyA).FullName] : []), request); + } + + // act + var visible = fragmentContext.Check(request); + + // validation + Assert.Equal(expected, visible); + } + + /// + /// A failing condition hides the fragment even from an identity that holds the policy. + /// + [Fact] + public void FailingConditionHidesFragmentDespitePolicy() + { + // arrange + using var fixture = new AuthenticationFixture(); + var request = fixture.Request(); + var fragmentContext = new FragmentContext + { + Conditions = [new ConditionNever()], + Policies = [new TestIdentityPolicyA()] + }; + fixture.Manager.Login(new Identity(Guid.NewGuid(), "test-user", + policyNames: [typeof(TestIdentityPolicyA).FullName]), request); + + // act + var visible = fragmentContext.Check(request); + + // validation + Assert.False(visible); + } + + /// + /// Stands for any condition that is not met, independent of the identity. + /// + private sealed class ConditionNever : WebCondition.ICondition + { + /// + /// Rejects every request. + /// + /// The request. + /// Always false. + public bool Fulfillment(WebMessage.IRequest request) => false; + } + } +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestHealthManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestHealthManager.cs new file mode 100644 index 00000000..89ae776f --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestHealthManager.cs @@ -0,0 +1,671 @@ +using System.Reflection; +using WebExpress.WebCore.Test.Data; +using static WebExpress.WebCore.Test.Fixture.HealthTestFixture; +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebHealth; +using WebExpress.WebCore.WebLog; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Verifies that framework state and every critical dependency participate in aggregate health. + /// + [Collection("NonParallelTests")] + public class UnitTestHealthManager + { + /// + /// Discovers components through real plugin events and rebinds applications added after their plugin. + /// + /// A task that completes after both lifecycle orders have been validated. + [Fact] + public async Task PluginAndApplicationEvents_DiscoverChecksAutomatically() + { + // arrange + var (hub, _) = CreateHost(); + using var manager = hub.HealthManager; + var additions = new List(); + var removals = new List(); + manager.AddHealth += (_, context) => additions.Add(context); + manager.RemoveHealth += (_, context) => removals.Add(context); + + // act + ((WebPlugin.PluginManager)hub.PluginManager).Register(); + var plugin = hub.PluginManager.GetPlugin(typeof(TestPlugin)); + var first = manager.HealthChecks.ToArray(); + var initialHealth = await manager.CheckAsync(TestContext.Current.CancellationToken); + ((ApplicationManager)hub.ApplicationManager).Remove(plugin); + var empty = manager.HealthChecks.ToArray(); + typeof(ApplicationManager).GetMethod("Register", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(hub.ApplicationManager, [plugin]); + var rebound = manager.HealthChecks.ToArray(); + var reboundHealth = await manager.CheckAsync(TestContext.Current.CancellationToken); + + // validation + Assert.Equal(3, first.Length); + Assert.Equal(3, rebound.Length); + Assert.Equal(6, additions.Count); + Assert.Equal(3, removals.Count); + Assert.Empty(empty); + Assert.True(initialHealth); + Assert.True(reboundHealth); + Assert.All(first, x => Assert.Equal(typeof(TestHealth).FullName.ToLowerInvariant(), x.HealthId.ToString())); + Assert.All(rebound, x => Assert.Contains(x.ApplicationContext, hub.ApplicationManager.Applications)); + Assert.All(first, x => Assert.DoesNotContain(x.ApplicationContext, rebound.Select(y => y.ApplicationContext))); + } + + /// + /// Avoids duplicate bindings and reuses a separate injected instance for each application. + /// + /// A task that completes after discovery, activation, and disposal are validated. + [Fact] + public async Task Discovery_IsIdempotentAndInstancesBelongToApplications() + { + // arrange + var (hub, _) = CreateHost(); + using var manager = hub.HealthManager; + var plugin = new HealthTestPluginContext("plugin", typeof(TestHealth)); + var first = new ApplicationContext { ApplicationId = "first" }; + var second = new ApplicationContext { ApplicationId = "second" }; + var added = 0; + var removed = 0; + manager.AddHealth += (_, _) => added++; + manager.RemoveHealth += (_, _) => removed++; + + // act + ((HealthManager)manager).Register(plugin, [first, second, first]); + ((HealthManager)manager).Register(plugin, [first, second]); + var firstContexts = manager.GetHealthChecks(first).ToArray(); + var secondContexts = manager.GetHealthChecks(second).ToArray(); + var healthy = await manager.CheckAsync(TestContext.Current.CancellationToken); + var repeated = await manager.CheckAsync(TestContext.Current.CancellationToken); + ((HealthManager)manager).Remove(plugin); + ((HealthManager)manager).Remove(plugin); + + // validation + Assert.True(healthy); + Assert.True(repeated); + Assert.Single(firstContexts); + Assert.Single(secondContexts); + Assert.Equal(TimeSpan.FromSeconds(5), firstContexts[0].Timeout); + Assert.Equal(2, added); + Assert.Equal(2, removed); + Assert.Equal(2, plugin.Created); + Assert.Equal(2, plugin.Disposed); + Assert.Equal(2, plugin.ActivatedContexts.Count); + Assert.Empty(manager.HealthChecks); + } + + /// + /// Limits discovery to public sealed concrete components with closed type parameters. + /// + [Fact] + public void Discovery_IgnoresUnsupportedTypes() + { + // arrange + var (hub, _) = CreateHost(); + using var manager = hub.HealthManager; + var plugin = new HealthTestPluginContext("plugin", typeof(TestHealth), typeof(UnsealedHealth), + typeof(InvalidTimeoutHealth<>), typeof(IHealth), typeof(HealthTestRegistration)); + + // act + ((HealthManager)manager).Register(plugin, [new ApplicationContext()]); + + // validation + Assert.Equal(typeof(TestHealth).FullName.ToLowerInvariant(), Assert.Single(manager.HealthChecks).HealthId.ToString()); + Assert.Equal(0, plugin.Created); + } + + /// + /// Applies declarative budgets while retaining invalid declarations as unsuccessful checks. + /// + /// Whether the component declares a nonpositive timeout. + /// A task that completes after the component budget is validated. + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Discovery_TimeoutAttribute_GovernsProbeResult(bool invalid) + { + // arrange + var (hub, server) = CreateHost(); + using var manager = hub.HealthManager; + var plugin = new HealthTestPluginContext("plugin", invalid + ? typeof(InvalidTimeoutHealth) : typeof(TimedHealth)); + + // act + ((HealthManager)manager).Register(plugin, [new ApplicationContext()]); + var healthy = await manager.CheckAsync(TestContext.Current.CancellationToken); + + // validation + Assert.False(healthy); + Assert.Equal(invalid ? TimeSpan.FromSeconds(5) : TimeSpan.FromMilliseconds(25), Assert.Single(manager.HealthChecks).Timeout); + if (invalid) + { + Assert.Contains(server.HttpServerContext.Log.GetRecentEntries(), x => x.Message.Contains("invalid configuration")); + } + } + + /// + /// Keeps activation failures visible to probes instead of silently losing a critical component. + /// + /// A task that completes after failure and later activation recovery are validated. + [Fact] + public async Task Discovery_ConstructorFails_RemainsUnhealthyAndCanRecover() + { + // arrange + var (hub, server) = CreateHost(); + using var manager = hub.HealthManager; + var plugin = new HealthTestPluginContext("plugin", typeof(TestHealth)) { FailConstruction = true }; + ((HealthManager)manager).Register(plugin, [new ApplicationContext()]); + + // act + var failed = await manager.CheckAsync(TestContext.Current.CancellationToken); + plugin.FailConstruction = false; + var recovered = await manager.CheckAsync(TestContext.Current.CancellationToken); + + // validation + Assert.False(failed); + Assert.True(recovered); + Assert.Single(manager.HealthChecks); + Assert.Equal(2, plugin.Created); + Assert.Contains(server.HttpServerContext.Log.GetRecentEntries(), x => x.Message.Contains("private-constructor-diagnostic")); + } + + /// + /// Keeps a declared application whose constructor threw visible to probes, although it left no + /// health bindings behind, until the plugin that declares it is removed. + /// + /// A task that completes after failure, diagnostics, and recovery are validated. + [Fact] + public async Task ApplicationConstructorFails_IsUnhealthyUntilPluginRemoved() + { + // arrange + var (hub, server) = CreateHost(); + using var manager = hub.HealthManager; + var applications = (ApplicationManager)hub.ApplicationManager; + var type = CreateThrowingApplication("private-application-diagnostic"); + var plugin = new HealthTestPluginContext("failing-plugin", type); + + // act + typeof(ApplicationManager).GetMethod("Register", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(applications, [plugin]); + var failed = await manager.CheckAsync(TestContext.Current.CancellationToken); + var live = manager.CheckLiveness(); + var failure = Assert.Single(applications.FailedApplications); + applications.Remove(plugin); + var recovered = await manager.CheckAsync(TestContext.Current.CancellationToken); + + // validation + Assert.False(failed); + Assert.False(live); + Assert.True(recovered); + Assert.Equal(type.FullName.ToLower(), failure.ApplicationId); + Assert.Same(plugin, failure.PluginContext); + Assert.Equal("private-application-diagnostic", Assert.IsType(failure.Exception).Message); + Assert.Empty(applications.GetApplications(plugin)); + Assert.Empty(applications.FailedApplications); + Assert.Contains(server.HttpServerContext.Log.GetRecentEntries(), x => + x.Message.Contains(failure.ApplicationId) && x.Message.Contains("private-application-diagnostic")); + } + + /// + /// Keeps the liveness probe independent of application dependencies while still judging the framework. + /// + /// A task that completes after readiness and liveness are compared. + [Fact] + public async Task CheckLiveness_SkipsApplicationChecks() + { + // arrange + var (hub, server) = CreateHost(); + using var manager = hub.HealthManager; + var calls = 0; + Register(manager, new ApplicationContext(), "database", _ => + { + Interlocked.Increment(ref calls); + return Task.FromResult(HealthCheckResult.Unhealthy()); + }); + + // act + var ready = await manager.CheckAsync(TestContext.Current.CancellationToken); + var callsAfterReadiness = calls; + var live = manager.CheckLiveness(); + SetRunning(server, false); + var stopping = manager.CheckLiveness(); + + // validation + Assert.False(ready); + Assert.True(live); + Assert.False(stopping); + Assert.Equal(callsAfterReadiness, calls); + } + + /// + /// Removes checks contributed by another plugin without removing the application or unrelated checks. + /// + /// A task that completes after the actual plugin removal event has been processed. + [Fact] + public async Task RemovePlugin_DetachesOnlyItsContribution() + { + // arrange + var (hub, _) = CreateHost(); + using var manager = hub.HealthManager; + ((WebPlugin.PluginManager)hub.PluginManager).Register(); + var application = hub.ApplicationManager.Applications.First(); + using var extension = Register(manager, application, "extension", _ => Task.FromResult(HealthCheckResult.Unhealthy())); + + // act + var failed = await manager.CheckAsync(TestContext.Current.CancellationToken); + ((WebPlugin.PluginManager)hub.PluginManager).Remove(extension.Plugin); + var healthy = await manager.CheckAsync(TestContext.Current.CancellationToken); + + // validation + Assert.False(failed); + Assert.True(healthy); + Assert.Contains(application, hub.ApplicationManager.Applications); + Assert.DoesNotContain(manager.HealthChecks, x => x.PluginContext == extension.Plugin); + Assert.Equal(1, extension.Plugin.Disposed); + } + + /// + /// Delays resource disposal until a detached component's active invocation finishes. + /// + /// A task that completes after deferred cleanup is validated. + [Fact] + public async Task RemovePlugin_InFlightCheck_DefersInstanceDisposal() + { + // arrange + var (hub, _) = CreateHost(); + using var manager = hub.HealthManager; + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = Register(manager, new ApplicationContext(), "plugin", _ => + { + entered.TrySetResult(); + return completion.Task; + }); + + // act + var probe = manager.CheckAsync(TestContext.Current.CancellationToken); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + registration.Dispose(); + var disposedWhileRunning = registration.Plugin.Disposed; + completion.SetResult(HealthCheckResult.Healthy()); + var result = await probe; + await registration.Plugin.DisposedSignal.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + // validation + Assert.Equal(0, disposedWhileRunning); + Assert.Equal(1, registration.Plugin.Disposed); + Assert.False(result); + Assert.Empty(manager.HealthChecks); + } + + /// + /// Ensures a running framework is sufficient when an application has no external dependencies. + /// + /// A task that completes after aggregate health is validated. + [Fact] + public async Task CheckAsync_RunningFrameworkWithoutDependencies_IsHealthy() + { + // arrange + var (hub, _) = CreateHost(); + using var manager = hub.HealthManager; + + // act + var healthy = await manager.CheckAsync(TestContext.Current.CancellationToken); + + // validation + Assert.True(healthy); + Assert.Contains(manager, hub.Managers); + } + + /// + /// Keeps startup and shutdown out of the healthy state even when dependencies succeed. + /// + /// A task that completes after lifecycle health is validated. + [Fact] + public async Task CheckAsync_HostNotRunning_IsUnhealthy() + { + // arrange + var (hub, server) = CreateHost(); + using var manager = hub.HealthManager; + SetRunning(server, false); + + // act + var healthy = await manager.CheckAsync(TestContext.Current.CancellationToken); + + // validation + Assert.False(healthy); + Assert.Contains(server.HttpServerContext.Log.GetRecentEntries(), x => x.Message.Contains("starting or stopping")); + } + + /// + /// Detects component initialization failures independently of listener reachability. + /// + /// A task that completes after the failed component is detected. + [Fact] + public async Task CheckAsync_MissingComponent_IsUnhealthy() + { + // arrange + var (hub, _) = CreateHost(); + using var manager = hub.HealthManager; + typeof(ComponentHub).GetField("_jobManager", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(hub, null); + + // act + var healthy = await manager.CheckAsync(TestContext.Current.CancellationToken); + + // validation + Assert.False(healthy); + } + + /// + /// Requires explicit success from every application while preserving diagnostics in the log. + /// + /// The dependency failure mode exercised by the probe. + /// A task that completes after aggregation and logging are validated. + [Theory] + [InlineData("result")] + [InlineData("throw")] + [InlineData("faulted")] + [InlineData("null")] + [InlineData("null-task")] + [InlineData("cancelled")] + public async Task CheckAsync_AnyFailure_ExecutesAllChecksAndLogsDetails(string failure) + { + // arrange + var (hub, server) = CreateHost(); + using var manager = hub.HealthManager; + var application = new ApplicationContext { ApplicationId = "private-application" }; + var successfulCalls = 0; + Register(manager, application, "healthy", _ => + { + Interlocked.Increment(ref successfulCalls); + return Task.FromResult(HealthCheckResult.Healthy()); + }); + Register(manager, application, "private-database", _ => failure switch + { + "result" => Task.FromResult(HealthCheckResult.Unhealthy("secret-diagnostic")), + "throw" => throw new InvalidOperationException("secret-diagnostic"), + "faulted" => Task.FromException(new InvalidOperationException("secret-diagnostic")), + "null" => Task.FromResult(null), + "null-task" => null, + _ => Task.FromCanceled(new CancellationToken(true)) + }); + + // act + var healthy = await manager.CheckAsync(TestContext.Current.CancellationToken); + + // validation + Assert.False(healthy); + Assert.Equal(1, successfulCalls); + Assert.Contains(server.HttpServerContext.Log.GetRecentEntries(), x => x.Message.Contains("private-application/" + typeof(TestHealth).FullName.ToLowerInvariant())); + if (failure is "result" or "throw" or "faulted") + { + Assert.Contains(server.HttpServerContext.Log.GetRecentEntries(), x => x.Message.Contains("secret-diagnostic")); + } + if (failure is "throw" or "faulted") + { + Assert.Contains(server.HttpServerContext.Log.GetRecentEntries(), x => + x.Level == LogLevel.Exception && x.Message.Contains("HealthItem.ExecuteAsync")); + } + } + + /// + /// Reevaluates recovered dependencies instead of caching an unhealthy decision indefinitely. + /// + /// A task that completes after both probe decisions are validated. + [Fact] + public async Task CheckAsync_DependencyRecovers_UsesFreshResult() + { + // arrange + var (hub, _) = CreateHost(); + using var manager = hub.HealthManager; + var available = false; + Register(manager, new ApplicationContext(), "database", _ => Task.FromResult(available + ? HealthCheckResult.Healthy() : HealthCheckResult.Unhealthy())); + + // act + var failed = await manager.CheckAsync(TestContext.Current.CancellationToken); + available = true; + var recovered = await manager.CheckAsync(TestContext.Current.CancellationToken); + + // validation + Assert.False(failed); + Assert.True(recovered); + } + + /// + /// Bounds latency and repeated work even when a dependency ignores cancellation. + /// + /// Whether the callback blocks before returning its task. + /// A task that completes after probe timeouts and invocation counts are validated. + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CheckAsync_UnresponsiveDependency_TimesOutWithoutOverlappingWork(bool synchronous) + { + // arrange + var (hub, server) = CreateHost(); + using var manager = hub.HealthManager; + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var calls = 0; + Register(manager, new ApplicationContext { ApplicationId = "application" }, "blocked", _ => + { + Interlocked.Increment(ref calls); + entered.TrySetResult(); + return synchronous ? Task.FromResult(completion.Task.GetAwaiter().GetResult()) : completion.Task; + }, TimeSpan.FromMilliseconds(100)); + + try + { + // act + var first = manager.CheckAsync(TestContext.Current.CancellationToken); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + var second = manager.CheckAsync(TestContext.Current.CancellationToken); + var results = await Task.WhenAll(first, second).WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + var repeated = await manager.CheckAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + // validation + Assert.All(results, Assert.False); + Assert.False(repeated); + Assert.Equal(1, calls); + Assert.Contains(server.HttpServerContext.Log.GetRecentEntries(), x => x.Message.Contains("time budget")); + } + finally + { + completion.TrySetResult(HealthCheckResult.Healthy()); + } + } + + /// + /// Lets dependencies release resources when their check budget expires. + /// + /// A task that completes after the dependency observes cancellation. + [Fact] + public async Task CheckAsync_Timeout_CancelsDependencyToken() + { + // arrange + var (hub, _) = CreateHost(); + using var manager = hub.HealthManager; + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Register(manager, new ApplicationContext(), "database", async token => + { + using var registration = token.Register(() => cancelled.TrySetResult()); + await Task.Delay(Timeout.InfiniteTimeSpan, token); + return HealthCheckResult.Healthy(); + }, TimeSpan.FromMilliseconds(100)); + + // act + var healthy = await manager.CheckAsync(TestContext.Current.CancellationToken); + await cancelled.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + // validation + Assert.False(healthy); + } + + /// + /// Prevents one disconnected probe from cancelling work shared with another caller. + /// + /// A task that completes after both callers have finished. + [Fact] + public async Task CheckAsync_CallerCancelled_OtherProbeStillSucceeds() + { + // arrange + var (hub, _) = CreateHost(); + using var manager = hub.HealthManager; + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var calls = 0; + Register(manager, new ApplicationContext(), "database", _ => + { + Interlocked.Increment(ref calls); + entered.TrySetResult(); + return completion.Task; + }); + + // act + var cancelled = manager.CheckAsync(cancellation.Token); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + var other = manager.CheckAsync(TestContext.Current.CancellationToken); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => cancelled); + completion.SetResult(HealthCheckResult.Healthy()); + var healthy = await other; + + // validation + Assert.True(healthy); + Assert.Equal(1, calls); + } + + /// + /// Rejects stale success when the host drains or a new dependency appears during a probe. + /// + /// Whether host shutdown or a registry change invalidates the probe. + /// A task that completes after the in-flight result is rejected. + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CheckAsync_StateChangesDuringProbe_RejectsStaleSuccess(bool stopHost) + { + // arrange + var (hub, server) = CreateHost(); + using var manager = hub.HealthManager; + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Register(manager, new ApplicationContext(), "pending", _ => completion.Task); + + // act + var probe = manager.CheckAsync(TestContext.Current.CancellationToken); + if (stopHost) + { + SetRunning(server, false); + } + else + { + Register(manager, new ApplicationContext(), "new", _ => Task.FromResult(HealthCheckResult.Unhealthy())); + } + completion.SetResult(HealthCheckResult.Healthy()); + var healthy = await probe; + + // validation + Assert.False(healthy); + } + + /// + /// Keeps independently bound applications isolated during plugin removal. + /// + /// A task that completes after registration and removal are validated. + [Fact] + public async Task Register_ApplicationScopeAndDisposal_AreIndependent() + { + // arrange + var (hub, _) = CreateHost(); + using var manager = hub.HealthManager; + var first = new ApplicationContext { ApplicationId = "first" }; + var second = new ApplicationContext { ApplicationId = "second" }; + using var healthy = Register(manager, first, "database", _ => Task.FromResult(HealthCheckResult.Healthy())); + var unhealthy = Register(manager, second, "database", _ => Task.FromResult(HealthCheckResult.Unhealthy())); + + // act + var before = await manager.CheckAsync(TestContext.Current.CancellationToken); + unhealthy.Dispose(); + unhealthy.Dispose(); + var after = await manager.CheckAsync(TestContext.Current.CancellationToken); + + // validation + Assert.False(before); + Assert.True(after); + Assert.All(manager.HealthChecks, x => Assert.Same(first, x.ApplicationContext)); + } + + /// + /// Ensures application unload automatically removes its dependency registrations. + /// + /// A task that completes after the application removal event is processed. + [Fact] + public async Task RemoveApplication_RemovesOwnedChecks() + { + // arrange + var (hub, _) = CreateHost(); + using var manager = hub.HealthManager; + ((WebPlugin.PluginManager)hub.PluginManager).Register(); + var application = hub.ApplicationManager.Applications.First(); + Register(manager, application, "database", _ => Task.FromResult(HealthCheckResult.Unhealthy())); + + // act + var before = await manager.CheckAsync(TestContext.Current.CancellationToken); + ((ApplicationManager)hub.ApplicationManager).Remove(application.PluginContext); + var after = await manager.CheckAsync(TestContext.Current.CancellationToken); + + // validation + Assert.False(before); + Assert.True(after); + } + + /// + /// Prevents a disposed health registry from becoming a successful empty probe. + /// + /// A task that completes after disposal behavior is validated. + [Fact] + public async Task Dispose_RejectsProbesAndStopsDiscovery() + { + // arrange + var (hub, _) = CreateHost(); + var manager = hub.HealthManager; + + // act + manager.Dispose(); + manager.Dispose(); + var healthy = await manager.CheckAsync(TestContext.Current.CancellationToken); + + // validation + Assert.False(healthy); + Assert.False(manager.CheckLiveness()); + using var registration = Register(manager, new ApplicationContext(), "database", + _ => Task.FromResult(HealthCheckResult.Healthy())); + Assert.Empty(manager.HealthChecks); + } + + /// + /// Isolates dependency aggregation from network binding while retaining real framework managers. + /// + /// A component hub and its host with completed startup simulated for unit tests. + private static (ComponentHub Hub, HttpServer Server) CreateHost() + { + var server = new HttpServer(UnitTestFixture.CreateHttpServerContextMock()); + var hub = UnitTestFixture.CreateComponentHubMock(server.HttpServerContext); + SetRunning(server, true); + return (hub, server); + } + + /// + /// Simulates lifecycle transitions without opening a listener in manager unit tests. + /// + /// The host whose lifecycle is being simulated. + /// Whether startup has completed without shutdown beginning. + private static void SetRunning(HttpServer server, bool running) + { + typeof(HttpServer).GetField("_isRunning", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(server, running); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityAuthentication.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityAuthentication.cs new file mode 100644 index 00000000..760b9260 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityAuthentication.cs @@ -0,0 +1,352 @@ +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using System.Security.Cryptography; +using WebExpress.WebCore.Test.Data; +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebIdentity; +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Exercises credential trust boundaries independently of any server-side identity session. + /// + [Collection("NonParallelTests")] + public class UnitTestIdentityAuthentication + { + /// + /// Proves that login cookies authenticate on another instance and contain no password verifier. + /// + [Fact] + public void LoginSurvivesInstanceChangeWithoutSession() + { + using var fixture = new AuthenticationFixture(); + var source = MockIdentityFactory.GetIdentity("Alice"); + var request = fixture.Request(); + var pair = fixture.Manager.Login(source, request); + Assert.Null(request.ExistingSession); + Assert.Null(fixture.Manager.Login(null, request)); + var other = fixture.Instance(); + var identity = other.ValidateAccessToken(pair.AccessToken, fixture.Application); + Assert.Equal(source.Id, identity.Id); + Assert.Equal(source.Roles, identity.Roles); + Assert.Null(identity.PasswordHash); + var payload = new JsonWebToken(pair.AccessToken).EncodedPayload; + Assert.DoesNotContain(source.PasswordHash, Base64UrlEncoder.Decode(payload)); + Assert.Contains(typeof(TestIdentityPermissionC).FullName, identity.Permissions); + Assert.True(fixture.Manager.CheckAccess(fixture.Application, identity, typeof(TestIdentityPermissionC))); + Assert.True(fixture.Manager.CheckAccess(identity, new TestIdentityPolicyA())); + + var response = new ResponseOK(); + fixture.Manager.ApplyAuthenticationCookies(request, response); + var access = response.Header.Cookies[IdentityManager.AccessCookieName]; + var refresh = response.Header.Cookies[IdentityManager.RefreshCookieName]; + Assert.True(access.Secure && access.HttpOnly && refresh.Secure && refresh.HttpOnly); + Assert.Equal("/", access.Path); + Assert.Equal("/api/auth/refresh", refresh.Path); + Assert.Equal("no-store", response.Header.CacheControl); + } + + /// + /// A session identifier, a refresh cookie, or a rejected explicit bearer credential cannot impersonate a user. + /// + [Fact] + public void RequestAuthenticationRejectsWrongCredentialChannels() + { + using var fixture = new AuthenticationFixture(); + var request = fixture.Request(); + var pair = fixture.Manager.Login(MockIdentityFactory.GetIdentity("Alice"), request); + var cookie = $"Cookie: {IdentityManager.AccessCookieName}={pair.AccessToken}\r\n"; + Assert.NotNull(fixture.Manager.GetCurrentIdentity(fixture.Request(cookie))); + Assert.Null(fixture.Manager.GetCurrentIdentity(fixture.Request($"Cookie: session={Guid.NewGuid()}\r\n"))); + Assert.Null(fixture.Manager.GetCurrentIdentity(fixture.Request($"Cookie: {IdentityManager.AccessCookieName}={pair.RefreshToken}\r\n"))); + Assert.Null(fixture.Manager.GetCurrentIdentity(fixture.Request(cookie + "Authorization: Bearer invalid\r\n"))); + Assert.Null(fixture.Manager.GetCurrentIdentity(fixture.Request($"Authorization: Bearer {pair.AccessToken}\r\n"))); + var anotherApplication = fixture.Request(cookie); + anotherApplication.ApplicationContext = fixture.Hub.ApplicationManager.GetApplications(typeof(TestApplicationB)).First(); + Assert.Null(fixture.Manager.GetCurrentIdentity(anotherApplication)); + } + + /// + /// A signing key the configuration gets wrong leaves every credential unverifiable. That has + /// to read as "not signed in" - a throw would fail each request that carries a cookie and + /// hand the exception to the error page an anonymous caller sees. + /// + /// The rejected signing key. + [Theory] + [InlineData("not base64!")] + [InlineData("c2hvcnQ=")] + [InlineData("")] + public void RejectedSigningKeyReadsAsUnauthenticated(string signingKey) + { + using var fixture = new AuthenticationFixture(); + var pair = fixture.Manager.Login(MockIdentityFactory.GetIdentity("Alice"), fixture.Request()); + var cookie = $"Cookie: {IdentityManager.AccessCookieName}={pair.AccessToken}\r\n"; + fixture.Settings.SigningKey = signingKey; + var misconfigured = fixture.Instance(); + + var exception = Record.Exception(() => Assert.Null(misconfigured.GetCurrentIdentity(fixture.Request(cookie)))); + + Assert.Null(exception); + Assert.False(misconfigured.IsAuthenticationConfigured(fixture.Application)); + } + + /// + /// A page checks policies once per protected fragment; the identity behind a request is + /// verified once and reused for the rest of it, while the next request verifies anew. + /// + [Fact] + public void IdentityIsVerifiedOncePerRequest() + { + using var fixture = new AuthenticationFixture(); + var pair = fixture.Manager.Login(MockIdentityFactory.GetIdentity("Alice"), fixture.Request()); + var cookie = $"Cookie: {IdentityManager.AccessCookieName}={pair.AccessToken}\r\n"; + var request = fixture.Request(cookie); + var identity = fixture.Manager.GetCurrentIdentity(request); + + // past expiry a fresh verification fails, so only the reused result still names Alice + fixture.Clock.Now = pair.AccessTokenExpiresAt.AddSeconds(1); + + Assert.NotNull(identity); + Assert.Same(identity, fixture.Manager.GetCurrentIdentity(request)); + Assert.Null(fixture.Manager.GetCurrentIdentity(fixture.Request(cookie))); + + // reusing the result changes nothing about the credentials, so no cookie is queued + var response = new ResponseOK(); + fixture.Manager.ApplyAuthenticationCookies(request, response); + Assert.Empty(response.Header.Cookies.Cast()); + } + + /// + /// Signature, audience, issuer, expiration, and token purpose remain mandatory even when claims look plausible. + /// + [Fact] + public void TokenValidationEnforcesEveryTrustBoundary() + { + using var fixture = new AuthenticationFixture(); + var identity = new Identity(Guid.NewGuid(), "alice", permissions: ["read"]); + var app = fixture.Application; + var pair = fixture.Manager.Issue(identity, app); + var parts = pair.AccessToken.Split('.'); + parts[1] = Base64UrlEncoder.Encode(Base64UrlEncoder.Decode(parts[1]).Replace("alice", "admin")); + Assert.Null(fixture.Manager.ValidateAccessToken(string.Join('.', parts), app)); + Assert.Null(fixture.Manager.ValidateAccessToken(pair.AccessToken, fixture.Hub.ApplicationManager.GetApplications(typeof(TestApplicationB)).First())); + Assert.Null(fixture.Manager.ValidateAccessToken(pair.RefreshToken, app)); + Assert.Null(fixture.Manager.Refresh(pair.AccessToken, app)); + Assert.Null(fixture.Manager.ValidateAccessToken("not.a.jwt", app)); + fixture.Settings.Issuer = "https://another-authority.test"; + var wrongIssuer = fixture.Instance(); + Assert.Null(wrongIssuer.ValidateAccessToken(pair.AccessToken, app)); + fixture.Settings.Issuer = "https://webexpress.test"; + fixture.Settings.SigningKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + var wrongKey = fixture.Instance(); + Assert.Null(wrongKey.ValidateAccessToken(pair.AccessToken, app)); + fixture.Clock.Now = pair.AccessTokenExpiresAt.AddSeconds(1); + Assert.Null(fixture.Manager.ValidateAccessToken(pair.AccessToken, app)); + Assert.NotNull(fixture.Manager.Refresh(pair.RefreshToken, app)); + } + + /// + /// The signature alone decides acceptance: identical header and claims are honored under the + /// deployment key and refused under any other key, so knowing the claim layout never suffices. + /// + [Fact] + public void TokenSignedWithForeignKeyIsRejected() + { + using var fixture = new AuthenticationFixture(); + var app = fixture.Application; + var token = fixture.Manager.Issue(new Identity(Guid.NewGuid(), "alice"), app).AccessToken; + var parts = token.Split('.'); + var key = Convert.FromBase64String(fixture.Settings.SigningKey); + + // reproducing the issued token proves the forgery differs from it in nothing but the key + var resigned = Sign(parts[0], parts[1], key, HMACSHA256.HashData); + Assert.Equal(token, resigned); + Assert.NotNull(fixture.Manager.ValidateAccessToken(resigned, app)); + + var forged = Sign(parts[0], parts[1], RandomNumberGenerator.GetBytes(32), HMACSHA256.HashData); + Assert.Null(fixture.Manager.ValidateAccessToken(forged, app)); + } + + /// + /// A damaged, truncated, or absent signature is refused instead of being treated as optional. + /// + [Fact] + public void TamperedOrMissingSignatureIsRejected() + { + using var fixture = new AuthenticationFixture(); + var app = fixture.Application; + var token = fixture.Manager.Issue(new Identity(Guid.NewGuid(), "alice"), app).AccessToken; + var parts = token.Split('.'); + Assert.NotNull(fixture.Manager.ValidateAccessToken(token, app)); + + // flipped on the decoded bytes, because altering the last base64url character may only touch + // padding bits and decode to the very same signature + var signature = Base64UrlEncoder.DecodeBytes(parts[2]); + signature[^1] ^= 0x01; + Assert.Null(fixture.Manager.ValidateAccessToken($"{parts[0]}.{parts[1]}.{Base64UrlEncoder.Encode(signature)}", app)); + Assert.Null(fixture.Manager.ValidateAccessToken($"{parts[0]}.{parts[1]}.{Base64UrlEncoder.Encode(signature[..^1])}", app)); + Assert.Null(fixture.Manager.ValidateAccessToken($"{parts[0]}.{parts[1]}.", app)); + Assert.Null(fixture.Manager.ValidateAccessToken($"{parts[0]}.{parts[1]}", app)); + } + + /// + /// The header cannot choose how the signature is checked: an unsigned token and a token correctly + /// signed with the deployment key under another algorithm are both refused. + /// + [Fact] + public void SignatureAlgorithmCannotBeChosenByToken() + { + using var fixture = new AuthenticationFixture(); + var app = fixture.Application; + var parts = fixture.Manager.Issue(new Identity(Guid.NewGuid(), "alice"), app).AccessToken.Split('.'); + var key = Convert.FromBase64String(fixture.Settings.SigningKey); + var header = Base64UrlEncoder.Decode(parts[0]); + Assert.Contains("\"HS256\"", header); + + var unsigned = Base64UrlEncoder.Encode(header.Replace("\"HS256\"", "\"none\"")); + Assert.Null(fixture.Manager.ValidateAccessToken($"{unsigned}.{parts[1]}.", app)); + + var hs512 = Base64UrlEncoder.Encode(header.Replace("\"HS256\"", "\"HS512\"")); + Assert.Null(fixture.Manager.ValidateAccessToken(Sign(hs512, parts[1], key, HMACSHA512.HashData), app)); + } + + /// + /// Builds a compact JWS by hand so a test controls header, payload, key, and algorithm independently + /// of the token handler under test. + /// + /// The base64url-encoded protected header. + /// The base64url-encoded claim set. + /// The HMAC key that produces the signature. + /// The HMAC function matching the algorithm named in the header. + /// The serialized token in compact form. + private static string Sign(string header, string payload, byte[] key, Func hmac) + { + var signature = hmac(key, System.Text.Encoding.ASCII.GetBytes(header + "." + payload)); + return header + "." + payload + "." + Base64UrlEncoder.Encode(signature); + } + + /// + /// Refresh replay revokes its successor across instances and renewal never moves the absolute grant deadline. + /// + [Fact] + public void RefreshRotatesOnceAndRetainsAbsoluteExpiry() + { + using var fixture = new AuthenticationFixture(); + var app = fixture.Application; + var pair = fixture.Manager.Issue(new Identity(Guid.NewGuid(), "alice"), app); + fixture.Clock.Now += TimeSpan.FromMinutes(10); + var next = fixture.Manager.Refresh(pair.RefreshToken, app); + Assert.NotEqual(pair.RefreshToken, next.RefreshToken); + Assert.Equal(pair.RefreshTokenExpiresAt.ToUnixTimeSeconds(), next.RefreshTokenExpiresAt.ToUnixTimeSeconds()); + var other = fixture.Instance(); + Assert.Null(other.Refresh(pair.RefreshToken, app)); + Assert.Null(fixture.Manager.Refresh(next.RefreshToken, app)); + var fresh = fixture.Manager.Issue(new Identity(Guid.NewGuid(), "bob"), app); + fixture.Clock.Now = fresh.RefreshTokenExpiresAt.AddSeconds(1); + Assert.Null(fixture.Manager.Refresh(fresh.RefreshToken, app)); + } + + /// + /// Exclusive marker creation permits only one winner when different nodes redeem the same refresh token. + /// + /// A task that completes after the asynchronous assertions have finished. + [Fact] + public async Task ConcurrentRefreshHasOnlyOneWinner() + { + using var fixture = new AuthenticationFixture(); + var app = fixture.Application; + var pair = fixture.Manager.Issue(new Identity(Guid.NewGuid(), "alice"), app); + var results = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => Task.Run(() => + fixture.Instance() + .Refresh(pair.RefreshToken, app)))); + Assert.Single(results, x => x is not null); + } + + /// + /// PAT permissions cannot grow through role or policy claims, renewal, or issuance of broader credentials. + /// + [Fact] + public void PersonalTokensAreScopedExpiringAndRevocable() + { + using var fixture = new AuthenticationFixture(); + var app = fixture.Application; + var owner = new Identity(Guid.NewGuid(), "alice", roles: ["admin"], permissions: ["read", "write"], policyNames: ["admin-policy"]); + var token = fixture.Manager.CreatePersonalAccessToken(owner, app, TimeSpan.FromHours(1), ["read"]); + var restricted = fixture.Manager.ValidatePersonalAccessToken(token, app); + Assert.Equal(["read"], restricted.Permissions); + Assert.Empty(restricted.Roles); + Assert.Empty(restricted.PolicyNames); + Assert.Null(fixture.Manager.ValidateAccessToken(token, app)); + Assert.Null(fixture.Manager.Refresh(token, app)); + Assert.Throws(() => fixture.Manager.CreatePersonalAccessToken(owner, app, TimeSpan.FromHours(1), ["delete"])); + Assert.Throws(() => fixture.Manager.CreatePersonalAccessToken(owner, app, TimeSpan.Zero, ["read"])); + Assert.True(fixture.Manager.RevokePersonalAccessToken(token, app)); + var other = fixture.Instance(); + Assert.Null(other.ValidatePersonalAccessToken(token, app)); + var expiring = fixture.Manager.CreatePersonalAccessToken(owner, app, TimeSpan.FromSeconds(1), ["read"]); + fixture.Clock.Now += TimeSpan.FromSeconds(2); + Assert.Null(fixture.Manager.ValidatePersonalAccessToken(expiring, app)); + } + + /// + /// Logout removes browser credentials and prevents renewal without promising immediate access-token revocation. + /// + [Fact] + public void LogoutRevokesRenewalAndExpiresBothCookiePaths() + { + using var fixture = new AuthenticationFixture(); + var pair = fixture.Manager.Login(MockIdentityFactory.GetIdentity("Alice"), fixture.Request()); + var request = fixture.Request($"Cookie: {IdentityManager.AccessCookieName}={pair.AccessToken}\r\n"); + fixture.Manager.Logout(request); + Assert.Null(fixture.Manager.GetCurrentIdentity(request)); + Assert.Null(fixture.Manager.Refresh(pair.RefreshToken, fixture.Application)); + var response = new ResponseOK(); + fixture.Manager.ApplyAuthenticationCookies(request, response); + Assert.All(response.Header.Cookies.Cast(), x => Assert.True(x.Expires < DateTime.UtcNow)); + Assert.Equal(IdentityManager.RefreshPath, response.Header.Cookies[IdentityManager.RefreshCookieName].Path); + Assert.Null(request.ExistingSession); + } + + /// + /// An expired access credential can revoke its grant but cannot regain authorization by reaching logout. + /// + [Fact] + public void ExpiredAccessCanOnlyRevokeRenewal() + { + using var fixture = new AuthenticationFixture(); + var app = fixture.Application; + var pair = fixture.Manager.Issue(new Identity(Guid.NewGuid(), "alice"), app); + fixture.Clock.Now = pair.AccessTokenExpiresAt.AddSeconds(1); + Assert.Null(fixture.Manager.ValidateAccessToken(pair.AccessToken, app)); + fixture.Manager.RevokeGrant(pair.AccessToken, app); + Assert.Null(fixture.Manager.Refresh(pair.RefreshToken, app)); + } + + /// + /// External policy mappings produce the same signed permission snapshot as local policy-bearing groups. + /// + [Fact] + public void MappedExternalPoliciesCaptureEffectivePermissions() + { + using var fixture = new AuthenticationFixture(); + var identity = new Identity(Guid.NewGuid(), "external", policyNames: [typeof(TestIdentityPolicyA).FullName]); + var pair = fixture.Manager.Login(identity, fixture.Request()); + var verified = fixture.Manager.ValidateAccessToken(pair.AccessToken, fixture.Application); + Assert.Contains(typeof(TestIdentityPermissionC).FullName, verified.Permissions); + } + + /// + /// Provider discovery and removal follow the plugin lifecycle instead of leaving stale authentication sources. + /// + [Fact] + public void ProviderLifecycleFollowsPluginRemoval() + { + using var fixture = new AuthenticationFixture(); + var providers = fixture.Hub.IdentityProviderManager.GetProviders(fixture.Application).ToArray(); + Assert.Contains(providers, x => x is MockIdentityProvider); + ((WebPlugin.PluginManager)fixture.Hub.PluginManager).Remove(fixture.Application.PluginContext); + Assert.Empty(fixture.Hub.IdentityProviderManager.GetProviders(fixture.Application)); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityManager.cs index b798ab1d..263204f6 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityManager.cs @@ -60,6 +60,10 @@ public void IsIComponentManager() /// /// Test the CheckAccess function of the identity manager. /// + /// The application whose permission bindings are evaluated. + /// The identity selected for the authorization scenario. + /// The required permission type. + /// The expected authorization result. [Theory] [InlineData(typeof(TestApplicationA), "Alice", typeof(TestIdentityPermissionA), true)] [InlineData(typeof(TestApplicationA), "Alice", typeof(TestIdentityPermissionB), true)] @@ -88,6 +92,10 @@ public void CheckAccessIdentity(Type application, string identityName, Type perm /// /// Test the CheckAccess function of the identity manager. /// + /// The application whose permission bindings are evaluated. + /// The group selected for the authorization scenario. + /// The required permission type. + /// The expected authorization result. [Theory] [InlineData(typeof(TestApplicationA), "Admins", typeof(TestIdentityPermissionA), true)] [InlineData(typeof(TestApplicationA), "Admins", typeof(TestIdentityPermissionB), true)] @@ -116,6 +124,10 @@ public void CheckAccessGroup(Type application, string groupName, Type permission /// /// Test the CheckAccess function of the identity manager. /// + /// The application whose permission bindings are evaluated. + /// The policy type whose permission binding is evaluated. + /// The required permission type. + /// The expected authorization result. [Theory] [InlineData(typeof(TestApplicationA), typeof(TestIdentityPolicyA), typeof(TestIdentityPermissionA), true)] [InlineData(typeof(TestApplicationA), typeof(TestIdentityPolicyA), typeof(TestIdentityPermissionB), true)] @@ -137,75 +149,6 @@ public void CheckAccess(Type application, Type policy, Type permission, bool exp Assert.Equal(expected, access); } - /// - /// Test the Login function of the identity manager. - /// - [Theory] - [InlineData(null, false)] - [InlineData("Alice", true)] - [InlineData("Bob", true)] - public void Login(string identityName, bool expected) - { - // arrange - var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - var identityManager = componentHub.IdentityManager as IdentityManager; - var request = UnitTestFixture.CreateRequestMock(); - var identity = MockIdentityFactory.GetIdentity(identityName); - - // act - var res = identityManager.Login(identity, request); - - // validation - Assert.Equal(expected, res is not null); - } - - /// - /// Test the Login function of the identity manager. - /// - [Theory] - [InlineData("Alice")] - [InlineData("Bob")] - [InlineData("Charlie")] - public void Logout(string identityName) - { - // arrange - var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - var identityManager = componentHub.IdentityManager as IdentityManager; - var request = UnitTestFixture.CreateRequestMock(); - var identity = MockIdentityFactory.GetIdentity(identityName); - identityManager.Login(identity, request); - - // act - identityManager.Logout(request); - - // validation - var res = identityManager.GetCurrentIdentity(request); - Assert.Null(res); - } - - /// - /// Test the GetCurrentIdentity function of the identity manager. - /// - [Theory] - [InlineData("Alice")] - [InlineData("Bob")] - [InlineData("Charlie")] - public void GetCurrentIdentity(string identityName) - { - // arrange - var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - var identityManager = componentHub.IdentityManager as IdentityManager; - var request = UnitTestFixture.CreateRequestMock(); - var identity = MockIdentityFactory.GetIdentity(identityName); - identityManager.Login(identity, request); - - // act - var res = identityManager.GetCurrentIdentity(request); - - // validation - Assert.Equal(identity, res); - } - /// /// Test that the IIdentityGroup interface has the Id and Name properties. /// @@ -239,7 +182,7 @@ public void RegisterIdentityProvider() provider.Identities.Add(identity); // act - identityManager.RegisterIdentityProvider(provider, applicationContext); + componentHub.IdentityProviderManager.Register(provider, applicationContext); var identities = identityManager.GetIdentities(applicationContext).ToList(); // validation @@ -266,14 +209,14 @@ public void UnregisterIdentityProvider() provider.Identities.Add(identity); - identityManager.RegisterIdentityProvider(provider, applicationContext); + componentHub.IdentityProviderManager.Register(provider, applicationContext); var identitiesBefore = identityManager.GetIdentities(applicationContext).ToList(); Assert.Contains(identity, identitiesBefore); Assert.Single(identitiesBefore); // act - var removed = identityManager.UnregisterIdentityProvider(provider, applicationContext); + var removed = componentHub.IdentityProviderManager.Unregister(provider, applicationContext); var identitiesAfter = identityManager.GetIdentities(applicationContext).ToList(); // validation diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityTokenStoreManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityTokenStoreManager.cs new file mode 100644 index 00000000..035fb97a --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityTokenStoreManager.cs @@ -0,0 +1,165 @@ +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebIdentity; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Verifies that replay and revocation markers always reach exactly one store per application, + /// and that the credential logic in the identity manager follows the store bound at call time. + /// + [Collection("NonParallelTests")] + public class UnitTestIdentityTokenStoreManager + { + /// + /// Without a plugin store, every application shares the file store configured by the deployment. + /// + [Fact] + public void DefaultStoreIsTheSharedFileStore() + { + using var fixture = new AuthenticationFixture(); + var other = fixture.Hub.ApplicationManager.GetApplications(typeof(TestApplicationB)).First(); + var store = fixture.Hub.IdentityTokenStoreManager.GetStore(fixture.Application); + Assert.IsType(store); + Assert.Same(store, fixture.Hub.IdentityTokenStoreManager.GetStore(other)); + Assert.Equal(new[] { store }, fixture.Hub.IdentityTokenStoreManager.Stores); + Assert.Null(fixture.Hub.IdentityTokenStoreManager.GetStore(null)); + } + + /// + /// A registered store receives the application's refresh markers, replaces an earlier binding instead of + /// joining it, and leaves other applications on the default store until it is unregistered. + /// + [Fact] + public void RegisteredStoreReplacesTheDefaultForItsApplicationOnly() + { + using var fixture = new AuthenticationFixture(); + var manager = fixture.Hub.IdentityTokenStoreManager; + var other = fixture.Hub.ApplicationManager.GetApplications(typeof(TestApplicationB)).First(); + var first = new MemoryTokenStore(); + var second = new MemoryTokenStore(); + manager.Register(first, fixture.Application); + manager.Register(second, fixture.Application); + Assert.Same(second, manager.GetStore(fixture.Application)); + Assert.DoesNotContain(first, manager.Stores); + Assert.False(first.Disposed, "an explicitly registered store stays owned by its caller"); + Assert.IsType(manager.GetStore(other)); + + var pair = fixture.Manager.Issue(new Identity(Guid.NewGuid(), "alice"), fixture.Application); + Assert.NotNull(fixture.Manager.Refresh(pair.RefreshToken, fixture.Application)); + Assert.Contains(second.Markers, x => x.StartsWith("refresh:")); + Assert.Null(fixture.Manager.Refresh(pair.RefreshToken, fixture.Application)); + Assert.Contains(second.Markers, x => x.StartsWith("grant:")); + Assert.Empty(first.Markers); + + Assert.False(manager.Unregister(first, fixture.Application)); + Assert.True(manager.Unregister(second, fixture.Application)); + Assert.IsType(manager.GetStore(fixture.Application)); + } + + /// + /// Without durable storage, stateless access still works, but every operation that depends on a replay + /// or revocation marker fails instead of accepting a credential that might have been revoked. + /// + [Fact] + public void MissingStoreFailsMarkerDependentOperations() + { + using var fixture = new AuthenticationFixture(withTokenStorePath: false); + var app = fixture.Application; + Assert.Null(fixture.Hub.IdentityTokenStoreManager.GetStore(app)); + Assert.False(fixture.Manager.IsAuthenticationConfigured(app)); + var owner = new Identity(Guid.NewGuid(), "alice", permissions: ["read"]); + var pair = fixture.Manager.Issue(owner, app); + Assert.NotNull(fixture.Manager.ValidateAccessToken(pair.AccessToken, app)); + Assert.Null(fixture.Manager.Refresh(pair.RefreshToken, app)); + var token = fixture.Manager.CreatePersonalAccessToken(owner, app, TimeSpan.FromHours(1), ["read"]); + Assert.Null(fixture.Manager.ValidatePersonalAccessToken(token, app)); + Assert.Throws(() => fixture.Manager.RevokePersonalAccessToken(token, app)); + + fixture.Hub.IdentityTokenStoreManager.Register(new MemoryTokenStore(), app); + Assert.True(fixture.Manager.IsAuthenticationConfigured(app)); + Assert.NotNull(fixture.Manager.ValidatePersonalAccessToken(token, app)); + } + + /// + /// A section that exists but would issue weak or inconsistent credentials answers the public question + /// like a missing one, instead of throwing at a health check, and names the reason only in the log. + /// + /// The setting that makes the section unusable. + /// The value it is overridden with. + [Theory] + [InlineData("SigningKey", "AAAAAAAAAAAAAAAAAAAAAA==")] + [InlineData("SigningKey", "not base64!")] + [InlineData("Issuer", " ")] + [InlineData("AccessTokenLifetime", "00:00:00")] + [InlineData("AccessTokenLifetime", "soon")] + public void InvalidSectionIsNotConfigured(string setting, string value) + { + using var fixture = new AuthenticationFixture(); + var key = fixture.Settings.SigningKey; + fixture.Server.Configuration["WebExpress:Authentication:" + setting] = value; + IIdentityManager manager = fixture.Manager; + + Assert.False(manager.IsAuthenticationConfigured(fixture.Application)); + Assert.Contains(fixture.Server.Log.GetRecentEntries(), x => x.Message.Contains("WebExpress:Authentication")); + Assert.DoesNotContain(fixture.Server.Log.GetRecentEntries(), x => x.Message.Contains(key)); + } + + /// + /// Removing the owning plugin unbinds and disposes the store, so no request keeps writing into it. + /// + [Fact] + public void PluginRemovalReleasesTheBoundStore() + { + using var fixture = new AuthenticationFixture(); + var store = new MemoryTokenStore(); + fixture.Hub.IdentityTokenStoreManager.Register(store, fixture.Application); + ((WebPlugin.PluginManager)fixture.Hub.PluginManager).Remove(fixture.Application.PluginContext); + Assert.True(store.Disposed); + Assert.DoesNotContain(store, fixture.Hub.IdentityTokenStoreManager.Stores); + Assert.NotSame(store, fixture.Hub.IdentityTokenStoreManager.GetStore(fixture.Application)); + } + + /// + /// Stands in for a plugin-supplied store; kept private so plugin discovery does not bind it to the test applications. + /// + private sealed class MemoryTokenStore : IIdentityTokenStore, IDisposable + { + private readonly HashSet _markers = []; + internal IReadOnlyCollection Markers { get { lock (_markers) { return _markers.ToArray(); } } } + internal bool Disposed { get; private set; } + + /// + /// Mirrors the atomic first-writer semantics required of every store. + /// + /// The unique credential or grant identifier whose marker is accessed. + /// The deadline until which the marker must be retained. + /// True when this call created the marker; otherwise, false. + public bool TryConsume(string tokenId, DateTimeOffset expiresAt) + { + lock (_markers) { return _markers.Add(tokenId); } + } + + /// + /// Records a denial marker. + /// + /// The unique credential or grant identifier whose marker is accessed. + /// The deadline until which the marker must be retained. + public void Revoke(string tokenId, DateTimeOffset expiresAt) => TryConsume(tokenId, expiresAt); + + /// + /// Reports whether a denial marker exists. + /// + /// The unique credential or grant identifier whose marker is accessed. + /// True when a marker exists; otherwise, false. + public bool IsRevoked(string tokenId) + { + lock (_markers) { return _markers.Contains(tokenId); } + } + + /// + /// Records that the manager released the store. + /// + public void Dispose() => Disposed = true; + } + } +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestJobManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestJobManager.cs index b1de50c4..5f8070b0 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestJobManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestJobManager.cs @@ -1,6 +1,8 @@ -using WebExpress.WebCore.Test.Fixture; +using System.Reflection; +using WebExpress.WebCore.Test.Fixture; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebJob; +using WebExpress.WebCore.WebJob.Model; namespace WebExpress.WebCore.Test.Manager { @@ -40,6 +42,70 @@ public void Remove() Assert.Empty(componentHub.JobManager.Jobs); } + /// + /// Removing the jobs of a plugin releases them at once rather than when the manager shuts + /// down, so an unloaded plugin leaves no job instance behind. + /// + [Fact] + public void RemoveDisposesJobsImmediately() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var plugin = componentHub.PluginManager?.GetPlugin(typeof(TestPlugin)); + var jobManager = componentHub.JobManager as JobManager; + var scheduleItems = GetScheduleItems(jobManager); + + // act + jobManager.Remove(plugin); + + // validation + Assert.NotEmpty(scheduleItems); + Assert.All(scheduleItems, x => + { + Assert.True(x.IsDisposed); + Assert.True(((TestJobA)x.Instance).IsDisposed); + Assert.True(x.TokenSource.IsCancellationRequested); + }); + } + + /// + /// Removing an application releases the jobs bound to it and leaves those of the other + /// applications running. + /// + [Fact] + public void RemoveApplicationDisposesOnlyItsJobs() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplicationA)).First(); + var jobManager = componentHub.JobManager as JobManager; + var scheduleItems = GetScheduleItems(jobManager); + + // act + jobManager.Remove(application); + + // validation + Assert.All(scheduleItems, x => Assert.Equal(x.ApplicationContext == application, x.IsDisposed)); + Assert.Equal(scheduleItems.Count - 1, componentHub.JobManager.Jobs.Count()); + } + + /// + /// Returns the schedule entries of all static jobs currently registered. + /// + /// The job manager. + /// The schedule entries. + private static List GetScheduleItems(JobManager jobManager) + { + var dictionary = typeof(JobManager) + .GetField("_staticScheduleDictionary", BindingFlags.NonPublic | BindingFlags.Instance) + .GetValue(jobManager) as ScheduleDictionary; + + return [.. dictionary.Values + .SelectMany(x => x.Values) + .SelectMany(x => x.Values) + .SelectMany(x => x)]; + } + /// /// Tests whether the job manager implements interface IComponentManager. /// diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestMetricInstruments.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestMetricInstruments.cs new file mode 100644 index 00000000..01bac8c0 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestMetricInstruments.cs @@ -0,0 +1,180 @@ +using WebExpress.WebCore.WebMetrics; +using WebExpress.WebCore.WebMetrics.Model; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Verifies that instruments keep their semantics under concurrency and only ever produce + /// exposition text Prometheus accepts. + /// + public class UnitTestMetricInstruments + { + /// + /// Keeps separate series per label values and loses no increment under concurrent updates. + /// + [Fact] + public void Counter_ConcurrentIncrements_AreNotLost() + { + // arrange + var counter = new MetricCounter("logins_total", "Logins.", "result"); + + // act + Parallel.For(0, 10000, i => counter.Increment(i % 2 == 0 ? "success" : "failure")); + counter.Add(0.5, "success"); + + // validation + Assert.Equal(5000.5, counter.GetValue("success")); + Assert.Equal(5000, counter.GetValue("failure")); + Assert.Equal(0, counter.GetValue("throttled")); + } + + /// + /// Refuses updates that would break the counter contract or address a series ambiguously. + /// + [Fact] + public void Counter_InvalidUpdates_AreRejected() + { + // arrange + var counter = new MetricCounter("events_total", "Events.", "kind"); + + // validation + Assert.Throws(() => counter.Add(-1, "a")); + Assert.Throws(() => counter.Add(double.NaN, "a")); + Assert.Throws(() => counter.Add(double.PositiveInfinity, "a")); + Assert.Throws(() => counter.Increment()); + Assert.Throws(() => counter.Increment("a", "b")); + Assert.Throws(() => counter.Increment([null])); + } + + /// + /// Rejects declarations whose names or labels cannot be carried by the text format. + /// + /// The metric name. + /// The comma separated label names. + [Theory] + [InlineData("", "")] + [InlineData("1metric", "")] + [InlineData("metric-name", "")] + [InlineData("metric", "__reserved")] + [InlineData("metric", "bad-label")] + [InlineData("metric", "a,a")] + public void Declaration_InvalidNames_AreRejected(string name, string labels) + { + // arrange + string[] labelNames = labels.Length == 0 ? [] : labels.Split(','); + + // validation + Assert.Throws(() => new MetricGauge(name, "", labelNames)); + } + + /// + /// Supports both absolute and relative updates of a gauge. + /// + [Fact] + public void Gauge_SetAndAdjust() + { + // arrange + var gauge = new MetricGauge("queue_length", "Queued jobs."); + + // act + gauge.Set(10); + gauge.Increment(); + gauge.Decrement(); + gauge.Decrement(); + gauge.Add(-2.5); + + // validation + Assert.Equal(6.5, gauge.GetValue()); + } + + /// + /// Places observations in inclusive buckets and reports them cumulatively, with the count + /// always equal to the +Inf bucket. + /// + [Fact] + public void Histogram_ReportsCumulativeBuckets() + { + // arrange + var histogram = new MetricHistogram("query_duration_seconds", "Query time.", [0.125, 1], "db"); + + // act + histogram.Observe(0.0625, "main"); + histogram.Observe(0.125, "main"); + histogram.Observe(0.5, "main"); + histogram.Observe(3, "main"); + var text = Format(histogram); + + // validation + Assert.Contains("query_duration_seconds_bucket{db=\"main\",le=\"0.125\"} 2\n", text); + Assert.Contains("query_duration_seconds_bucket{db=\"main\",le=\"1\"} 3\n", text); + Assert.Contains("query_duration_seconds_bucket{db=\"main\",le=\"+Inf\"} 4\n", text); + Assert.Contains("query_duration_seconds_sum{db=\"main\"} 3.6875\n", text); + Assert.Contains("query_duration_seconds_count{db=\"main\"} 4\n", text); + Assert.Contains("# TYPE query_duration_seconds histogram\n", text); + } + + /// + /// Rejects bucket layouts and labels that would produce an invalid histogram. + /// + [Fact] + public void Histogram_InvalidDeclaration_IsRejected() + { + // validation + Assert.Throws(() => new MetricHistogram("h", "", [1, 1])); + Assert.Throws(() => new MetricHistogram("h", "", [2, 1])); + Assert.Throws(() => new MetricHistogram("h", "", [])); + Assert.Throws(() => new MetricHistogram("h", "", [double.PositiveInfinity])); + Assert.Throws(() => new MetricHistogram("h", "", null, "le")); + Assert.Throws(() => new MetricHistogram("h", "", null).Observe(double.NaN)); + } + + /// + /// Escapes label values and descriptions and writes numbers independent of the server culture. + /// + [Fact] + public void Format_EscapesTextAndUsesInvariantNumbers() + { + // arrange + var previous = System.Globalization.CultureInfo.CurrentCulture; + System.Globalization.CultureInfo.CurrentCulture = new System.Globalization.CultureInfo("de-DE"); + var gauge = new MetricGauge("temperature_celsius", "Line one\nback\\slash \"quoted\"", "sensor"); + gauge.Set(21.5, "a \"b\"\\c\nd"); + gauge.Set(double.NaN, "nan"); + gauge.Set(double.NegativeInfinity, "low"); + + try + { + // act + var text = Format(gauge); + + // validation + Assert.Contains("# HELP temperature_celsius Line one\\nback\\\\slash \"quoted\"\n", text); + Assert.Contains("temperature_celsius{sensor=\"a \\\"b\\\"\\\\c\\nd\"} 21.5\n", text); + Assert.Contains("temperature_celsius{sensor=\"nan\"} NaN\n", text); + Assert.Contains("temperature_celsius{sensor=\"low\"} -Inf\n", text); + } + finally + { + System.Globalization.CultureInfo.CurrentCulture = previous; + } + } + + /// + /// Renders a single instrument as a scrape would. + /// + /// The instrument to render. + /// The exposition text. + private static string Format(MetricInstrument instrument) + { + var collector = new MetricCollector(); + collector.Add(instrument); + var set = new MetricSet(); + foreach (var entry in collector.Entries) + { + set.TryAdd(entry.Name, entry.Help, entry.Type, entry.Sample, out _); + } + + return MetricText.Format(set.ToFamilies()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestMetricsManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestMetricsManager.cs new file mode 100644 index 00000000..db7abb5b --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestMetricsManager.cs @@ -0,0 +1,399 @@ +using System.Reflection; +using WebExpress.WebCore.Test.Data; +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebMetrics; +using static WebExpress.WebCore.Test.Fixture.MetricTestFixture; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Verifies that framework series, shared instruments and discovered components merge into one valid scrape. + /// + [Collection("NonParallelTests")] + public class UnitTestMetricsManager + { + /// + /// Discovers components through real plugin events and labels each binding with its application. + /// + /// A task that completes after discovery, removal and rebinding have been validated. + [Fact] + public async Task PluginAndApplicationEvents_DiscoverComponentsAutomatically() + { + // arrange + var hub = UnitTestFixture.CreateComponentHubMock(); + using var manager = hub.MetricsManager; + var additions = new List(); + var removals = new List(); + manager.AddMetric += (_, context) => additions.Add(context); + manager.RemoveMetric += (_, context) => removals.Add(context); + + // act + ((WebPlugin.PluginManager)hub.PluginManager).Register(); + var plugin = hub.PluginManager.GetPlugin(typeof(TestPlugin)); + var first = manager.Metrics.ToArray(); + var scrape = await ScrapeAsync(manager); + ((ApplicationManager)hub.ApplicationManager).Remove(plugin); + var empty = manager.Metrics.ToArray(); + typeof(ApplicationManager).GetMethod("Register", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(hub.ApplicationManager, [plugin]); + var rebound = manager.Metrics.ToArray(); + + // validation + Assert.Equal(3, first.Length); + Assert.Equal(3, rebound.Length); + Assert.Equal(6, additions.Count); + Assert.Equal(3, removals.Count); + Assert.Empty(empty); + Assert.All(first, x => Assert.Equal(typeof(TestMetric).FullName.ToLowerInvariant(), x.MetricId.ToString())); + foreach (var context in first) + { + Assert.Contains($"test_metric_value{{application=\"{context.ApplicationContext.ApplicationId}\"}} 1", scrape); + Assert.Contains($"webexpress_metric_collector_up{{application=\"{context.ApplicationContext.ApplicationId}\"," + + $"collector=\"{context.MetricId}\"}} 1", scrape); + } + Assert.Contains($"webexpress_applications {hub.ApplicationManager.Applications.Count()}", scrape); + } + + /// + /// Avoids duplicate bindings and keeps a separate injected instance per application. + /// + /// A task that completes after discovery, activation and disposal have been validated. + [Fact] + public async Task Discovery_IsIdempotentAndInstancesBelongToApplications() + { + // arrange + var hub = UnitTestFixture.CreateComponentHubMock(); + using var manager = hub.MetricsManager; + var plugin = new MetricTestPluginContext("plugin", typeof(TestMetric), typeof(UnsealedMetric), + typeof(InvalidTimeoutMetric<>), typeof(IMetric)); + var first = new ApplicationContext { ApplicationId = "first" }; + var second = new ApplicationContext { ApplicationId = "second" }; + + // act + ((MetricsManager)manager).Register(plugin, [first, second, first]); + ((MetricsManager)manager).Register(plugin, [first, second]); + var bindings = manager.Metrics.ToArray(); + var firstBindings = manager.GetMetrics(first).ToArray(); + await ScrapeAsync(manager); + var scrape = await ScrapeAsync(manager); + ((MetricsManager)manager).Remove(plugin); + + // validation + Assert.Equal(2, bindings.Length); + Assert.Same(first, Assert.Single(firstBindings).ApplicationContext); + Assert.Equal(TimeSpan.FromSeconds(2), bindings[0].Timeout); + Assert.Equal(2, plugin.Created); + Assert.Equal(2, plugin.Disposed); + Assert.Empty(manager.Metrics); + Assert.DoesNotContain("unsealed_metric", scrape); + } + + /// + /// Keeps a failing, slow or misconfigured component from costing the other sources of a scrape, + /// and reports it through the collector status series instead. + /// + /// The failure mode of the component. + /// A task that completes after the scrape has been validated. + [Theory] + [InlineData("throw")] + [InlineData("faulted")] + [InlineData("null-task")] + [InlineData("timeout")] + [InlineData("invalid-name")] + [InlineData("reserved-label")] + [InlineData("duplicate-series")] + [InlineData("constructor")] + public async Task CollectAsync_ComponentFails_OnlyItsSeriesAreLost(string failure) + { + // arrange + var hub = UnitTestFixture.CreateComponentHubMock(); + using var manager = hub.MetricsManager; + var application = new ApplicationContext { ApplicationId = "app" }; + Register(manager, application, "healthy", (collector, _) => + { + collector.Gauge("healthy_value", "A value of a working component.", 7); + return Task.CompletedTask; + }); + using var failing = Register(manager, new ApplicationContext { ApplicationId = "broken" }, "failing", async (collector, token) => + { + collector.Gauge("partial_value", "A value reported before the failure.", 1); + switch (failure) + { + case "throw": throw new InvalidOperationException("secret-diagnostic"); + case "faulted": await Task.FromException(new InvalidOperationException("secret-diagnostic")); break; + case "timeout": await Task.Delay(Timeout.InfiniteTimeSpan, token); break; + case "invalid-name": collector.Gauge("invalid-name", "", 1); break; + case "reserved-label": collector.Gauge("labelled", "", 1, new MetricLabel("application", "spoofed")); break; + case "duplicate-series": collector.Gauge("partial_value", "", 2); break; + } + }, TimeSpan.FromMilliseconds(100)); + failing.Plugin.FailConstruction = failure == "constructor"; + if (failure == "null-task") + { + failing.Plugin.Collect = (_, _) => null; + } + + // act + var scrape = await ScrapeAsync(manager); + + // validation + Assert.Contains("healthy_value{application=\"app\"} 7", scrape); + Assert.DoesNotContain("partial_value", scrape); + Assert.DoesNotContain("spoofed", scrape); + Assert.DoesNotContain("secret-diagnostic", scrape); + Assert.Matches("webexpress_metric_collector_up\\{application=\"app\",collector=\"[^\"]+\"\\} 1", scrape); + Assert.Matches("webexpress_metric_collector_up\\{application=\"broken\",collector=\"[^\"]+\"\\} 0", scrape); + } + + /// + /// Applies declarative budgets while treating invalid declarations as a failed collector. + /// + /// Whether the component declares a non-positive timeout. + /// A task that completes after the component budget has been validated. + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Discovery_TimeoutAttribute_GovernsCollection(bool invalid) + { + // arrange + var hub = UnitTestFixture.CreateComponentHubMock(); + using var manager = hub.MetricsManager; + var plugin = new MetricTestPluginContext("plugin", invalid ? typeof(InvalidTimeoutMetric) : typeof(TimedMetric)); + + // act + ((MetricsManager)manager).Register(plugin, [new ApplicationContext { ApplicationId = "app" }]); + var scrape = await ScrapeAsync(manager); + + // validation + Assert.Equal(invalid ? TimeSpan.FromSeconds(2) : TimeSpan.FromMilliseconds(25), Assert.Single(manager.Metrics).Timeout); + Assert.Matches("webexpress_metric_collector_up\\{application=\"app\",collector=\"[^\"]+\"\\} 0", scrape); + } + + /// + /// Shares one collection between concurrent scrapes, so replicas of the scraper do not multiply the work. + /// + /// A task that completes after both scrapes have finished. + [Fact] + public async Task CollectAsync_ConcurrentScrapes_ShareOneCollection() + { + // arrange + var hub = UnitTestFixture.CreateComponentHubMock(); + using var manager = hub.MetricsManager; + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var calls = 0; + Register(manager, new ApplicationContext { ApplicationId = "app" }, "shared", async (collector, _) => + { + Interlocked.Increment(ref calls); + entered.TrySetResult(); + await release.Task; + collector.Gauge("shared_value", "", 3); + }); + + // act + var first = ScrapeAsync(manager); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + var second = ScrapeAsync(manager); + release.SetResult(); + var scrapes = await Task.WhenAll(first, second); + + // validation + Assert.Equal(1, calls); + Assert.All(scrapes, x => Assert.Contains("shared_value{application=\"app\"} 3", x)); + } + + /// + /// Delays resource disposal until a detached component's active collection finishes. + /// + /// A task that completes after deferred cleanup has been validated. + [Fact] + public async Task RemovePlugin_InFlightCollection_DefersInstanceDisposal() + { + // arrange + var hub = UnitTestFixture.CreateComponentHubMock(); + using var manager = hub.MetricsManager; + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = Register(manager, new ApplicationContext(), "plugin", async (_, _) => + { + entered.TrySetResult(); + await release.Task; + }); + + // act + var scrape = manager.CollectAsync(TestContext.Current.CancellationToken); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + registration.Dispose(); + var disposedWhileRunning = registration.Plugin.Disposed; + release.SetResult(); + await scrape; + await registration.Plugin.DisposedSignal.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + // validation + Assert.Equal(0, disposedWhileRunning); + Assert.Equal(1, registration.Plugin.Disposed); + Assert.Empty(manager.Metrics); + } + + /// + /// Shares instruments by name and refuses a second declaration that would make the series ambiguous. + /// + [Fact] + public void CreateInstrument_SharesByNameAndRejectsIncompatibleDeclarations() + { + // arrange + var hub = UnitTestFixture.CreateComponentHubMock(); + using var manager = hub.MetricsManager; + + // act + var counter = manager.CreateCounter("ldap_requests_total", "LDAP requests.", "result"); + var same = manager.CreateCounter("ldap_requests_total", "Other help is ignored.", "result"); + var histogram = manager.CreateHistogram("ldap_request_duration_seconds", "LDAP latency.", [0.1, 1]); + var framework = manager.CreateCounter("webexpress_identity_logins_total", "", "result"); + + // validation + Assert.Same(counter, same); + Assert.Same(histogram, manager.CreateHistogram("ldap_request_duration_seconds", "", [0.1, 1])); + Assert.Same(((MetricsManager)manager).Framework.Logins, framework); + Assert.Throws(() => manager.CreateGauge("ldap_requests_total", "")); + Assert.Throws(() => manager.CreateCounter("ldap_requests_total", "", "server")); + Assert.Throws(() => manager.CreateHistogram("ldap_request_duration_seconds", "", [0.5])); + Assert.Throws(() => manager.CreateCounter("ldap requests", "")); + Assert.Contains(counter, manager.Instruments); + } + + /// + /// Exports shared instruments and drops a component series that contradicts one, instead of + /// letting the conflict invalidate the whole scrape. + /// + /// A task that completes after the scrape has been validated. + [Fact] + public async Task CollectAsync_TypeConflictBetweenSources_KeepsFirstAndLogs() + { + // arrange + var hub = UnitTestFixture.CreateComponentHubMock(); + using var manager = hub.MetricsManager; + manager.CreateCounter("orders_total", "Orders placed.", "channel").Increment("web"); + Register(manager, new ApplicationContext { ApplicationId = "shop" }, "shop", (collector, _) => + { + collector.Gauge("orders_total", "Contradicting type.", 5); + collector.Gauge("cart_items", "Items in carts.", 2); + return Task.CompletedTask; + }); + + // act + var scrape = await ScrapeAsync(manager); + + // validation + Assert.Contains("# TYPE orders_total counter", scrape); + Assert.Contains("orders_total{channel=\"web\"} 1", scrape); + Assert.DoesNotContain("orders_total{application=\"shop\"}", scrape); + Assert.Contains("cart_items{application=\"shop\"} 2", scrape); + Assert.Single(scrape.Split('\n'), x => x == "# TYPE orders_total counter"); + } + + /// + /// Reports the process, runtime and framework baseline without any application component. + /// + /// A task that completes after the baseline has been validated. + [Fact] + public async Task CollectAsync_WithoutComponents_ReportsFrameworkBaseline() + { + // arrange + var hub = UnitTestFixture.CreateComponentHubMock(); + using var manager = hub.MetricsManager; + + // act + var scrape = await ScrapeAsync(manager); + + // validation + foreach (var family in new[] + { + "webexpress_info gauge", "webexpress_http_request_duration_seconds histogram", + "webexpress_http_requests_in_flight gauge", "webexpress_identity_logouts_total counter", + "webexpress_identity_active_users gauge", "webexpress_sessions gauge", "process_cpu_seconds_total counter", + "process_resident_memory_bytes gauge", "process_start_time_seconds gauge", "dotnet_total_memory_bytes gauge", + "dotnet_collection_count_total counter", "dotnet_threadpool_threads gauge" + }) + { + Assert.Contains("# TYPE " + family + "\n", scrape); + } + + Assert.Contains("webexpress_http_request_duration_seconds_bucket{le=\"+Inf\"} 0", scrape); + Assert.Contains("webexpress_identity_logouts_total 0", scrape); + Assert.DoesNotContain("webexpress_metric_collector_up", scrape); + } + + /// + /// Reports how far the clock of each cluster instance that sent a message runs ahead, so + /// an alert can catch clocks drifting apart before deadlines and messages suffer. + /// + /// A task that completes after the scrape has been validated. + [Fact] + public async Task CollectAsync_ClusterPeer_ReportsClockSkew() + { + // arrange + var hub = UnitTestFixture.CreateComponentHubMock(); + using var manager = hub.MetricsManager; + ((WebCluster.ClusterManager)hub.ClusterManager).MeasureClock(new WebCluster.ClusterMessage("t", "pod-2", [], DateTimeOffset.UtcNow.AddSeconds(30))); + + // act + var scrape = await ScrapeAsync(manager); + + // validation + Assert.Contains("# TYPE webexpress_cluster_clock_skew_seconds gauge\n", scrape); + Assert.Contains("webexpress_cluster_clock_skew_seconds{peer=\"pod-2\"} 29.", scrape); + } + + /// + /// Counts identities seen within the window once each and forgets those outside it. + /// + /// A task that completes after the active user gauge has been validated. + [Fact] + public async Task ActiveUsers_CountsDistinctIdentitiesWithinWindow() + { + // arrange + var hub = UnitTestFixture.CreateComponentHubMock(); + using var manager = hub.MetricsManager; + var framework = ((MetricsManager)manager).Framework; + var alice = Guid.NewGuid(); + var stale = Guid.NewGuid(); + + // act + framework.RecordActiveUser(alice); + framework.RecordActiveUser(alice); + framework.RecordActiveUser(Guid.NewGuid()); + var activeUsers = (System.Collections.Concurrent.ConcurrentDictionary)typeof(WebMetrics.Model.FrameworkMetrics) + .GetField("_activeUsers", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(framework); + activeUsers[stale] = Environment.TickCount64 - (long)TimeSpan.FromHours(1).TotalMilliseconds; + var scrape = await ScrapeAsync(manager); + + // validation + Assert.Contains("webexpress_identity_active_users 2", scrape); + Assert.False(activeUsers.ContainsKey(stale)); + } + + /// + /// Rejects scrapes of a disposed registry instead of reporting an empty, seemingly healthy process. + /// + /// A task that completes after disposal behavior has been validated. + [Fact] + public async Task Dispose_RejectsScrapesAndStopsDiscovery() + { + // arrange + var hub = UnitTestFixture.CreateComponentHubMock(); + var manager = hub.MetricsManager; + + // act + manager.Dispose(); + manager.Dispose(); + + // validation + await Assert.ThrowsAsync(() => manager.CollectAsync(TestContext.Current.CancellationToken)); + using var registration = Register(manager, new ApplicationContext(), "late", (_, _) => Task.CompletedTask); + Assert.Empty(manager.Metrics); + Assert.Contains(manager, hub.Managers); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestOpenIdConnectIdentityProvider.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestOpenIdConnectIdentityProvider.cs new file mode 100644 index 00000000..a6d1524a --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestOpenIdConnectIdentityProvider.cs @@ -0,0 +1,212 @@ +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebIdentity; +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Exercises real discovery, JWKS, code exchange, and JWT validation over a deterministic backchannel. + /// + [Collection("NonParallelTests")] + public class UnitTestOpenIdConnectIdentityProvider + { + /// + /// A plugin can map its verified external roles into the common identity without changing protocol validation. + /// + /// A task that completes after the asynchronous assertions have finished. + [Fact] + public async Task CodeFlowUsesPkceAndProducesCommonIdentity() + { + using var fixture = new AuthenticationFixture(); + using var backchannel = new Backchannel(); + using var client = new HttpClient(backchannel); + using var provider = new PluginIdentityProvider(Settings(fixture), client); + var redirect = await provider.CreateChallengeAsync(fixture.Manager, fixture.Application); + var parameters = QueryHelpers.ParseQuery(new Uri(redirect.Header.Location).Query); + Assert.Equal("code", parameters["response_type"]); + Assert.Equal("S256", parameters["code_challenge_method"]); + Assert.False(parameters.ContainsKey("code_verifier")); + var cookie = redirect.Header.Cookies[OpenIdConnectIdentityProvider.ChallengeCookieName]; + Assert.True(cookie.Secure && cookie.HttpOnly); + var challenge = new JsonWebToken(cookie.Value); + backchannel.Nonce = parameters["nonce"]; + var callback = fixture.Request($"Cookie: {cookie.Name}={cookie.Value}\r\n", "GET", + $"/api/auth/callback?state={parameters["state"]}&code=single-use-code"); + var identity = await provider.AuthenticateCallbackAsync(callback, fixture.Manager); + Assert.NotNull(identity); + Assert.Equal("alice", identity.Name); + Assert.Equal(["read", "write"], identity.Permissions.Order()); + Assert.DoesNotContain("unmapped", identity.Roles); + Assert.Equal(challenge.GetPayloadValue("verifier"), backchannel.Verifier); + Assert.Equal(parameters["code_challenge"], Base64UrlEncoder.Encode(SHA256.HashData(Encoding.ASCII.GetBytes(backchannel.Verifier)))); + var pair = fixture.Manager.Login(identity, callback); + var authenticatedRequest = fixture.Request($"Cookie: {IdentityManager.AccessCookieName}={pair.AccessToken}\r\n"); + Assert.Equal(identity.Id, fixture.Manager.GetCurrentIdentity(authenticatedRequest)?.Id); + Assert.Null(await provider.AuthenticateCallbackAsync(callback, fixture.Manager)); + Assert.Equal(1, backchannel.Exchanges); + } + + /// + /// Invalid state is rejected before code exchange; untrusted signatures and claims never reach local issuance. + /// + /// The trust boundary deliberately violated by the simulated external response. + /// A task that completes after the asynchronous assertions have finished. + [Theory] + [InlineData("state")] + [InlineData("nonce")] + [InlineData("issuer")] + [InlineData("audience")] + [InlineData("signature")] + [InlineData("expired")] + [InlineData("azp")] + [InlineData("unsigned")] + public async Task InvalidExternalAuthenticationIsRejected(string failure) + { + using var fixture = new AuthenticationFixture(); + using var backchannel = new Backchannel { Failure = failure }; + using var client = new HttpClient(backchannel); + using var provider = new PluginIdentityProvider(Settings(fixture), client); + var redirect = await provider.CreateChallengeAsync(fixture.Manager, fixture.Application); + var parameters = QueryHelpers.ParseQuery(new Uri(redirect.Header.Location).Query); + backchannel.Nonce = parameters["nonce"]; + var cookie = redirect.Header.Cookies[OpenIdConnectIdentityProvider.ChallengeCookieName]; + var state = failure == "state" ? "attacker-state" : parameters["state"].ToString(); + var callback = fixture.Request($"Cookie: {cookie.Name}={cookie.Value}\r\n", "GET", $"/api/auth/callback?state={state}&code=code"); + Assert.Null(await provider.AuthenticateCallbackAsync(callback, fixture.Manager)); + Assert.Equal(failure == "state" ? 0 : 1, backchannel.Exchanges); + } + + /// + /// A callback cannot supply a bare ID token or exchange a code without the initiating browser cookie. + /// + /// A task that completes after the asynchronous assertions have finished. + [Fact] + public async Task MissingBrowserCorrelationNeverCallsTokenEndpoint() + { + using var fixture = new AuthenticationFixture(); + using var backchannel = new Backchannel(); + using var client = new HttpClient(backchannel); + using var provider = new PluginIdentityProvider(Settings(fixture), client); + Assert.Null(await provider.AuthenticateCallbackAsync(fixture.Request(path: "/api/auth/callback?code=stolen&state=stolen"), fixture.Manager)); + Assert.Null(await provider.AuthenticateCallbackAsync(fixture.Request(path: "/api/auth/callback?id_token=jwt"), fixture.Manager)); + Assert.Equal(0, backchannel.Exchanges); + } + + /// + /// Binds the simulated external provider to the application under test. + /// + /// The isolated authentication context used by the test. + /// The authority configuration bound to the application under test. + private static OpenIdConnectSettings Settings(AuthenticationFixture fixture) => new() + { + ProviderId = "external", Authority = "https://identity.test/realm", ClientId = "webexpress", + RedirectUri = $"https://webexpress.test/api/auth/callback?application={fixture.Application.ApplicationId}&provider=external", + RolePermissions = new() { ["reader"] = ["read"], ["editor"] = ["write"] } + }; + + /// + /// Demonstrates provider extension in a plugin without a vendor implementation in WebCore. + /// + private sealed class PluginIdentityProvider : OpenIdConnectIdentityProvider + { + /// + /// Reuses the generic code flow with plugin-specific configuration. + /// + /// The authority and role mappings trusted by this plugin. + /// The controlled transport for the protocol test. + internal PluginIdentityProvider(OpenIdConnectSettings settings, HttpClient client) : base(settings, client) { } + + /// + /// Translates the plugin's role claim after the common pipeline has validated the token. + /// + /// The verified external ID token supplied by the base provider. + /// The external role labels used by the configured local authorization mappings. + protected override IEnumerable ReadRoles(JsonWebToken token) + { + return token.TryGetPayloadValue("plugin_roles", out var roles) ? roles : []; + } + } + /// + /// Simulates an external authority while retaining real cryptographic token validation. + /// + private sealed class Backchannel : HttpMessageHandler + { + private readonly RSA _rsa = RSA.Create(2048); + internal string Nonce; + internal string Failure; + internal string Verifier; + internal int Exchanges; + + /// + /// Supplies protocol documents and a freshly signed ID token while preserving real cryptographic validation. + /// + /// The HTTP request whose authentication context is being evaluated. + /// The cancellation token governing the simulated backchannel operation. + /// The simulated discovery, signing-key, or token response. + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + object payload; + if (request.RequestUri.AbsolutePath.EndsWith("openid-configuration")) + { + payload = new + { + issuer = "https://identity.test/realm", authorization_endpoint = "https://identity.test/authorize", + token_endpoint = "https://identity.test/token", jwks_uri = "https://identity.test/keys" + }; + } + else if (request.RequestUri.AbsolutePath == "/keys") + { + var parameters = _rsa.ExportParameters(false); + payload = new { keys = new[] { new { kty = "RSA", kid = "test-key", use = "sig", alg = "RS256", + n = Base64UrlEncoder.Encode(parameters.Modulus), e = Base64UrlEncoder.Encode(parameters.Exponent) } } }; + } + else + { + Assert.Equal("/token", request.RequestUri.AbsolutePath); + Assert.Equal(HttpMethod.Post, request.Method); + var form = QueryHelpers.ParseQuery(await request.Content.ReadAsStringAsync(cancellationToken)); + Assert.Equal("authorization_code", form["grant_type"]); + Verifier = form["code_verifier"]; + Exchanges++; + using var wrongKey = RSA.Create(2048); + var key = new RsaSecurityKey(Failure == "signature" ? wrongKey : _rsa) { KeyId = "test-key" }; + var descriptor = new SecurityTokenDescriptor + { + Issuer = Failure == "issuer" ? "https://attacker.test" : "https://identity.test/realm", + Audience = Failure == "audience" ? "other-client" : "webexpress", + IssuedAt = DateTime.UtcNow.AddMinutes(-5), NotBefore = DateTime.UtcNow.AddMinutes(-5), + Expires = Failure == "expired" ? DateTime.UtcNow.AddMinutes(-1) : DateTime.UtcNow.AddMinutes(5), + SigningCredentials = Failure == "unsigned" ? null : new SigningCredentials(key, SecurityAlgorithms.RsaSha256), + Claims = new Dictionary + { + ["sub"] = "external-user", ["preferred_username"] = "alice", + ["nonce"] = Failure == "nonce" ? "attacker-nonce" : Nonce, + ["azp"] = Failure == "azp" ? "attacker-client" : "webexpress", + ["plugin_roles"] = new[] { "reader", "editor" }, + ["roles"] = new[] { "unmapped" } + } + }; + payload = new { id_token = new JsonWebTokenHandler().CreateToken(descriptor) }; + } + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json") }; + } + + /// + /// Releases test signing material after each independent authorization flow. + /// + /// A value indicating whether managed test resources should be released. + protected override void Dispose(bool disposing) + { + if (disposing) { _rsa.Dispose(); } + base.Dispose(disposing); + } + } + } +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs index c39f1e4f..77096287 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs @@ -1,10 +1,13 @@ using System.IO.Compression; using System.Reflection; using System.Security.Cryptography; +using Microsoft.Extensions.Configuration; using WebExpress.WebCore.Test.Fixture; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebPackage; using WebExpress.WebCore.WebPackage.Model; +using WebExpress.WebCore.WebPlugin; +using WebExpress.WebCore.WebSetting; namespace WebExpress.WebCore.Test.Manager { @@ -69,7 +72,7 @@ public void AddPackageEvent() packageManager.AddPackage += (sender, item) => { eventFired = true; }; // create dummy package - var package = new PackageCatalogItem() { Id = "test", File = "test.wxp", State = PackageCatalogeItemState.Active }; + var package = new PackageCatalogItem() { Id = "test", File = "test.wxp", State = PackageCatalogItemState.Active }; // act var method = typeof(PackageManager).GetMethod("OnAddPackage", BindingFlags.NonPublic | BindingFlags.Instance); @@ -92,7 +95,7 @@ public void RemovePackageEvent() packageManager.RemovePackage += (sender, item) => { eventFired = true; }; // create dummy package - var package = new PackageCatalogItem() { Id = "test", File = "test.wxp", State = PackageCatalogeItemState.Active }; + var package = new PackageCatalogItem() { Id = "test", File = "test.wxp", State = PackageCatalogItemState.Active }; // act var method = typeof(PackageManager).GetMethod("OnRemovePackage", BindingFlags.NonPublic | BindingFlags.Instance); @@ -308,17 +311,17 @@ public void PackageLifecycleInstallDeactivateActivateUninstall() // act + validation (install active) var install = packageManager.InstallPackage(packageFile, true); Assert.True(install.Success); - Assert.Equal(PackageCatalogeItemState.Active, packageManager.GetPackage("lifecycle")?.State); + Assert.Equal(PackageCatalogItemState.Active, packageManager.GetPackage("lifecycle")?.State); // act + validation (deactivate) var deactivate = packageManager.DeactivatePackage("lifecycle"); Assert.True(deactivate.Success); - Assert.Equal(PackageCatalogeItemState.Disable, packageManager.GetPackage("lifecycle")?.State); + Assert.Equal(PackageCatalogItemState.Disable, packageManager.GetPackage("lifecycle")?.State); // act + validation (activate) var activate = packageManager.ActivatePackage("lifecycle"); Assert.True(activate.Success); - Assert.Equal(PackageCatalogeItemState.Active, packageManager.GetPackage("lifecycle")?.State); + Assert.Equal(PackageCatalogItemState.Active, packageManager.GetPackage("lifecycle")?.State); // act + validation (uninstall) var uninstall = packageManager.UninstallPackage("lifecycle"); @@ -334,6 +337,92 @@ public void PackageLifecycleInstallDeactivateActivateUninstall() } } + /// + /// Tests that two instances sharing the package directory follow each other's changes: + /// what one installs, deactivates, activates or uninstalls, the other adopts on its next + /// scan - while each extracts into a directory of its own. + /// + [Fact] + public void ClusterInstancesFollowSharedCatalog() + { + // arrange + var packagePath = Path.Combine(Environment.CurrentDirectory, Guid.NewGuid().ToString()); + var statePath = Path.Combine(Path.GetTempPath(), "wx-cluster-" + Guid.NewGuid().ToString("N")); + var a = CreateClusterInstance(packagePath, statePath); + var b = CreateClusterInstance(packagePath, statePath); + var packageFile = Path.Combine(packagePath, "shared.1.0.0.wxp"); + + try + { + Directory.CreateDirectory(packagePath); + CreatePackageArchive(packageFile, "shared", "1.0.0"); + + // act + validation (install on a, b follows) + Assert.True(a.InstallPackage(packageFile, true).Success); + b.Scan(); + Assert.Equal(PackageCatalogItemState.Active, b.GetPackage("shared")?.State); + Assert.False(Directory.Exists(Path.Combine(packagePath, "shared.1.0.0")), "extraction must not touch the shared directory"); + + // act + validation (deactivate on a, b follows) + Assert.True(a.DeactivatePackage("shared").Success); + b.Scan(); + Assert.Equal(PackageCatalogItemState.Disable, b.GetPackage("shared")?.State); + + // act + validation (activate on b, a follows) + Assert.True(b.ActivatePackage("shared").Success); + a.Scan(); + Assert.Equal(PackageCatalogItemState.Active, a.GetPackage("shared")?.State); + + // act + validation (uninstall on a, b follows) + Assert.True(a.UninstallPackage("shared").Success); + b.Scan(); + Assert.Null(b.GetPackage("shared")); + Assert.Empty(Directory.GetFiles(packagePath, "*.tmp")); + } + finally + { + foreach (var directory in new[] { packagePath, statePath }) + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, true); + } + } + } + } + + /// + /// Creates the package manager of one cluster instance over a shared package directory. + /// + /// The shared package directory. + /// The shared cluster state directory. + /// The package manager. + private static PackageManager CreateClusterInstance(string packagePath, string statePath) + { + var context = new HttpServerContext + ( + new WebExpress.WebCore.WebEndpoint.RouteEndpoint("server"), + [], + packagePath, + Environment.CurrentDirectory, + Environment.CurrentDirectory, + Path.Combine(Environment.CurrentDirectory, Guid.NewGuid().ToString()), + new ConfigurationBuilder().Build(), + System.Globalization.CultureInfo.GetCultureInfo("en"), + new WebExpress.WebCore.WebLog.Log() { LogMode = WebExpress.WebCore.WebLog.LogMode.Off }, + null + ); + var hub = UnitTestFixture.CreateComponentHubMock(context); + + ((WebExpress.WebCore.WebCluster.ClusterManager)hub.ClusterManager).Configure(new ClusterSettings + { + NodeId = "node-" + Guid.NewGuid().ToString("N"), + StatePath = statePath + }); + + return hub.PackageManager as PackageManager; + } + /// /// Tests that update fails when uploaded package id does not match the requested package id. /// @@ -404,13 +493,434 @@ public void ValidatePackageWithSha256() } } + /// + /// Tests that the plugins loaded from the application directory are reported by the read + /// side even though they are not packages. + /// + /// + /// This is the defect the built-in entries exist for: in a plain build deployment every + /// plugin is referenced statically, the catalog is empty, and a management surface reading + /// the catalog alone stays blank while the server logs the plugins as running. + /// + [Fact] + public void GetPackagesReportsStaticallyLoadedPluginsAsBuiltIn() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var packageManager = componentHub.PackageManager as PackageManager; + var pluginIds = componentHub.PluginManager.Plugins.Select(x => x.PluginId.ToString()).ToList(); + + // act + var packages = packageManager.GetPackages().ToList(); + + // validation + Assert.NotEmpty(pluginIds); + Assert.Empty(packageManager.Catalog.Packages); + + foreach (var pluginId in pluginIds) + { + var package = Assert.Single(packages, x => x.Id == pluginId); + + Assert.True(package.BuiltIn); + Assert.Equal(string.Empty, package.File); + Assert.Equal(PackageCatalogItemState.Active, package.State); + Assert.Contains(package.Plugins, x => x.PluginId.ToString() == pluginId); + Assert.Equal(pluginId, package.Metadata?.Id); + } + } + + /// + /// Tests that a plugin present both statically and as an installed package is reported + /// once, by the package - which is the entry the lifecycle operations can act on. + /// + [Fact] + public void GetPackagesReportsAPluginOnceWhenItIsAlsoInstalled() + { + // arrange + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + (componentHub.PluginManager as PluginManager).Register(); + + var packageManager = componentHub.PackageManager as PackageManager; + var pluginId = componentHub.PluginManager.Plugins.First().PluginId.ToString(); + var packagePath = httpServerContext.PackagePath; + var packageFile = Path.Combine(packagePath, $"{pluginId}.1.0.0.wxp"); + + try + { + Directory.CreateDirectory(packagePath); + CreatePackageArchive(packageFile, pluginId, "1.0.0"); + + // act + var install = packageManager.InstallPackage(packageFile, true); + var packages = packageManager.GetPackages().Where(x => x.Id == pluginId).ToList(); + + // validation + Assert.True(install.Success); + Assert.False(Assert.Single(packages).BuiltIn); + } + finally + { + if (Directory.Exists(packagePath)) + { + Directory.Delete(packagePath, true); + } + } + } + + /// + /// Tests that the built-in entries never reach the persisted catalog. + /// + /// + /// Persisting them would be worse than the original defect: on the next start LoadCatalog + /// would read them back as installed packages, every path built from their empty file name + /// would fail to resolve, and Scan would count them as no longer present and drop them. + /// + [Fact] + public void SaveCatalogLeavesBuiltInEntriesOutOfTheCatalogFile() + { + // arrange + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + (componentHub.PluginManager as PluginManager).Register(); + + var packageManager = componentHub.PackageManager as PackageManager; + var packagePath = httpServerContext.PackagePath; + var catalogFile = Path.Combine(packagePath, "catalog.xml"); + var save = typeof(PackageManager).GetMethod("SaveCatalog", BindingFlags.NonPublic | BindingFlags.Instance); + var load = typeof(PackageManager).GetMethod("LoadCatalog", BindingFlags.NonPublic | BindingFlags.Instance); + + try + { + Directory.CreateDirectory(packagePath); + Assert.NotEmpty(packageManager.GetPackages()); + + // act - two start cycles worth of save/load must not adopt the built-ins + save.Invoke(packageManager, null); + load.Invoke(packageManager, null); + save.Invoke(packageManager, null); + + // validation + Assert.Empty(packageManager.Catalog.Packages); + Assert.DoesNotContain(" + /// Tests that the directory scan neither adopts nor removes the built-in entries, which + /// only exist in the read path. + ///
        + [Fact] + public void ScanKeepsBuiltInEntriesOutOfTheCatalog() + { + // arrange + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + (componentHub.PluginManager as PluginManager).Register(); + + var packageManager = componentHub.PackageManager as PackageManager; + var packagePath = httpServerContext.PackagePath; + + try + { + Directory.CreateDirectory(packagePath); + + // act + packageManager.Scan(); + packageManager.Scan(); + + // validation + Assert.Empty(packageManager.Catalog.Packages); + Assert.All(packageManager.GetPackages(), x => Assert.True(x.BuiltIn)); + } + finally + { + if (Directory.Exists(packagePath)) + { + Directory.Delete(packagePath, true); + } + } + } + + /// + /// Tests that every lifecycle operation refuses a built-in plugin with a defined failure + /// rather than running half of its steps. + /// + [Fact] + public void BuiltInPackageRefusesEveryLifecycleOperation() + { + // arrange + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + (componentHub.PluginManager as PluginManager).Register(); + + var packageManager = componentHub.PackageManager as PackageManager; + var pluginId = componentHub.PluginManager.Plugins.First().PluginId.ToString(); + + // act + var results = new[] + { + packageManager.ActivatePackage(pluginId), + packageManager.DeactivatePackage(pluginId), + packageManager.UpdatePackage(pluginId, Path.Combine(httpServerContext.PackagePath, "missing.wxp")), + packageManager.UninstallPackage(pluginId) + }; + + // validation + Assert.All(results, x => + { + Assert.False(x.Success); + Assert.Contains("ships with the application", x.Message, StringComparison.OrdinalIgnoreCase); + }); + + // the refusal has to leave the plugin exactly as it was + Assert.Contains(componentHub.PluginManager.Plugins, x => x.PluginId.ToString() == pluginId); + Assert.Equal(PackageCatalogItemState.Active, packageManager.GetPackage(pluginId)?.State); + } + + /// + /// A package ships its settings file under settings/; installing it deploys the file to + /// the settings directory of the server and reloads the configuration, so the plugin's + /// section is there before the plugin boots. + /// + [Fact] + public void InstallDeploysSettingsFileAndReloadsConfiguration() + { + // arrange + var settingsPath = Path.Combine(Environment.CurrentDirectory, Guid.NewGuid().ToString()); + var configuration = new ConfigurationBuilder().AddSettingsDirectory(settingsPath, reloadOnChange: false).Build(); + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(settingsPath, configuration); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + var packageManager = componentHub.PackageManager as PackageManager; + var packagePath = httpServerContext.PackagePath; + var packageFile = Path.Combine(packagePath, "withsettings.1.0.0.wxp"); + var section = configuration.GetPluginSettings("withsettings"); + + try + { + Directory.CreateDirectory(packagePath); + CreatePackageArchive(packageFile, "withsettings", "1.0.0", """{ "Plugins": { "withsettings": { "Greeting": "hello" } } }"""); + + // act + var install = packageManager.InstallPackage(packageFile, true); + + // validation + Assert.True(install.Success); + Assert.True(File.Exists(Path.Combine(settingsPath, "withsettings.settings.json"))); + Assert.Equal("hello", section["Greeting"]); + } + finally + { + Directory.Delete(packagePath, true); + Directory.Delete(settingsPath, true); + } + } + + /// + /// A settings file the administrator already has is theirs: a package update must not + /// overwrite it with the shipped default. + /// + [Fact] + public void InstallKeepsExistingSettingsFile() + { + // arrange + var settingsPath = Path.Combine(Environment.CurrentDirectory, Guid.NewGuid().ToString()); + Directory.CreateDirectory(settingsPath); + File.WriteAllText(Path.Combine(settingsPath, "withsettings.settings.json"), """{ "Plugins": { "withsettings": { "Greeting": "edited" } } }"""); + var configuration = new ConfigurationBuilder().AddSettingsDirectory(settingsPath, reloadOnChange: false).Build(); + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(settingsPath, configuration); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + var packageManager = componentHub.PackageManager as PackageManager; + var packagePath = httpServerContext.PackagePath; + var packageFile = Path.Combine(packagePath, "withsettings.1.0.0.wxp"); + + try + { + Directory.CreateDirectory(packagePath); + CreatePackageArchive(packageFile, "withsettings", "1.0.0", """{ "Plugins": { "withsettings": { "Greeting": "shipped" } } }"""); + + // act + var install = packageManager.InstallPackage(packageFile, true); + + // validation + Assert.True(install.Success); + Assert.Equal("edited", configuration.GetPluginSettings("withsettings")["Greeting"]); + } + finally + { + Directory.Delete(packagePath, true); + Directory.Delete(settingsPath, true); + } + } + + /// + /// A settings file that does not parse is refused: once in the directory it would fail + /// every following start of the server, so the installation goes on without it. + /// + [Fact] + public void InstallRefusesInvalidSettingsFile() + { + // arrange + var settingsPath = Path.Combine(Environment.CurrentDirectory, Guid.NewGuid().ToString()); + var configuration = new ConfigurationBuilder().AddSettingsDirectory(settingsPath, reloadOnChange: false).Build(); + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(settingsPath, configuration); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + var packageManager = componentHub.PackageManager as PackageManager; + var packagePath = httpServerContext.PackagePath; + var packageFile = Path.Combine(packagePath, "broken.1.0.0.wxp"); + + try + { + Directory.CreateDirectory(packagePath); + CreatePackageArchive(packageFile, "broken", "1.0.0", "{ this is not json"); + + // act + var install = packageManager.InstallPackage(packageFile, true); + + // validation + Assert.True(install.Success); + Assert.False(Directory.Exists(settingsPath)); + } + finally + { + Directory.Delete(packagePath, true); + } + } + + /// + /// A package may only configure its own plugin: a settings file that sets any key outside + /// of Plugins:<plugin-id> - a server section, another plugin, or a sibling id that + /// merely starts with the same name - is refused as a whole and never deployed. + /// + [Theory] + [InlineData("""{ "Http": { "Port": "8080" } }""")] + [InlineData("""{ "Plugins": { "other": { "Greeting": "hello" } } }""")] + [InlineData("""{ "Plugins": { "withsettingsx": { "Greeting": "hello" } } }""")] + [InlineData("""{ "Plugins:other:Greeting": "hello" }""")] + [InlineData("""{ "Plugins": "hello" }""")] + [InlineData("""{ "Plugins": { "withsettings": { "Greeting": "hello" } }, "Security": { "Enabled": "false" } }""")] + public void InstallRefusesSettingsOutsideOwnPluginSection(string settings) + { + // arrange + var settingsPath = Path.Combine(Environment.CurrentDirectory, Guid.NewGuid().ToString()); + var configuration = new ConfigurationBuilder().AddSettingsDirectory(settingsPath, reloadOnChange: false).Build(); + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(settingsPath, configuration); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + var packageManager = componentHub.PackageManager as PackageManager; + var packagePath = httpServerContext.PackagePath; + var packageFile = Path.Combine(packagePath, "withsettings.1.0.0.wxp"); + + try + { + Directory.CreateDirectory(packagePath); + CreatePackageArchive(packageFile, "withsettings", "1.0.0", settings); + + // act + var install = packageManager.InstallPackage(packageFile, true); + + // validation + Assert.True(install.Success); + Assert.False(Directory.Exists(settingsPath)); + } + finally + { + Directory.Delete(packagePath, true); + } + } + + /// + /// Configuration keys are case-insensitive, so a settings file that addresses its own + /// plugin section in different casing - or through a colon-separated property name - is + /// still within scope and deployed. + /// + [Theory] + [InlineData("""{ "plugins": { "WithSettings": { "Greeting": "hello" } } }""")] + [InlineData("""{ "Plugins:withsettings": { "Greeting": "hello" } }""")] + public void InstallDeploysSettingsOfOwnPluginSection(string settings) + { + // arrange + var settingsPath = Path.Combine(Environment.CurrentDirectory, Guid.NewGuid().ToString()); + var configuration = new ConfigurationBuilder().AddSettingsDirectory(settingsPath, reloadOnChange: false).Build(); + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(settingsPath, configuration); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + var packageManager = componentHub.PackageManager as PackageManager; + var packagePath = httpServerContext.PackagePath; + var packageFile = Path.Combine(packagePath, "withsettings.1.0.0.wxp"); + + try + { + Directory.CreateDirectory(packagePath); + CreatePackageArchive(packageFile, "withsettings", "1.0.0", settings); + + // act + var install = packageManager.InstallPackage(packageFile, true); + + // validation + Assert.True(install.Success); + Assert.Equal("hello", configuration.GetPluginSettings("withsettings")["Greeting"]); + } + finally + { + Directory.Delete(packagePath, true); + + if (Directory.Exists(settingsPath)) + { + Directory.Delete(settingsPath, true); + } + } + } + + /// + /// Only a json file directly below settings/ is a settings file; an entry that tries to + /// leave the directory is ignored and never written anywhere. + /// + [Fact] + public void InstallIgnoresNestedSettingsEntry() + { + // arrange + var settingsPath = Path.Combine(Environment.CurrentDirectory, Guid.NewGuid().ToString()); + var configuration = new ConfigurationBuilder().AddSettingsDirectory(settingsPath, reloadOnChange: false).Build(); + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(settingsPath, configuration); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + var packageManager = componentHub.PackageManager as PackageManager; + var packagePath = httpServerContext.PackagePath; + var packageFile = Path.Combine(packagePath, "nested.1.0.0.wxp"); + + try + { + Directory.CreateDirectory(packagePath); + CreatePackageArchive(packageFile, "nested", "1.0.0", null, "settings/sub/escape.json"); + + // act + var install = packageManager.InstallPackage(packageFile, true); + + // validation + Assert.True(install.Success); + Assert.False(Directory.Exists(settingsPath)); + } + finally + { + Directory.Delete(packagePath, true); + } + } + /// /// Creates a simple package archive for tests. /// /// The package file path. /// The package id. /// The package version. - private static void CreatePackageArchive(string file, string id, string version) + /// The content of a settings file shipped as settings/{id}.settings.json, or null for none. + /// The entry name of the settings file, if it is to differ from the default. + private static void CreatePackageArchive(string file, string id, string version, string settings = null, string settingsEntry = null) { using var zip = ZipFile.Open(file, ZipArchiveMode.Create); var specEntry = zip.CreateEntry($"{id}.spec"); @@ -427,6 +937,13 @@ private static void CreatePackageArchive(string file, string id, string version) // add minimal lib folder marker to resemble package layout zip.CreateEntry("lib/"); + + if (settings is not null || settingsEntry is not null) + { + var entry = zip.CreateEntry(settingsEntry ?? $"settings/{id}.settings.json"); + using var writer = new StreamWriter(entry.Open()); + writer.Write(settings ?? "{}"); + } } /// diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestPluginManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestPluginManager.cs index 819a278b..857ac792 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestPluginManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestPluginManager.cs @@ -1,3 +1,4 @@ +using System.Reflection; using WebExpress.WebCore.Test.Fixture; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebPlugin; @@ -93,6 +94,57 @@ public void RemoveEvent() Assert.True(triggered); } + /// + /// Removing a plugin releases it completely: the listeners of the removal event still + /// find it intact, afterwards the plugin, its cancellation token source and the + /// applications bound to it are disposed - all before the load context is unloaded. + /// + [Fact] + public void RemoveDisposesPluginAndApplications() + { + // arrange + var componentHub = UnitTestFixture.CreateComponentHubMock(); + var pluginManager = componentHub.PluginManager as PluginManager; + pluginManager.Register(); + var plugin = componentHub.PluginManager?.GetPlugin(typeof(TestPlugin)); + var dictionary = typeof(PluginManager) + .GetField("_dictionary", BindingFlags.NonPublic | BindingFlags.Instance) + .GetValue(pluginManager) as PluginDictionary; + var pluginItem = dictionary[plugin.PluginId]; + var pluginInstance = (TestPlugin)pluginItem.Plugin; + var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplicationA)).Single(); + bool? disposedDuringEvent = null; + + componentHub.PluginManager.RemovePlugin += (s, e) => disposedDuringEvent = pluginInstance.IsDisposed; + + // act + pluginManager.Remove(plugin); + + // validation + Assert.False(disposedDuringEvent); + Assert.True(pluginInstance.IsDisposed); + Assert.True(pluginItem.CancellationTokenSource.IsCancellationRequested); + Assert.Throws(() => pluginItem.CancellationTokenSource.Token); + Assert.Empty(componentHub.ApplicationManager.Applications); + Assert.Null(componentHub.ApplicationManager.GetApplication(application.ApplicationId)); + } + + /// + /// A plugin's load context must be collectible, otherwise unloading it on removal fails. + /// + [Fact] + public void PluginLoadContextIsCollectible() + { + // arrange + var loadContext = new PluginLoadContext(typeof(TestPlugin).Assembly.Location); + + // act + loadContext.Unload(); + + // validation + Assert.True(loadContext.IsCollectible); + } + /// /// Test the get plugin function of the plugin manager. /// diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestResourceManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestResourceManager.cs index 18ebb837..efb31d7d 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestResourceManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestResourceManager.cs @@ -62,7 +62,7 @@ public void Id(Type applicationType, Type resourceType, string id) // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); - var resource = componentHub.ResourceManager.GetResorces(resourceType, application)?.FirstOrDefault(); + var resource = componentHub.ResourceManager.GetResources(resourceType, application)?.FirstOrDefault(); // act Assert.Equal(id, resource?.EndpointId.ToString()); @@ -90,7 +90,7 @@ public void RoutePath(Type applicationType, Type resourceType, string path) // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); - var resource = componentHub.ResourceManager.GetResorces(resourceType, application)?.FirstOrDefault(); + var resource = componentHub.ResourceManager.GetResources(resourceType, application)?.FirstOrDefault(); // act Assert.Equal(path, resource.Route.ToString()); diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestSessionManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestSessionManager.cs index afd01d4d..877c477e 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestSessionManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestSessionManager.cs @@ -1,6 +1,7 @@ using WebExpress.WebCore.Test.Fixture; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebParameter; +using WebExpress.WebCore.WebSession; using WebExpress.WebCore.WebSession.Model; namespace WebExpress.WebCore.Test.Manager @@ -94,5 +95,305 @@ public void RemovePropertyFromSession() Assert.Null(testProperty); } + + /// + /// A well-formed id the server never issued must not become a session id: a client + /// that could dictate the id would plant it in a victim's browser and read the + /// victim's session once they signed in. + /// + [Fact] + public void GetSession_UnknownCookieId_IsNotAdopted() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var planted = Guid.NewGuid(); + var request = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={planted}\n\n"); + + // act + var session = componentHub.SessionManager.GetSession(request); + + // validation + Assert.NotNull(session); + Assert.NotEqual(planted, session.Id); + Assert.NotEqual(Guid.Empty, session.Id); + } + + /// + /// A request without a session cookie gets a fresh session. + /// + [Fact] + public void GetSession_NoCookie_CreatesSession() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var request = UnitTestFixture.CreateRequestMock("GET / HTTP/1.1\nCookie:\n\n"); + + // act + var session = componentHub.SessionManager.GetSession(request); + + // validation + Assert.NotNull(session); + Assert.NotEqual(Guid.Empty, session.Id); + } + + /// + /// A cookie naming an id the server issued resolves to that session. + /// + [Fact] + public void GetSession_KnownCookieId_ReturnsThatSession() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var issued = componentHub.SessionManager.GetSession(UnitTestFixture.CreateRequestMock()); + var request = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={issued.Id}\n\n"); + + // act + var session = componentHub.SessionManager.GetSession(request); + + // validation + Assert.Same(issued, session); + } + + /// + /// The id of a session created for a request only reaches the client with the response, + /// so every lookup made while answering that request - the request itself, the sign-in, + /// the cookie - must find the same session even though no cookie can name it yet. + /// Otherwise the identity would be bound to one session and the client sent the id of + /// another. + /// + /// The cookie header the request carries. + [Theory] + [InlineData("")] + [InlineData("session=")] + [InlineData("session=not-a-guid")] + [InlineData("session=6F9619FF-8B86-D011-B42D-00C04FC964FF")] + public void GetSession_SameRequest_ReturnsSameSession(string cookie) + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var request = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: {cookie}\n\n"); + + // act + var first = componentHub.SessionManager.GetSession(request); + var second = componentHub.SessionManager.GetSession(request); + + // validation + Assert.NotNull(first); + Assert.Same(first, second); + Assert.Same(request.Session, first); + } + + /// + /// Regenerating the id keeps the session's state, makes the new id resolve to it and + /// retires the old one - whoever holds the old id holds nothing. + /// + [Fact] + public void RegenerateId_ReplacesIdKeepsStateAndRetiresOldId() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var session = componentHub.SessionManager.GetSession(UnitTestFixture.CreateRequestMock()); + var oldId = session.Id; + session.SetProperty(new SessionPropertyParameter(new Parameter("test", "test param", ParameterScope.Session))); + + // act + var newId = componentHub.SessionManager.RegenerateId(session); + + // validation + Assert.NotEqual(oldId, newId); + Assert.Equal(newId, session.Id); + Assert.NotNull(session.GetProperty()); + + var withOldId = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={oldId}\n\n"); + Assert.NotSame(session, componentHub.SessionManager.GetSession(withOldId)); + + var withNewId = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={newId}\n\n"); + Assert.Same(session, componentHub.SessionManager.GetSession(withNewId)); + } + + /// + /// Regenerating the id of a session the manager has never seen still registers it + /// under the new id, so a caller need not know where the session came from. + /// + [Fact] + public void RegenerateId_UnknownSession_RegistersIt() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var session = new Session(); + + // act + var newId = componentHub.SessionManager.RegenerateId(session); + + // validation + var request = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={newId}\n\n"); + Assert.Same(session, componentHub.SessionManager.GetSession(request)); + } + + /// + /// Regenerating without a session is a programming error and must not pass silently. + /// + [Fact] + public void RegenerateId_Null_Throws() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + + // act & validation + Assert.Throws(() => componentHub.SessionManager.RegenerateId(null)); + } + + /// + /// A session left idle past the timeout must not revive on the next request even before + /// the periodic cleanup runs: its id is dropped and a fresh session takes its place, so a + /// stale cookie never carries access back. + /// + [Fact] + public void GetSession_ExpiredSession_IsReplaced() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + componentHub.SessionManager.Timeout = TimeSpan.FromMinutes(30); + var seeded = componentHub.SessionManager.GetSession(UnitTestFixture.CreateRequestMock()); + var staleId = seeded.Id; + + // push the last-access point beyond the timeout + seeded.Updated = DateTime.Now.AddHours(-1); + + // act + var request = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={staleId}\n\n"); + var session = componentHub.SessionManager.GetSession(request); + + // validation + Assert.NotEqual(staleId, session.Id); + + // the stale id resolves to yet another fresh session, never back to the expired one + var again = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={staleId}\n\n"); + Assert.NotEqual(staleId, componentHub.SessionManager.GetSession(again).Id); + } + + /// + /// An active session renews its idle deadline on each access (sliding window), so a user + /// who keeps using the site is never expired out from under an ongoing session. + /// + [Fact] + public void GetSession_ActiveSession_RenewsAndPersists() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + componentHub.SessionManager.Timeout = TimeSpan.FromMinutes(30); + var seeded = componentHub.SessionManager.GetSession(UnitTestFixture.CreateRequestMock()); + seeded.Updated = DateTime.Now.AddMinutes(-10); + + // act + var request = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={seeded.Id}\n\n"); + var session = componentHub.SessionManager.GetSession(request); + + // validation + Assert.Same(seeded, session); + Assert.True((DateTime.Now - session.Updated).TotalMinutes < 1, "the access renewed the idle deadline"); + } + + /// + /// A non-positive timeout means expiry is switched off, so even a long-idle session is + /// returned unchanged. + /// + [Fact] + public void GetSession_TimeoutDisabled_NeverExpires() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + componentHub.SessionManager.Timeout = TimeSpan.Zero; + var seeded = componentHub.SessionManager.GetSession(UnitTestFixture.CreateRequestMock()); + seeded.Updated = DateTime.Now.AddDays(-3650); + + // act + var request = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={seeded.Id}\n\n"); + var session = componentHub.SessionManager.GetSession(request); + + // validation + Assert.Same(seeded, session); + } + + /// + /// The default idle lifetime is finite and modest - neither the year-long window the + /// cleanup used to assume nor the never-expiring cookie that went with it. + /// + [Fact] + public void Timeout_DefaultIsBoundedAndModest() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + + // validation + Assert.Equal(WebExpress.WebCore.WebSession.SessionManager.DefaultTimeout, componentHub.SessionManager.Timeout); + Assert.True(componentHub.SessionManager.Timeout > TimeSpan.Zero); + Assert.True(componentHub.SessionManager.Timeout <= TimeSpan.FromDays(31)); + } + + /// + /// With no explicit timeout, cleanup reclaims exactly what the access check would already + /// refuse - it sweeps by the manager's own + /// rather than by the old year-long default. The call goes through the interface on + /// purpose: a default argument is taken from the type the caller sees, so a default that + /// only the implementation declares never reaches a caller such as the cleanup job. + /// + [Fact] + public void CleanUp_WithoutExplicitTimeout_UsesTheManagerTimeout() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + ISessionManager sessionManager = componentHub.SessionManager; + sessionManager.Timeout = TimeSpan.FromMinutes(30); + var seeded = sessionManager.GetSession(UnitTestFixture.CreateRequestMock()); + var staleId = seeded.Id; + seeded.Updated = DateTime.Now.AddHours(-1); + var applicationContext = new WebExpress.WebCore.WebApplication.ApplicationContext(); + + // act + sessionManager.CleanUp(applicationContext); + + // validation - expiry on access is switched off, so the stale id could only fail to + // resolve because the sweep removed it, not because the lookup refused it + sessionManager.Timeout = TimeSpan.Zero; + var request = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={staleId}\n\n"); + Assert.NotEqual(staleId, componentHub.SessionManager.GetSession(request).Id); + } + + /// + /// The session directory is shared mutable state: many requests read and create sessions + /// at once. Under a single lock the lookups and creations stay consistent - a known id + /// keeps resolving to its one instance, and no concurrent access corrupts the directory. + /// + [Fact] + public void GetSession_UnderConcurrency_StaysConsistent() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + componentHub.SessionManager.Timeout = TimeSpan.Zero; // isolate concurrency from expiry + var known = componentHub.SessionManager.GetSession(UnitTestFixture.CreateRequestMock()); + var knownCookie = $"GET / HTTP/1.1\nCookie: session={known.Id}\n\n"; + + // act - hammer the manager: half the threads re-read the one known session while the + // other half force fresh creations, so lookups and inserts race on the directory. The + // count is high so the many inserts trigger dictionary resizes while reads are in + // flight - the exact window an unsynchronised read would tear on + System.Threading.Tasks.Parallel.For(0, 50000, i => + { + if (i % 2 == 0) + { + var resolved = componentHub.SessionManager.GetSession(UnitTestFixture.CreateRequestMock(knownCookie)); + Assert.Same(known, resolved); + } + else + { + var fresh = componentHub.SessionManager.GetSession(UnitTestFixture.CreateRequestMock("GET / HTTP/1.1\nCookie:\n\n")); + Assert.NotNull(fresh); + } + }); + + // validation - the known session survived the storm intact + Assert.Same(known, componentHub.SessionManager.GetSession(UnitTestFixture.CreateRequestMock(knownCookie))); + } } } diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs index 76c0eb55..a4db694e 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs @@ -135,6 +135,25 @@ public void GetUri(Type applicationType, Type resourceType, int? param, string e Assert.Equal(expected, uri?.ToString()); } + /// + /// Uses the configured public URI instead of the listener binding for absolute sitemap links. + /// + [Fact] + public void GetUriUsesExternalUri() + { + // arrange + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(externalUri: "https://www.example.com/"); + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(httpServerContext); + var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplicationA)).FirstOrDefault(); + componentHub.SitemapManager.Refresh(); + + // act + var uri = componentHub.SitemapManager.GetUri(typeof(TestResourceA), application); + + // validation + Assert.Equal("https://www.example.com/server/appa/resources/testresourcea", uri?.ToString()); + } + /// /// Test the get endpoint function of the sitemap. /// diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestThemeManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestThemeManager.cs index d0dbffbb..5476d8e2 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestThemeManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestThemeManager.cs @@ -207,30 +207,5 @@ public void ThemeStyle(Type applicationType, Type themeType, string expected) Assert.NotNull(theme); Assert.Equal(expected, theme?.ThemeStyle?.ToString()); } - - /// - /// Test the icon theme property of the theme. TestThemeA - /// carries [IconTheme(Light)]; TestThemeB does not - /// declare an icon theme and therefore falls back to - /// . - /// - [Theory] - [InlineData(typeof(TestApplicationA), typeof(TestThemeA), WebCore.WebIcon.TypeIconTheme.Light)] - [InlineData(typeof(TestApplicationA), typeof(TestThemeB), WebCore.WebIcon.TypeIconTheme.Default)] - [InlineData(typeof(TestApplicationB), typeof(TestThemeA), WebCore.WebIcon.TypeIconTheme.Light)] - [InlineData(typeof(TestApplicationB), typeof(TestThemeB), WebCore.WebIcon.TypeIconTheme.Default)] - public void IconTheme(Type applicationType, Type themeType, WebCore.WebIcon.TypeIconTheme expected) - { - // arrange - var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); - - // act - var theme = componentHub.ThemeManager.GetThemes(application, themeType).FirstOrDefault(); - - // validation - Assert.NotNull(theme); - Assert.Equal(expected, theme?.IconTheme); - } } } diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestContentType.cs b/src/WebExpress.WebCore.Test/Message/UnitTestContentType.cs new file mode 100644 index 00000000..f99abd84 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestContentType.cs @@ -0,0 +1,296 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ContentType enum and the ContentTypeExtensions mapping helpers. + /// + public class UnitTestContentType + { + /// + /// Tests that known file extensions are mapped to the matching content type. + /// + [Theory] + [InlineData(".pdf", ContentType.Pdf)] + [InlineData(".txt", ContentType.Txt)] + [InlineData(".css", ContentType.Css)] + [InlineData(".js", ContentType.Js)] + [InlineData(".mjs", ContentType.Js)] + [InlineData(".map", ContentType.Json)] + [InlineData(".eot", ContentType.Eot)] + [InlineData(".xml", ContentType.Xml)] + [InlineData(".html", ContentType.Html)] + [InlineData(".htm", ContentType.Htm)] + [InlineData(".zip", ContentType.Zip)] + [InlineData(".doc", ContentType.Doc)] + [InlineData(".docx", ContentType.Docx)] + [InlineData(".xls", ContentType.Xls)] + [InlineData(".xlsx", ContentType.Xlsx)] + [InlineData(".xlx", ContentType.Xlx)] + [InlineData(".ppt", ContentType.Ppt)] + [InlineData(".pptx", ContentType.Pptx)] + [InlineData(".gif", ContentType.Gif)] + [InlineData(".png", ContentType.Png)] + [InlineData(".svg", ContentType.Svg)] + [InlineData(".jpeg", ContentType.Jpeg)] + [InlineData(".jpg", ContentType.Jpg)] + [InlineData(".ico", ContentType.Ico)] + [InlineData(".webp", ContentType.WebP)] + [InlineData(".bmp", ContentType.Bmp)] + [InlineData(".avif", ContentType.Avif)] + [InlineData(".mp3", ContentType.Mp3)] + [InlineData(".mp4", ContentType.Mp4)] + [InlineData(".webm", ContentType.Webm)] + [InlineData(".ogg", ContentType.Ogg)] + [InlineData(".wav", ContentType.Wav)] + [InlineData(".json", ContentType.Json)] + [InlineData(".csv", ContentType.Csv)] + [InlineData(".wasm", ContentType.Wasm)] + [InlineData(".woff", ContentType.Woff)] + [InlineData(".woff2", ContentType.Woff2)] + [InlineData(".ttf", ContentType.Ttf)] + [InlineData(".otf", ContentType.Otf)] + [InlineData(".gz", ContentType.Gz)] + [InlineData(".tar", ContentType.Tar)] + [InlineData(".7z", ContentType.SevenZip)] + public void ToContentTypeMapsExtension(string extension, ContentType expected) + { + Assert.Equal(expected, ContentTypeExtensions.ToContentType(extension)); + } + + /// + /// Tests that the extension input is normalized before mapping, so a missing + /// leading dot, surrounding whitespace and casing do not affect the result. + /// + [Theory] + [InlineData("png")] + [InlineData(".PNG")] + [InlineData("PNG")] + [InlineData(" .png ")] + [InlineData(" .PnG ")] + public void ToContentTypeNormalizesInput(string extension) + { + Assert.Equal(ContentType.Png, ContentTypeExtensions.ToContentType(extension)); + } + + /// + /// Tests that empty or unmapped extensions resolve to the unknown content type. + /// + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData(".foo")] + [InlineData(".")] + public void ToContentTypeReturnsUnknown(string extension) + { + Assert.Equal(ContentType.Unknown, ContentTypeExtensions.ToContentType(extension)); + } + + /// + /// Tests that known MIME types are mapped to the matching content type. + /// + [Theory] + [InlineData("application/pdf", ContentType.Pdf)] + [InlineData("text/plain", ContentType.Txt)] + [InlineData("text/css", ContentType.Css)] + [InlineData("application/javascript", ContentType.Js)] + [InlineData("text/javascript", ContentType.Js)] + [InlineData("application/xml", ContentType.Xml)] + [InlineData("text/xml", ContentType.Xml)] + [InlineData("text/html", ContentType.Html)] + [InlineData("application/zip", ContentType.Zip)] + [InlineData("application/msword", ContentType.Doc)] + [InlineData("application/vnd.openxmlformats-officedocument.wordprocessingml.document", ContentType.Docx)] + [InlineData("application/vnd.ms-excel", ContentType.Xls)] + [InlineData("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ContentType.Xlsx)] + [InlineData("application/vnd.ms-powerpoint", ContentType.Ppt)] + [InlineData("application/vnd.openxmlformats-officedocument.presentationml.presentation", ContentType.Pptx)] + [InlineData("image/gif", ContentType.Gif)] + [InlineData("image/png", ContentType.Png)] + [InlineData("image/svg+xml", ContentType.Svg)] + [InlineData("image/jpeg", ContentType.Jpeg)] + [InlineData("image/jpg", ContentType.Jpg)] + [InlineData("image/x-icon", ContentType.Ico)] + [InlineData("image/webp", ContentType.WebP)] + [InlineData("image/bmp", ContentType.Bmp)] + [InlineData("image/avif", ContentType.Avif)] + [InlineData("audio/mpeg", ContentType.Mp3)] + [InlineData("video/mp4", ContentType.Mp4)] + [InlineData("video/webm", ContentType.Webm)] + [InlineData("audio/ogg", ContentType.Ogg)] + [InlineData("audio/wav", ContentType.Wav)] + [InlineData("application/json", ContentType.Json)] + [InlineData("text/csv", ContentType.Csv)] + [InlineData("application/wasm", ContentType.Wasm)] + [InlineData("font/woff", ContentType.Woff)] + [InlineData("font/woff2", ContentType.Woff2)] + [InlineData("font/ttf", ContentType.Ttf)] + [InlineData("font/otf", ContentType.Otf)] + [InlineData("application/vnd.ms-fontobject", ContentType.Eot)] + [InlineData("application/gzip", ContentType.Gz)] + [InlineData("application/x-tar", ContentType.Tar)] + [InlineData("application/x-7z-compressed", ContentType.SevenZip)] + public void ToContentTypeFromMimeMapsMime(string mimeType, ContentType expected) + { + Assert.Equal(expected, ContentTypeExtensions.ToContentTypeFromMime(mimeType)); + } + + /// + /// Tests that the MIME input is normalized for casing and surrounding whitespace before mapping. + /// + [Theory] + [InlineData("TEXT/HTML")] + [InlineData(" text/html ")] + [InlineData("Text/Html")] + public void ToContentTypeFromMimeNormalizesInput(string mimeType) + { + Assert.Equal(ContentType.Html, ContentTypeExtensions.ToContentTypeFromMime(mimeType)); + } + + /// + /// Tests that empty or unmapped MIME types resolve to the unknown content type. + /// + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("application/foo")] + public void ToContentTypeFromMimeReturnsUnknown(string mimeType) + { + Assert.Equal(ContentType.Unknown, ContentTypeExtensions.ToContentTypeFromMime(mimeType)); + } + + /// + /// Tests that each content type reports its MIME type. The mapping is intentionally + /// lossy: htm/html share text/html, and the legacy xlx still maps to the binary + /// ms-excel type, whereas xlsx now uses the dedicated Open XML media type. + /// + [Theory] + [InlineData(ContentType.Pdf, "application/pdf")] + [InlineData(ContentType.Txt, "text/plain")] + [InlineData(ContentType.Css, "text/css")] + [InlineData(ContentType.Js, "text/javascript")] + [InlineData(ContentType.Xml, "text/xml")] + [InlineData(ContentType.Html, "text/html")] + [InlineData(ContentType.Htm, "text/html")] + [InlineData(ContentType.Zip, "application/zip")] + [InlineData(ContentType.Doc, "application/msword")] + [InlineData(ContentType.Docx, "application/vnd.openxmlformats-officedocument.wordprocessingml.document")] + [InlineData(ContentType.Xls, "application/vnd.ms-excel")] + [InlineData(ContentType.Xlx, "application/vnd.ms-excel")] + [InlineData(ContentType.Xlsx, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")] + [InlineData(ContentType.Ppt, "application/vnd.ms-powerpoint")] + [InlineData(ContentType.Pptx, "application/vnd.openxmlformats-officedocument.presentationml.presentation")] + [InlineData(ContentType.Gif, "image/gif")] + [InlineData(ContentType.Png, "image/png")] + [InlineData(ContentType.Svg, "image/svg+xml")] + [InlineData(ContentType.Jpeg, "image/jpeg")] + [InlineData(ContentType.Jpg, "image/jpeg")] + [InlineData(ContentType.Ico, "image/x-icon")] + [InlineData(ContentType.WebP, "image/webp")] + [InlineData(ContentType.Bmp, "image/bmp")] + [InlineData(ContentType.Avif, "image/avif")] + [InlineData(ContentType.Mp3, "audio/mpeg")] + [InlineData(ContentType.Mp4, "video/mp4")] + [InlineData(ContentType.Webm, "video/webm")] + [InlineData(ContentType.Ogg, "audio/ogg")] + [InlineData(ContentType.Wav, "audio/wav")] + [InlineData(ContentType.Json, "application/json")] + [InlineData(ContentType.Csv, "text/csv")] + [InlineData(ContentType.Wasm, "application/wasm")] + [InlineData(ContentType.Woff, "font/woff")] + [InlineData(ContentType.Woff2, "font/woff2")] + [InlineData(ContentType.Ttf, "font/ttf")] + [InlineData(ContentType.Otf, "font/otf")] + [InlineData(ContentType.Eot, "application/vnd.ms-fontobject")] + [InlineData(ContentType.Gz, "application/gzip")] + [InlineData(ContentType.Tar, "application/x-tar")] + [InlineData(ContentType.SevenZip, "application/x-7z-compressed")] + public void GetMimeTypeReturnsMime(ContentType contentType, string expected) + { + Assert.Equal(expected, contentType.GetMimeType()); + } + + /// + /// Tests that the unknown content type falls back to the generic binary MIME type. + /// + [Fact] + public void GetMimeTypeUnknownReturnsOctetStream() + { + Assert.Equal("application/octet-stream", ContentType.Unknown.GetMimeType()); + } + + /// + /// Tests that the Open XML office MIME types round-trip back to their dedicated content + /// types, guarding against the previous behavior where docx/xlsx collapsed onto the + /// legacy binary office formats. + /// + [Theory] + [InlineData(ContentType.Docx)] + [InlineData(ContentType.Xlsx)] + [InlineData(ContentType.Pptx)] + public void OpenXmlOfficeTypesRoundTrip(ContentType contentType) + { + Assert.Equal(contentType, ContentTypeExtensions.ToContentTypeFromMime(contentType.GetMimeType())); + } + + /// + /// Tests that each content type reports a file search pattern matching its extension. + /// + [Theory] + [InlineData(ContentType.Pdf, "*.pdf")] + [InlineData(ContentType.Txt, "*.txt")] + [InlineData(ContentType.Css, "*.css")] + [InlineData(ContentType.Js, "*.js")] + [InlineData(ContentType.Xml, "*.xml")] + [InlineData(ContentType.Html, "*.html")] + [InlineData(ContentType.Htm, "*.htm")] + [InlineData(ContentType.Zip, "*.zip")] + [InlineData(ContentType.Doc, "*.doc")] + [InlineData(ContentType.Docx, "*.docx")] + [InlineData(ContentType.Xls, "*.xls")] + [InlineData(ContentType.Xlx, "*.xlx")] + [InlineData(ContentType.Xlsx, "*.xlsx")] + [InlineData(ContentType.Ppt, "*.ppt")] + [InlineData(ContentType.Pptx, "*.pptx")] + [InlineData(ContentType.Gif, "*.gif")] + [InlineData(ContentType.Png, "*.png")] + [InlineData(ContentType.Svg, "*.svg")] + [InlineData(ContentType.Jpeg, "*.jpeg")] + [InlineData(ContentType.Jpg, "*.jpg")] + [InlineData(ContentType.Ico, "*.ico")] + [InlineData(ContentType.WebP, "*.webp")] + [InlineData(ContentType.Bmp, "*.bmp")] + [InlineData(ContentType.Avif, "*.avif")] + [InlineData(ContentType.Mp3, "*.mp3")] + [InlineData(ContentType.Mp4, "*.mp4")] + [InlineData(ContentType.Webm, "*.webm")] + [InlineData(ContentType.Ogg, "*.ogg")] + [InlineData(ContentType.Wav, "*.wav")] + [InlineData(ContentType.Json, "*.json")] + [InlineData(ContentType.Csv, "*.csv")] + [InlineData(ContentType.Wasm, "*.wasm")] + [InlineData(ContentType.Woff, "*.woff")] + [InlineData(ContentType.Woff2, "*.woff2")] + [InlineData(ContentType.Ttf, "*.ttf")] + [InlineData(ContentType.Otf, "*.otf")] + [InlineData(ContentType.Eot, "*.eot")] + [InlineData(ContentType.Gz, "*.gz")] + [InlineData(ContentType.Tar, "*.tar")] + [InlineData(ContentType.SevenZip, "*.7z")] + public void GetFilePatternReturnsPattern(ContentType contentType, string expected) + { + Assert.Equal(expected, contentType.GetFilePattern()); + } + + /// + /// Tests that the unknown content type falls back to the match-all file pattern. + /// + [Fact] + public void GetFilePatternUnknownReturnsWildcard() + { + Assert.Equal("*.*", ContentType.Unknown.GetFilePattern()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestForwardedClientResolver.cs b/src/WebExpress.WebCore.Test/Message/UnitTestForwardedClientResolver.cs new file mode 100644 index 00000000..05af1b9f --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestForwardedClientResolver.cs @@ -0,0 +1,97 @@ +using System.Net; +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Behind a reverse proxy only X-Forwarded-For names the client, and any caller can send that + /// header. These tests pin down that it is believed exactly as far as trusted proxies vouch for it. + /// The fixture's requests arrive from 127.0.0.1, which plays the proxy. + /// + [Collection("NonParallelTests")] + public class UnitTestForwardedClientResolver + { + /// + /// Resolves the client of a request carrying the given forwarding chain. + /// + /// The X-Forwarded-For value, or null to send none. + /// The configured proxies. + /// The resolved client address as text. + private static string Resolve(string forwardedFor, params string[] trustedProxies) + { + var header = forwardedFor is null ? string.Empty : $"X-Forwarded-For: {forwardedFor}\n"; + var request = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\n{header}\n"); + var resolver = new ForwardedClientResolver(new SecuritySettings { TrustedProxies = [.. trustedProxies] }); + + return resolver.Resolve(request)?.ToString(); + } + + /// + /// Without configured proxies the header is never believed, whatever it claims. + /// + [Fact] + public void UntrustedConnectionIgnoresTheHeader() + { + Assert.Equal("127.0.0.1", Resolve("203.0.113.7")); + Assert.Equal("127.0.0.1", Resolve("203.0.113.7", "10.0.0.1")); + } + + /// + /// A connection from a trusted proxy - named singly or by range - is attributed to the + /// client the proxy reports. + /// + /// The configured proxy. + [Theory] + [InlineData("127.0.0.1")] + [InlineData("127.0.0.0/8")] + [InlineData(" 127.0.0.1 ")] + public void TrustedProxyNamesTheClient(string proxy) + { + Assert.Equal("203.0.113.7", Resolve("203.0.113.7", proxy)); + } + + /// + /// Through several proxies the client is the last address no trusted proxy accounts for; + /// what the client wrote further left cannot choose the address it is limited under. + /// + [Fact] + public void ChainIsReadFromTheTrustedEnd() + { + Assert.Equal("203.0.113.7", Resolve("198.51.100.1, 203.0.113.7, 10.0.0.2", "127.0.0.1", "10.0.0.0/8")); + } + + /// + /// A chain that names no one reliably leaves the request with the proxy, which keeps it limited. + /// + /// The X-Forwarded-For value. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("not-an-address")] + [InlineData("203.0.113.7, garbage")] + [InlineData("10.0.0.2")] + public void UnusableChainFallsBackToTheConnection(string forwardedFor) + { + Assert.Equal("127.0.0.1", Resolve(forwardedFor, "127.0.0.1", "10.0.0.0/8")); + } + + /// + /// Entries that are neither an address nor a range are skipped instead of failing the start; + /// a range written with host bits names the network it lies in. + /// + [Fact] + public void InvalidEntriesAreIgnored() + { + var resolver = new ForwardedClientResolver(new SecuritySettings + { + TrustedProxies = ["proxy.example", "10.0.0.5/8", null, "", "192.168.0.0/16", "::1"] + }); + + Assert.Equal( + [IPNetwork.Parse("10.0.0.0/8"), IPNetwork.Parse("192.168.0.0/16"), IPNetwork.Parse("::1/128")], + resolver.TrustedProxies); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestHttpContextHost.cs b/src/WebExpress.WebCore.Test/Message/UnitTestHttpContextHost.cs new file mode 100644 index 00000000..6f7fe8ff --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestHttpContextHost.cs @@ -0,0 +1,61 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the host-header handling of . + /// + /// + /// A client addressing a non-default port sends it in the host header, while the port of + /// the connection is known separately. Taking the header as the host name would produce a + /// second port and an unparsable uri, which fails before a request context exists - so + /// every request on such a port answers an empty error naming nothing. The tests pin the + /// forms a host header actually arrives in. + /// + public class UnitTestHttpContextHost + { + /// + /// Tests that a host header keeps only its name, whatever port it carries. + /// + /// The value of the host header. + /// The expected host name. + [Theory] + [InlineData("localhost", "localhost")] + [InlineData("localhost:8080", "localhost")] + [InlineData("example.org:443", "example.org")] + [InlineData("192.168.0.5:5000", "192.168.0.5")] + [InlineData("[::1]", "[::1]")] + [InlineData("[::1]:8080", "[::1]")] + [InlineData("[2001:db8::1]:80", "[2001:db8::1]")] + [InlineData("", "")] + [InlineData(null, null)] + public void HostNameOf(string host, string expected) + { + // act + var name = HttpContext.HostNameOf(host); + + // validation + Assert.Equal(expected, name); + } + + /// + /// Tests that the host name is usable as the host of a uri together with a port taken + /// from elsewhere - the case the production code builds. + /// + /// The value of the host header. + /// The port of the connection. + /// The expected uri. + [Theory] + [InlineData("localhost:8080", 8080, "http://localhost:8080/")] + [InlineData("localhost", 80, "http://localhost/")] + [InlineData("[::1]:8080", 8080, "http://[::1]:8080/")] + public void HostNameBuildsUri(string host, int port, string expected) + { + // act + var uri = new UriBuilder("http", HttpContext.HostNameOf(host), port).Uri; + + // validation + Assert.Equal(expected, uri.ToString()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestRequestContent.cs b/src/WebExpress.WebCore.Test/Message/UnitTestRequestContent.cs new file mode 100644 index 00000000..3f189d1b --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestRequestContent.cs @@ -0,0 +1,118 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for reading the request body, which is sized by the client-supplied + /// Content-Length header and therefore must not be trusted for the allocation. + /// + public class UnitTestRequestContent + { + /// + /// Tests that a missing or non-positive content length yields no content. + /// + [Theory] + [InlineData(null)] + [InlineData(0L)] + [InlineData(-1L)] + public void NoContentLength(long? contentLength) + { + // act + var content = Request.GetContent(new MemoryStream([1, 2, 3]), contentLength); + + // validation + Assert.Null(content); + } + + /// + /// Tests that a body larger than the initial buffer is read completely, also when the + /// stream delivers it in small fragments across several buffer resizes. + /// + [Theory] + [InlineData(1)] + [InlineData(4096)] + [InlineData(Request.InitialContentBufferSize)] + public void BodyLargerThanInitialBuffer(int maxReadSize) + { + // arrange + var body = CreateBody(Request.InitialContentBufferSize * 3 + 17); + + // act + var content = Request.GetContent(new FragmentedStream(body, maxReadSize), body.Length); + + // validation + Assert.Equal(body, content); + } + + /// + /// Tests that a client announcing a huge body but sending only a few bytes gets exactly + /// those bytes, without the server reserving memory for the announced size. + /// + [Fact] + public void AnnouncedLengthExceedsBody() + { + // arrange + var body = CreateBody(5); + + // act + var content = Request.GetContent(new MemoryStream(body), long.MaxValue); + + // validation + Assert.Equal(body, content); + } + + /// + /// Tests that bytes beyond the announced content length are not read. + /// + [Fact] + public void BodyExceedsAnnouncedLength() + { + // arrange + var body = CreateBody(100); + + // act + var content = Request.GetContent(new MemoryStream(body), 40); + + // validation + Assert.Equal(body[..40], content); + } + + /// + /// Tests that an announced body that never arrives yields no content. + /// + [Fact] + public void EmptyBody() + { + // act + var content = Request.GetContent(new MemoryStream(), 1024); + + // validation + Assert.Null(content); + } + + /// + /// Creates a body with a non-repeating byte pattern so misplaced fragments are detected. + /// + /// The length of the body. + /// The body. + private static byte[] CreateBody(int length) + { + var body = new byte[length]; + new Random(length).NextBytes(body); + + return body; + } + + /// + /// A stream that returns at most a fixed number of bytes per read, as a network + /// stream does when the client sends its body in several packets. + /// + private sealed class FragmentedStream(byte[] data, int maxReadSize) : MemoryStream(data) + { + public override int Read(byte[] buffer, int offset, int count) + { + return base.Read(buffer, offset, Math.Min(count, maxReadSize)); + } + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestRequestOriginGuard.cs b/src/WebExpress.WebCore.Test/Message/UnitTestRequestOriginGuard.cs new file mode 100644 index 00000000..a9766e8e --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestRequestOriginGuard.cs @@ -0,0 +1,72 @@ +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for , which rejects requests another site + /// triggers in the browser of a signed-in user. + /// + public class UnitTestRequestOriginGuard + { + /// + /// Builds a request with the given method and extra header lines. + /// + /// The request method. + /// Header lines in wire format. + /// The request. + private static IRequest Request(string method, params string[] headers) + { + return UnitTestFixture.CreateRequestMock($"{method} /app HTTP/1.1\r\n{string.Join("\r\n", headers)}\r\n\r\n"); + } + + /// + /// Tests the decision for typical browser and non-browser requests. + /// + [Theory] + [InlineData("GET", true, "Origin: https://evil.example", "Sec-Fetch-Site: cross-site")] + [InlineData("POST", true, "Origin: http://localhost", "Sec-Fetch-Site: same-origin")] + [InlineData("POST", true, "Origin: http://localhost")] + [InlineData("POST", true, "Sec-Fetch-Site: none")] + [InlineData("POST", true)] + [InlineData("POST", false, "Origin: https://evil.example", "Sec-Fetch-Site: cross-site")] + [InlineData("DELETE", false, "Origin: https://evil.example")] + [InlineData("PUT", false, "Origin: null", "Sec-Fetch-Site: cross-site")] + [InlineData("POST", false, "Origin: https://sub.localhost", "Sec-Fetch-Site: same-site")] + [InlineData("POST", true, "Origin: https://public.example", "Sec-Fetch-Site: same-origin")] + [InlineData("POST", true, "Origin: https://evil.example", "Authorization: Bearer abc")] + public void Decision(string method, bool allowed, params string[] headers) + { + var guard = new RequestOriginGuard(null); + + Assert.Equal(allowed, guard.IsAllowed(Request(method, headers))); + } + + /// + /// Tests that a websocket is checked although it opens with a GET, since browsers + /// connect it across sites and send the cookies along. + /// + [Fact] + public void CrossSiteWebSocketIsRejected() + { + var guard = new RequestOriginGuard(null); + var request = Request("GET", "Origin: https://evil.example", "Sec-Fetch-Site: cross-site"); + + Assert.False(guard.IsAllowed(request, webSocket: true)); + } + + /// + /// Tests that a configured origin is accepted and that the check can be switched off. + /// + [Fact] + public void SettingsTrustOriginsAndDisableTheCheck() + { + var request = Request("POST", "Origin: https://portal.example", "Sec-Fetch-Site: cross-site"); + + Assert.True(new RequestOriginGuard(new SecuritySettings { TrustedOrigins = ["https://portal.example/"] }).IsAllowed(request)); + Assert.True(new RequestOriginGuard(new SecuritySettings { CsrfProtection = false }).IsAllowed(request)); + Assert.False(new RequestOriginGuard(new SecuritySettings()).IsAllowed(request)); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseAccepted.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseAccepted.cs new file mode 100644 index 00000000..abdd1108 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseAccepted.cs @@ -0,0 +1,36 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseAccepted class. + /// + public class UnitTestResponseAccepted + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseAccepted(); + + // validation + Assert.Equal(202, response.Status); + } + + /// + /// Tests that the reason phrase is set on construction. + /// + [Fact] + public void Reason() + { + // act + var response = new ResponseAccepted(); + + // validation + Assert.Equal("Accepted", response.Reason); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseBadGateway.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseBadGateway.cs new file mode 100644 index 00000000..0697488f --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseBadGateway.cs @@ -0,0 +1,37 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseBadGateway class. + /// + public class UnitTestResponseBadGateway + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseBadGateway(); + + // validation + Assert.Equal(502, response.Status); + } + + /// + /// Tests that a default html body and content type are produced when no message is supplied. + /// + [Fact] + public void DefaultContent() + { + // act + var response = new ResponseBadGateway(); + + // validation + Assert.Equal("text/html", response.Header.ContentType); + Assert.Contains("502 -", response.Content as string); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseBadRequest.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseBadRequest.cs new file mode 100644 index 00000000..0fae418e --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseBadRequest.cs @@ -0,0 +1,37 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseBadRequest class. + /// + public class UnitTestResponseBadRequest + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseBadRequest(); + + // validation + Assert.Equal(400, response.Status); + } + + /// + /// Tests that a default html body and content type are produced when no message is supplied. + /// + [Fact] + public void DefaultContent() + { + // act + var response = new ResponseBadRequest(); + + // validation + Assert.Equal("text/html", response.Header.ContentType); + Assert.Contains("400 -", response.Content as string); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseConflict.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseConflict.cs new file mode 100644 index 00000000..f0f41e1f --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseConflict.cs @@ -0,0 +1,37 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseConflict class. + /// + public class UnitTestResponseConflict + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseConflict(); + + // validation + Assert.Equal(409, response.Status); + } + + /// + /// Tests that a default html body and content type are produced when no message is supplied. + /// + [Fact] + public void DefaultContent() + { + // act + var response = new ResponseConflict(); + + // validation + Assert.Equal("text/html", response.Header.ContentType); + Assert.Contains("409 -", response.Content as string); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseCreated.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseCreated.cs new file mode 100644 index 00000000..698d8e87 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseCreated.cs @@ -0,0 +1,36 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseCreated class. + /// + public class UnitTestResponseCreated + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseCreated(); + + // validation + Assert.Equal(201, response.Status); + } + + /// + /// Tests that the reason phrase is set on construction. + /// + [Fact] + public void Reason() + { + // act + var response = new ResponseCreated(); + + // validation + Assert.Equal("Created", response.Reason); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseForbidden.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseForbidden.cs new file mode 100644 index 00000000..ffe71640 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseForbidden.cs @@ -0,0 +1,37 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseForbidden class. + /// + public class UnitTestResponseForbidden + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseForbidden(); + + // validation + Assert.Equal(403, response.Status); + } + + /// + /// Tests that a default html body and content type are produced when no message is supplied. + /// + [Fact] + public void DefaultContent() + { + // act + var response = new ResponseForbidden(); + + // validation + Assert.Equal("text/html", response.Header.ContentType); + Assert.Contains("403 -", response.Content as string); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseGatewayTimeout.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseGatewayTimeout.cs new file mode 100644 index 00000000..630dab7f --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseGatewayTimeout.cs @@ -0,0 +1,37 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseGatewayTimeout class. + /// + public class UnitTestResponseGatewayTimeout + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseGatewayTimeout(); + + // validation + Assert.Equal(504, response.Status); + } + + /// + /// Tests that a default html body and content type are produced when no message is supplied. + /// + [Fact] + public void DefaultContent() + { + // act + var response = new ResponseGatewayTimeout(); + + // validation + Assert.Equal("text/html", response.Header.ContentType); + Assert.Contains("504 -", response.Content as string); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseGone.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseGone.cs new file mode 100644 index 00000000..fe8fd6d9 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseGone.cs @@ -0,0 +1,37 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseGone class. + /// + public class UnitTestResponseGone + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseGone(); + + // validation + Assert.Equal(410, response.Status); + } + + /// + /// Tests that a default html body and content type are produced when no message is supplied. + /// + [Fact] + public void DefaultContent() + { + // act + var response = new ResponseGone(); + + // validation + Assert.Equal("text/html", response.Header.ContentType); + Assert.Contains("410 -", response.Content as string); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseHttpVersionNotSupported.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseHttpVersionNotSupported.cs new file mode 100644 index 00000000..4011ffdc --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseHttpVersionNotSupported.cs @@ -0,0 +1,37 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseHttpVersionNotSupported class. + /// + public class UnitTestResponseHttpVersionNotSupported + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseHttpVersionNotSupported(); + + // validation + Assert.Equal(505, response.Status); + } + + /// + /// Tests that a default html body and content type are produced when no message is supplied. + /// + [Fact] + public void DefaultContent() + { + // act + var response = new ResponseHttpVersionNotSupported(); + + // validation + Assert.Equal("text/html", response.Header.ContentType); + Assert.Contains("505 -", response.Content as string); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseInternalServerError.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseInternalServerError.cs new file mode 100644 index 00000000..0dfad0c3 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseInternalServerError.cs @@ -0,0 +1,37 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseInternalServerError class. + /// + public class UnitTestResponseInternalServerError + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseInternalServerError(); + + // validation + Assert.Equal(500, response.Status); + } + + /// + /// Tests that a default html body and content type are produced when no message is supplied. + /// + [Fact] + public void DefaultContent() + { + // act + var response = new ResponseInternalServerError(); + + // validation + Assert.Equal("text/html", response.Header.ContentType); + Assert.Contains("500 -", response.Content as string); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseLengthRequired.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseLengthRequired.cs new file mode 100644 index 00000000..e8aea73a --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseLengthRequired.cs @@ -0,0 +1,37 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseLengthRequired class. + /// + public class UnitTestResponseLengthRequired + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseLengthRequired(); + + // validation + Assert.Equal(411, response.Status); + } + + /// + /// Tests that a default html body and content type are produced when no message is supplied. + /// + [Fact] + public void DefaultContent() + { + // act + var response = new ResponseLengthRequired(); + + // validation + Assert.Equal("text/html", response.Header.ContentType); + Assert.Contains("411 -", response.Content as string); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseMethodNotAllowed.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseMethodNotAllowed.cs new file mode 100644 index 00000000..e20387a4 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseMethodNotAllowed.cs @@ -0,0 +1,37 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseMethodNotAllowed class. + /// + public class UnitTestResponseMethodNotAllowed + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseMethodNotAllowed(); + + // validation + Assert.Equal(405, response.Status); + } + + /// + /// Tests that a default html body and content type are produced when no message is supplied. + /// + [Fact] + public void DefaultContent() + { + // act + var response = new ResponseMethodNotAllowed(); + + // validation + Assert.Equal("text/html", response.Header.ContentType); + Assert.Contains("405 -", response.Content as string); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseMovedPermanently.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseMovedPermanently.cs new file mode 100644 index 00000000..54d0b8f4 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseMovedPermanently.cs @@ -0,0 +1,40 @@ +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebUri; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseMovedPermanently class. + /// + public class UnitTestResponseMovedPermanently + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseMovedPermanently(); + + // validation + Assert.Equal(301, response.Status); + } + + /// + /// Tests that the target location is written to the Location header. + /// + [Fact] + public void Location() + { + // arrange + var location = new UriEndpoint("/target"); + + // act + var response = new ResponseMovedPermanently(location); + + // validation + Assert.Equal(location.ToString(), response.Header.Location); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseMovedTemporarily.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseMovedTemporarily.cs new file mode 100644 index 00000000..6748d8ed --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseMovedTemporarily.cs @@ -0,0 +1,40 @@ +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebUri; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseMovedTemporarily class. + /// + public class UnitTestResponseMovedTemporarily + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseMovedTemporarily(); + + // validation + Assert.Equal(302, response.Status); + } + + /// + /// Tests that the target location is written to the Location header. + /// + [Fact] + public void Location() + { + // arrange + var location = new UriEndpoint("/target"); + + // act + var response = new ResponseMovedTemporarily(location); + + // validation + Assert.Equal(location.ToString(), response.Header.Location); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseNoContent.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseNoContent.cs new file mode 100644 index 00000000..5636f203 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseNoContent.cs @@ -0,0 +1,36 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseNoContent class. + /// + public class UnitTestResponseNoContent + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseNoContent(); + + // validation + Assert.Equal(204, response.Status); + } + + /// + /// Tests that the reason phrase is set on construction. + /// + [Fact] + public void Reason() + { + // act + var response = new ResponseNoContent(); + + // validation + Assert.Equal("No Content", response.Reason); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseNotAcceptable.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseNotAcceptable.cs new file mode 100644 index 00000000..f91f6372 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseNotAcceptable.cs @@ -0,0 +1,37 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseNotAcceptable class. + /// + public class UnitTestResponseNotAcceptable + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseNotAcceptable(); + + // validation + Assert.Equal(406, response.Status); + } + + /// + /// Tests that a default html body and content type are produced when no message is supplied. + /// + [Fact] + public void DefaultContent() + { + // act + var response = new ResponseNotAcceptable(); + + // validation + Assert.Equal("text/html", response.Header.ContentType); + Assert.Contains("406 -", response.Content as string); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseNotFound.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseNotFound.cs new file mode 100644 index 00000000..ebcb6161 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseNotFound.cs @@ -0,0 +1,37 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseNotFound class. + /// + public class UnitTestResponseNotFound + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseNotFound(); + + // validation + Assert.Equal(404, response.Status); + } + + /// + /// Tests that a default html body and content type are produced when no message is supplied. + /// + [Fact] + public void DefaultContent() + { + // act + var response = new ResponseNotFound(); + + // validation + Assert.Equal("text/html", response.Header.ContentType); + Assert.Contains("404 -", response.Content as string); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseNotImplemented.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseNotImplemented.cs new file mode 100644 index 00000000..6671579a --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseNotImplemented.cs @@ -0,0 +1,37 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseNotImplemented class. + /// + public class UnitTestResponseNotImplemented + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseNotImplemented(); + + // validation + Assert.Equal(501, response.Status); + } + + /// + /// Tests that a default html body and content type are produced when no message is supplied. + /// + [Fact] + public void DefaultContent() + { + // act + var response = new ResponseNotImplemented(); + + // validation + Assert.Equal("text/html", response.Header.ContentType); + Assert.Contains("501 -", response.Content as string); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseNotModified.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseNotModified.cs new file mode 100644 index 00000000..82822823 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseNotModified.cs @@ -0,0 +1,36 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseNotModified class. + /// + public class UnitTestResponseNotModified + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseNotModified(); + + // validation + Assert.Equal(304, response.Status); + } + + /// + /// Tests that the reason phrase is set on construction. + /// + [Fact] + public void Reason() + { + // act + var response = new ResponseNotModified(); + + // validation + Assert.Equal("Not Modified", response.Reason); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseOK.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseOK.cs new file mode 100644 index 00000000..eaa5ef78 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseOK.cs @@ -0,0 +1,36 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseOK class. + /// + public class UnitTestResponseOK + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseOK(); + + // validation + Assert.Equal(200, response.Status); + } + + /// + /// Tests that the reason phrase is set on construction. + /// + [Fact] + public void Reason() + { + // act + var response = new ResponseOK(); + + // validation + Assert.Equal("OK", response.Reason); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponsePartialContent.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponsePartialContent.cs new file mode 100644 index 00000000..3dcb89c3 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponsePartialContent.cs @@ -0,0 +1,36 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponsePartialContent class. + /// + public class UnitTestResponsePartialContent + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponsePartialContent(); + + // validation + Assert.Equal(206, response.Status); + } + + /// + /// Tests that the reason phrase is set on construction. + /// + [Fact] + public void Reason() + { + // act + var response = new ResponsePartialContent(); + + // validation + Assert.Equal("Partial Content", response.Reason); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponsePayloadTooLarge.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponsePayloadTooLarge.cs new file mode 100644 index 00000000..b1318d77 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponsePayloadTooLarge.cs @@ -0,0 +1,37 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponsePayloadTooLarge class. + /// + public class UnitTestResponsePayloadTooLarge + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponsePayloadTooLarge(); + + // validation + Assert.Equal(413, response.Status); + } + + /// + /// Tests that a default html body and content type are produced when no message is supplied. + /// + [Fact] + public void DefaultContent() + { + // act + var response = new ResponsePayloadTooLarge(); + + // validation + Assert.Equal("text/html", response.Header.ContentType); + Assert.Contains("413 -", response.Content as string); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponsePermanentRedirect.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponsePermanentRedirect.cs new file mode 100644 index 00000000..5cea3d86 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponsePermanentRedirect.cs @@ -0,0 +1,56 @@ +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebUri; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponsePermanentRedirect class. + /// + public class UnitTestResponsePermanentRedirect + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponsePermanentRedirect(); + + // validation + Assert.Equal(308, response.Status); + } + + /// + /// Tests that the target location is written to the Location header. + /// + [Fact] + public void Location() + { + // arrange + var location = new UriEndpoint("/target"); + + // act + var response = new ResponsePermanentRedirect(location); + + // validation + Assert.Equal(location.ToString(), response.Header.Location); + } + + /// + /// Tests that the reason phrase is set regardless of the constructor used, so a + /// response created without a location still writes a complete status line. + /// + [Fact] + public void Reason() + { + // act + var withoutLocation = new ResponsePermanentRedirect(); + var withLocation = new ResponsePermanentRedirect(new UriEndpoint("/target")); + + // validation + Assert.Equal("Permanent Redirect", withoutLocation.Reason); + Assert.Equal("Permanent Redirect", withLocation.Reason); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponsePreconditionRequired.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponsePreconditionRequired.cs new file mode 100644 index 00000000..5e82aa44 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponsePreconditionRequired.cs @@ -0,0 +1,37 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponsePreconditionRequired class. + /// + public class UnitTestResponsePreconditionRequired + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponsePreconditionRequired(); + + // validation + Assert.Equal(428, response.Status); + } + + /// + /// Tests that a default html body and content type are produced when no message is supplied. + /// + [Fact] + public void DefaultContent() + { + // act + var response = new ResponsePreconditionRequired(); + + // validation + Assert.Equal("text/html", response.Header.ContentType); + Assert.Contains("428 -", response.Content as string); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseReason.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseReason.cs new file mode 100644 index 00000000..f01e20a2 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseReason.cs @@ -0,0 +1,57 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests that pin the reason phrase of every response type to the standard phrase of its + /// status code, because the phrase is sent on the HTTP/1.1 status line. + /// + public class UnitTestResponseReason + { + /// + /// Tests that the reason phrase matches the standard phrase for the status code. + /// + [Theory] + [InlineData(typeof(ResponseOK), "OK")] + [InlineData(typeof(ResponseCreated), "Created")] + [InlineData(typeof(ResponseAccepted), "Accepted")] + [InlineData(typeof(ResponseNoContent), "No Content")] + [InlineData(typeof(ResponsePartialContent), "Partial Content")] + [InlineData(typeof(ResponseMovedPermanently), "Moved Permanently")] + [InlineData(typeof(ResponseMovedTemporarily), "Found")] + [InlineData(typeof(ResponseSeeOther), "See Other")] + [InlineData(typeof(ResponseNotModified), "Not Modified")] + [InlineData(typeof(ResponseTemporaryRedirect), "Temporary Redirect")] + [InlineData(typeof(ResponsePermanentRedirect), "Permanent Redirect")] + [InlineData(typeof(ResponseBadRequest), "Bad Request")] + [InlineData(typeof(ResponseUnauthorized), "Unauthorized")] + [InlineData(typeof(ResponseForbidden), "Forbidden")] + [InlineData(typeof(ResponseNotFound), "Not Found")] + [InlineData(typeof(ResponseMethodNotAllowed), "Method Not Allowed")] + [InlineData(typeof(ResponseNotAcceptable), "Not Acceptable")] + [InlineData(typeof(ResponseRequestTimeout), "Request Timeout")] + [InlineData(typeof(ResponseConflict), "Conflict")] + [InlineData(typeof(ResponseGone), "Gone")] + [InlineData(typeof(ResponseLengthRequired), "Length Required")] + [InlineData(typeof(ResponsePayloadTooLarge), "Payload Too Large")] + [InlineData(typeof(ResponseUnsupportedMediaType), "Unsupported Media Type")] + [InlineData(typeof(ResponseUnprocessableEntity), "Unprocessable Entity")] + [InlineData(typeof(ResponseUpgradeRequired), "Upgrade Required")] + [InlineData(typeof(ResponsePreconditionRequired), "Precondition Required")] + [InlineData(typeof(ResponseTooManyRequests), "Too Many Requests")] + [InlineData(typeof(ResponseInternalServerError), "Internal Server Error")] + [InlineData(typeof(ResponseNotImplemented), "Not Implemented")] + [InlineData(typeof(ResponseBadGateway), "Bad Gateway")] + [InlineData(typeof(ResponseServiceUnavailable), "Service Unavailable")] + [InlineData(typeof(ResponseGatewayTimeout), "Gateway Timeout")] + [InlineData(typeof(ResponseHttpVersionNotSupported), "HTTP Version Not Supported")] + public void Reason(Type responseType, string expected) + { + // act + var response = (Response)Activator.CreateInstance(responseType); + + // validation + Assert.Equal(expected, response.Reason); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseRequestTimeout.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseRequestTimeout.cs new file mode 100644 index 00000000..bfb3eca2 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseRequestTimeout.cs @@ -0,0 +1,37 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseRequestTimeout class. + /// + public class UnitTestResponseRequestTimeout + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseRequestTimeout(); + + // validation + Assert.Equal(408, response.Status); + } + + /// + /// Tests that a default html body and content type are produced when no message is supplied. + /// + [Fact] + public void DefaultContent() + { + // act + var response = new ResponseRequestTimeout(); + + // validation + Assert.Equal("text/html", response.Header.ContentType); + Assert.Contains("408 -", response.Content as string); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseSeeOther.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseSeeOther.cs new file mode 100644 index 00000000..0dcb0fa4 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseSeeOther.cs @@ -0,0 +1,56 @@ +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebUri; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseSeeOther class. + /// + public class UnitTestResponseSeeOther + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseSeeOther(); + + // validation + Assert.Equal(303, response.Status); + } + + /// + /// Tests that the target location is written to the Location header. + /// + [Fact] + public void Location() + { + // arrange + var location = new UriEndpoint("/target"); + + // act + var response = new ResponseSeeOther(location); + + // validation + Assert.Equal(location.ToString(), response.Header.Location); + } + + /// + /// Tests that the reason phrase is set regardless of the constructor used, so a + /// response created without a location still writes a complete status line. + /// + [Fact] + public void Reason() + { + // act + var withoutLocation = new ResponseSeeOther(); + var withLocation = new ResponseSeeOther(new UriEndpoint("/target")); + + // validation + Assert.Equal("See Other", withoutLocation.Reason); + Assert.Equal("See Other", withLocation.Reason); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseSender.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseSender.cs new file mode 100644 index 00000000..1f7d3d20 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseSender.cs @@ -0,0 +1,259 @@ +using System.IO.Pipelines; +using System.Net; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using WebExpress.WebCore.WebHtml; +using WebExpress.WebCore.WebHtml.Parser; +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for , focusing on the handling of connection-specific + /// headers across HTTP protocol versions where their validity differs. + /// + public class UnitTestResponseSender + { + /// + /// Sends the given response through the sender on a connection negotiated with the specified + /// protocol and returns the populated response feature for header inspection. + /// + /// The negotiated protocol, e.g. "HTTP/1.1" or "HTTP/2". + /// The response to send. + /// The response feature carrying the emitted headers and status. + private static async Task Send(string protocol, IResponse response) + { + return (await Send(protocol, "http", response, null)).Feature; + } + + /// + /// Sends the given response over the given scheme with the given security settings and + /// returns the emitted headers together with the written body. + /// + /// The negotiated protocol, e.g. "HTTP/1.1" or "HTTP/2". + /// The request scheme, "http" or "https". + /// The response to send. + /// The security settings, or null for the built-in defaults. + /// The response feature and the body as text. + private static async Task<(IHttpResponseFeature Feature, string Body)> Send(string protocol, string scheme, IResponse response, SecuritySettings settings) + { + var features = new FeatureCollection(); + var body = new FakeResponseBodyFeature(); + features.Set(new HttpRequestFeature { Protocol = protocol, Scheme = scheme, Headers = new HeaderDictionary() }); + features.Set(new HttpResponseFeature { Headers = new HeaderDictionary() }); + features.Set(body); + + await new ResponseSender(new SecurityHeaders(settings)).SendAsync(new FakeHttpContext(features), response); + + return (features.Get(), Encoding.UTF8.GetString(((MemoryStream)body.Stream).ToArray())); + } + + /// + /// Tests that a server without any security configuration still sends the protective + /// headers, and that HSTS stays off over plain http where browsers ignore it. + /// + [Fact] + public async Task SecurityHeadersAreSentByDefault() + { + var (feature, _) = await Send("HTTP/1.1", "http", new ResponseOK(), null); + + Assert.Equal("nosniff", feature.Headers["X-Content-Type-Options"]); + Assert.Equal("SAMEORIGIN", feature.Headers["X-Frame-Options"]); + Assert.Equal("strict-origin-when-cross-origin", feature.Headers["Referrer-Policy"]); + Assert.Contains("object-src 'self'", feature.Headers["Content-Security-Policy"].ToString()); + Assert.False(feature.Headers.ContainsKey("Strict-Transport-Security")); + } + + /// + /// Tests that HSTS is announced on https responses. + /// + [Fact] + public async Task HstsIsSentOverHttps() + { + var (feature, _) = await Send("HTTP/2", "https", new ResponseOK(), null); + + Assert.Equal("max-age=31536000", feature.Headers["Strict-Transport-Security"]); + } + + /// + /// Tests that the inline scripts the server renders carry the very nonce the policy of + /// the same response allows, so they keep running under the strict policy. + /// + [Fact] + public async Task InlineScriptCarriesNonceOfPolicy() + { + var response = new ResponseOK() { Content = new HtmlElementScriptingScript("let a = 1;") }; + + var (feature, body) = await Send("HTTP/1.1", "http", response, null); + + var nonce = Regex.Match(feature.Headers["Content-Security-Policy"].ToString(), "'nonce-([^']+)'").Groups[1].Value; + Assert.NotEmpty(nonce); + Assert.Contains($"nonce=\"{nonce}\"", body); + } + + /// + /// Tests that an html response without an explicit type is still declared as html, since + /// nosniff forbids the browser to guess and it would otherwise display the markup as text. + /// + [Fact] + public async Task HtmlContentIsTypedAsHtml() + { + var response = new ResponseOK() { Content = new HtmlElementTextContentDiv() }; + + var (feature, _) = await Send("HTTP/1.1", "http", response, null); + + Assert.Equal("text/html; charset=utf-8", feature.Headers.ContentType); + } + + /// + /// Tests that a script read from markup, which may stem from user input, gets no nonce + /// and is therefore still blocked by the browser. + /// + [Fact] + public async Task ParsedScriptGetsNoNonce() + { + var parsed = new HtmlParser().Parse("
        "); + var response = new ResponseOK() { Content = parsed.First() }; + + var (_, body) = await Send("HTTP/1.1", "http", response, null); + + Assert.Contains(" + /// Tests that the settings can switch individual headers off, replace them, and move the + /// policy into report-only mode. + ///
        + [Fact] + public async Task SettingsOverrideDefaults() + { + var settings = new SecuritySettings + { + ContentSecurityPolicy = "default-src 'self' 'nonce-{nonce}'", + ContentSecurityPolicyReportOnly = true, + HstsMaxAge = 0, + Headers = new() { ["X-Frame-Options"] = "", ["Referrer-Policy"] = "no-referrer" } + }; + + var (feature, _) = await Send("HTTP/1.1", "https", new ResponseOK(), settings); + + Assert.False(feature.Headers.ContainsKey("Content-Security-Policy")); + Assert.StartsWith("default-src 'self' 'nonce-", feature.Headers["Content-Security-Policy-Report-Only"].ToString()); + Assert.False(feature.Headers.ContainsKey("Strict-Transport-Security")); + Assert.False(feature.Headers.ContainsKey("X-Frame-Options")); + Assert.Equal("no-referrer", feature.Headers["Referrer-Policy"]); + } + + /// + /// Tests that cookies take the configured SameSite default unless they state their own. + /// + [Fact] + public async Task CookieSameSiteFollowsSettingsAndPerCookieOverride() + { + var response = new ResponseOK(); + response.Header.Cookies.Add(new Cookie("a", "1")); + response.Header.Cookies.Add(new Cookie("b", "2")); + response.Header.CookieSameSite["b"] = SameSiteMode.Lax; + + var (feature, _) = await Send("HTTP/1.1", "http", response, new SecuritySettings { CookieSameSite = SameSiteMode.Strict }); + + var cookies = feature.Headers.SetCookie.ToArray(); + Assert.Contains(cookies, x => x.StartsWith("a=1") && x.EndsWith("SameSite=Strict")); + Assert.Contains(cookies, x => x.StartsWith("b=2") && x.EndsWith("SameSite=Lax")); + } + + /// + /// Tests that the connection-specific Keep-Alive header is never emitted: persistence is + /// owned by Kestrel and the header is forbidden on HTTP/2. + /// + [Theory] + [InlineData("HTTP/1.1")] + [InlineData("HTTP/2")] + public async Task KeepAliveHeaderIsNeverSent(string protocol) + { + var feature = await Send(protocol, new ResponseOK()); + + Assert.False(feature.Headers.ContainsKey("Keep-Alive")); + } + + /// + /// Tests that the websocket handshake headers are emitted on HTTP/1.x, where they are valid. + /// + [Fact] + public async Task ConnectionAndUpgradeEmittedOnHttp1() + { + var response = new ResponseSwitchingProtocols("upgrade", "websocket", "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="); + + var feature = await Send("HTTP/1.1", response); + + Assert.True(feature.Headers.ContainsKey("Connection")); + Assert.True(feature.Headers.ContainsKey("Upgrade")); + } + + /// + /// Tests that the connection-specific Connection and Upgrade headers are suppressed on + /// HTTP/2, where Kestrel rejects them (RFC 9113 §8.2.2). + /// + [Fact] + public async Task ConnectionAndUpgradeSuppressedOnHttp2() + { + var response = new ResponseSwitchingProtocols("upgrade", "websocket", "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="); + + var feature = await Send("HTTP/2", response); + + Assert.False(feature.Headers.ContainsKey("Connection")); + Assert.False(feature.Headers.ContainsKey("Upgrade")); + } + + /// + /// A minimal backed by an explicit feature collection, used to + /// drive the sender without constructing a full request pipeline. + /// + private sealed class FakeHttpContext : IHttpContext + { + public FakeHttpContext(IFeatureCollection features) => Features = features; + + public IHttpServerContext HttpServerContext => null; + public string Id => "test"; + public IRequest Request => null; + public EndPoint LocalEndPoint => new IPEndPoint(IPAddress.Loopback, 80); + public EndPoint RemoteEndPoint => new IPEndPoint(IPAddress.Loopback, 12345); + public IFeatureCollection Features { get; } + public Encoding Encoding => Encoding.UTF8; + public Uri Uri => new("http://localhost/"); + } + + /// + /// A minimal response body feature that discards the written body into an in-memory stream. + /// + private sealed class FakeResponseBodyFeature : IHttpResponseBodyFeature + { + public Stream Stream { get; } = new MemoryStream(); + public PipeWriter Writer => PipeWriter.Create(Stream); + public Task CompleteAsync() => Task.CompletedTask; + public void DisableBuffering() { } + public Task SendFileAsync(string path, long offset, long? count, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task StartAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseServiceUnavailable.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseServiceUnavailable.cs new file mode 100644 index 00000000..ab53fa4f --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseServiceUnavailable.cs @@ -0,0 +1,37 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseServiceUnavailable class. + /// + public class UnitTestResponseServiceUnavailable + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseServiceUnavailable(); + + // validation + Assert.Equal(503, response.Status); + } + + /// + /// Tests that a default html body and content type are produced when no message is supplied. + /// + [Fact] + public void DefaultContent() + { + // act + var response = new ResponseServiceUnavailable(); + + // validation + Assert.Equal("text/html", response.Header.ContentType); + Assert.Contains("503 -", response.Content as string); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseSwitchingProtocols.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseSwitchingProtocols.cs new file mode 100644 index 00000000..0bc750fa --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseSwitchingProtocols.cs @@ -0,0 +1,38 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseSwitchingProtocols class. + /// + public class UnitTestResponseSwitchingProtocols + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseSwitchingProtocols("keep-alive", "websocket", "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="); + + // validation + Assert.Equal(101, response.Status); + } + + /// + /// Tests that the handshake headers required for a protocol upgrade are set. + /// + [Fact] + public void HandshakeHeaders() + { + // act + var response = new ResponseSwitchingProtocols("keep-alive", "websocket", "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="); + + // validation + Assert.Equal("keep-alive", response.Header.Connection); + Assert.Equal("websocket", response.Header.Upgrade); + Assert.Equal("s3pPLMBiTxaQ9kYGzzhZRbK+xOo=", response.Header.SecWebSocketAccept); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseTemporaryRedirect.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseTemporaryRedirect.cs new file mode 100644 index 00000000..867f7b65 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseTemporaryRedirect.cs @@ -0,0 +1,56 @@ +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebUri; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseTemporaryRedirect class. + /// + public class UnitTestResponseTemporaryRedirect + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseTemporaryRedirect(); + + // validation + Assert.Equal(307, response.Status); + } + + /// + /// Tests that the target location is written to the Location header. + /// + [Fact] + public void Location() + { + // arrange + var location = new UriEndpoint("/target"); + + // act + var response = new ResponseTemporaryRedirect(location); + + // validation + Assert.Equal(location.ToString(), response.Header.Location); + } + + /// + /// Tests that the reason phrase is set regardless of the constructor used, so a + /// response created without a location still writes a complete status line. + /// + [Fact] + public void Reason() + { + // act + var withoutLocation = new ResponseTemporaryRedirect(); + var withLocation = new ResponseTemporaryRedirect(new UriEndpoint("/target")); + + // validation + Assert.Equal("Temporary Redirect", withoutLocation.Reason); + Assert.Equal("Temporary Redirect", withLocation.Reason); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseTooManyRequests.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseTooManyRequests.cs new file mode 100644 index 00000000..55ee175b --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseTooManyRequests.cs @@ -0,0 +1,37 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseTooManyRequests class. + /// + public class UnitTestResponseTooManyRequests + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseTooManyRequests(); + + // validation + Assert.Equal(429, response.Status); + } + + /// + /// Tests that a default html body and content type are produced when no message is supplied. + /// + [Fact] + public void DefaultContent() + { + // act + var response = new ResponseTooManyRequests(); + + // validation + Assert.Equal("text/html", response.Header.ContentType); + Assert.Contains("429 -", response.Content as string); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseUnauthorized.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseUnauthorized.cs new file mode 100644 index 00000000..2c35aa98 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseUnauthorized.cs @@ -0,0 +1,78 @@ +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebStatusPage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseUnauthorized class. + /// + public class UnitTestResponseUnauthorized + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseUnauthorized(); + + // validation + Assert.Equal(401, response.Status); + } + + /// + /// Tests that the reason phrase is the standard one for 401 and not inherited from a success response. + /// + [Fact] + public void Reason() + { + // act + var response = new ResponseUnauthorized(); + + // validation + Assert.Equal("Unauthorized", response.Reason); + } + + /// + /// Tests that a supplied status message becomes the body instead of being dropped. + /// + [Fact] + public void MessageContent() + { + // act + var response = new ResponseUnauthorized(new StatusMessage("Authentication required.")); + + // validation + Assert.Equal("Authentication required.", response.Content); + Assert.Equal("text/html", response.Header.ContentType); + } + + /// + /// Tests that no body is set without a message, so callers can attach their own content type. + /// + [Fact] + public void NoMessageNoContent() + { + // act + var response = new ResponseUnauthorized(); + + // validation + Assert.Null(response.Content); + Assert.Null(response.Header.ContentType); + } + + /// + /// Tests that the WWW-Authenticate header is requested so the client is prompted to authenticate. + /// + [Fact] + public void WwwAuthenticate() + { + // act + var response = new ResponseUnauthorized(); + + // validation + Assert.True(response.Header.WWWAuthenticate); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseUnprocessableEntity.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseUnprocessableEntity.cs new file mode 100644 index 00000000..e7f9dc74 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseUnprocessableEntity.cs @@ -0,0 +1,37 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseUnprocessableEntity class. + /// + public class UnitTestResponseUnprocessableEntity + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseUnprocessableEntity(); + + // validation + Assert.Equal(422, response.Status); + } + + /// + /// Tests that a default html body and content type are produced when no message is supplied. + /// + [Fact] + public void DefaultContent() + { + // act + var response = new ResponseUnprocessableEntity(); + + // validation + Assert.Equal("text/html", response.Header.ContentType); + Assert.Contains("422 -", response.Content as string); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseUnsupportedMediaType.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseUnsupportedMediaType.cs new file mode 100644 index 00000000..e1885df6 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseUnsupportedMediaType.cs @@ -0,0 +1,37 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseUnsupportedMediaType class. + /// + public class UnitTestResponseUnsupportedMediaType + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseUnsupportedMediaType(); + + // validation + Assert.Equal(415, response.Status); + } + + /// + /// Tests that a default html body and content type are produced when no message is supplied. + /// + [Fact] + public void DefaultContent() + { + // act + var response = new ResponseUnsupportedMediaType(); + + // validation + Assert.Equal("text/html", response.Header.ContentType); + Assert.Contains("415 -", response.Content as string); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseUpgradeRequired.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseUpgradeRequired.cs new file mode 100644 index 00000000..72404b24 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseUpgradeRequired.cs @@ -0,0 +1,37 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the ResponseUpgradeRequired class. + /// + public class UnitTestResponseUpgradeRequired + { + /// + /// Tests that the status code is derived from the StatusCode attribute. + /// + [Fact] + public void StatusCode() + { + // act + var response = new ResponseUpgradeRequired(); + + // validation + Assert.Equal(426, response.Status); + } + + /// + /// Tests that a default html body and content type are produced when no message is supplied. + /// + [Fact] + public void DefaultContent() + { + // act + var response = new ResponseUpgradeRequired(); + + // validation + Assert.Equal("text/html", response.Header.ContentType); + Assert.Contains("426 -", response.Content as string); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Server/UnitTestContainerEnvironment.cs b/src/WebExpress.WebCore.Test/Server/UnitTestContainerEnvironment.cs new file mode 100644 index 00000000..557fc9ef --- /dev/null +++ b/src/WebExpress.WebCore.Test/Server/UnitTestContainerEnvironment.cs @@ -0,0 +1,92 @@ +namespace WebExpress.WebCore.Test.Server +{ + /// + /// Unit tests for the container detection, fed with synthetic markers so the result does not + /// depend on where the tests run. + /// + public class UnitTestContainerEnvironment + { + /// + /// Runs the detection against the given environment variables and files. + /// + /// The environment variables that are set. + /// The files that exist, with their content. + /// The detection result. + private static bool Detect(Dictionary variables = null, Dictionary files = null) + { + variables ??= []; + files ??= []; + + return ContainerEnvironment.Detect + ( + name => variables.GetValueOrDefault(name), + files.ContainsKey, + path => files[path] + ); + } + + /// + /// Tests that a host without any marker is not taken for a container. + /// + [Fact] + public void NoMarker() + { + Assert.False(Detect()); + } + + /// + /// Tests that the environment variables set by the .NET images and by podman are recognized. + /// + [Theory] + [InlineData("DOTNET_RUNNING_IN_CONTAINER", "true", true)] + [InlineData("DOTNET_RUNNING_IN_CONTAINER", "True", true)] + [InlineData("DOTNET_RUNNING_IN_CONTAINER", "false", false)] + [InlineData("container", "podman", true)] + [InlineData("container", "", false)] + public void EnvironmentVariable(string name, string value, bool expected) + { + Assert.Equal(expected, Detect(variables: new() { [name] = value })); + } + + /// + /// Tests that the marker files of docker and podman are recognized. + /// + [Theory] + [InlineData("/.dockerenv")] + [InlineData("/run/.containerenv")] + public void MarkerFile(string path) + { + Assert.True(Detect(files: new() { [path] = "" })); + } + + /// + /// Tests that the control groups of process 1 reveal a container under cgroup v1, while the + /// anonymous cgroup v2 entry and a plain host stay unrecognized. + /// + [Theory] + [InlineData("12:memory:/docker/3f2a9c\n", true)] + [InlineData("11:cpu:/kubepods/besteffort/pod1\n", true)] + [InlineData("0::/\n", false)] + [InlineData("12:memory:/init.scope\n", false)] + public void ControlGroup(string content, bool expected) + { + Assert.Equal(expected, Detect(files: new() { ["/proc/1/cgroup"] = content })); + } + + /// + /// Tests that an unreadable control group file counts as no container rather than failing. + /// + [Fact] + public void UnreadableControlGroup() + { + var result = ContainerEnvironment.Detect + ( + _ => null, + path => path == "/proc/1/cgroup", + _ => throw new UnauthorizedAccessException() + ); + + Assert.False(result); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Server/UnitTestGracefulShutdown.cs b/src/WebExpress.WebCore.Test/Server/UnitTestGracefulShutdown.cs new file mode 100644 index 00000000..7e84d0bc --- /dev/null +++ b/src/WebExpress.WebCore.Test/Server/UnitTestGracefulShutdown.cs @@ -0,0 +1,519 @@ +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Configuration; +using System.Diagnostics; +using System.Net; +using System.Net.WebSockets; +using System.Reflection; +using System.Text.Json; +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.Test.WWW.Api._2; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebComponent.Model; +using WebExpress.WebCore.WebJob; +using WebExpress.WebCore.WebJob.Model; +using WebExpress.WebCore.WebSetting; +using WebExpress.WebCore.WebSocket; + +namespace WebExpress.WebCore.Test.Server +{ + /// + /// Verifies shutdown admission, drain ordering, deadlines, and host execution. + /// + [Collection("NonParallelTests")] + public class UnitTestGracefulShutdown + { + /// + /// Keeps container configuration case insensitive and rejects invalid drain budgets at startup. + /// + /// The mode supplied by a configuration provider. + /// The configured drain budget. + /// Whether startup must accept these settings. + [Theory] + [InlineData("graceful", "30", true)] + [InlineData("GRACEFUL", "1", true)] + [InlineData("immediate", "30", true)] + [InlineData("invalid", "30", false)] + [InlineData("99", "30", false)] + [InlineData("graceful", "0", false)] + [InlineData("graceful", "-1", false)] + [InlineData("graceful", "86401", false)] + public void Configuration_ValidatesShutdown(string mode, string seconds, bool valid) + { + // arrange + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["WebExpress:Shutdown"] = mode, + ["WebExpress:ShutdownTimeoutSeconds"] = seconds + }).Build(); + using var configurationOwner = (IDisposable)configuration; + + // act + var error = Record.Exception(() => configuration.GetServerSettings()); + + // validation + Assert.Equal(valid, error is null); + if (valid) + { + Assert.Equal(Enum.Parse(mode, true), configuration.GetServerSettings().Shutdown); + } + } + + /// + /// Proves that an admitted HTTP response completes and cleanup waits for background work too. + /// + /// A task that completes after the live Kestrel round trip and drain assertions. + [Fact] + public async Task Kestrel_Graceful_DrainsRequestsAndWorkersAndRejectsNewWork() + { + // arrange + var server = CreateServer(ShutdownMode.Graceful); + using var hub = UnitTestFixture.CreateAndRegisterComponentHubMock(server.HttpServerContext); + hub.SitemapManager.Refresh(); + using var releaseRequest = new ManualResetEventSlim(); + var requestEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseWorker = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var workerEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + TestRestApiB.BeforeGet = () => + { + requestEntered.TrySetResult(); + releaseRequest.Wait(TimeSpan.FromSeconds(10)); + }; + server.HttpServerContext.Lifetime.TryRun(async _ => + { + workerEntered.SetResult(); + await releaseWorker.Task; + }); + + try + { + Assert.True(server.Start()); + using var client = CreateClient(server); + var responseTask = client.GetAsync("/server/appa/api/2/testrestapib", TestContext.Current.CancellationToken); + await requestEntered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + await workerEntered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + // act + var shutdown = server.StopAsync(TestContext.Current.CancellationToken); + var repeated = server.StopAsync(TestContext.Current.CancellationToken); + + // validation + Assert.Same(shutdown, repeated); + Assert.False(server.IsRunning); + Assert.False(shutdown.IsCompleted); + Assert.False(server.HttpServerContext.Lifetime.TryRun(() => throw new Exception("must not execute"))); + var context = UnitTestFixture.CreateHttpContextMock("GET /health HTTP/1.1\r\n\r\n"); + var response = new HttpResponseFeature(); + using var output = new MemoryStream(); + context.Features.Set(response); + context.Features.Set(new StreamResponseBodyFeature(output)); + await server.ProcessRequestAsync(context); + Assert.Equal(503, response.StatusCode); + using var newcomer = CreateClient(server); + await Assert.ThrowsAsync(() => newcomer.GetAsync("/health", TestContext.Current.CancellationToken)); + + releaseRequest.Set(); + using var completed = await responseTask.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, completed.StatusCode); + Assert.Contains("Not implemented.", await completed.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + Assert.False(shutdown.IsCompleted); + releaseWorker.SetResult(); + await shutdown.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + } + finally + { + releaseRequest.Set(); + releaseWorker.TrySetResult(); + TestRestApiB.BeforeGet = null; + await server.StopAsync(TestContext.Current.CancellationToken); + } + } + + /// + /// Bounds an uncooperative worker without canceling it prematurely in graceful mode. + /// + /// The requested shutdown policy. + /// A task that completes when the bounded shutdown and remaining worker finish. + [Theory] + [InlineData(ShutdownMode.Graceful)] + [InlineData(ShutdownMode.Immediate)] + public async Task StopAsync_DeadlineBoundsUncooperativeWork(ShutdownMode mode) + { + // arrange + var server = CreateServer(mode); + server.Settings.ShutdownTimeoutSeconds = 1; + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + server.HttpServerContext.Lifetime.TryRun(_ => release.Task); + var elapsed = Stopwatch.StartNew(); + + try + { + // act + await server.StopAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + // validation + Assert.False(release.Task.IsCompleted); + Assert.True(elapsed.Elapsed < TimeSpan.FromSeconds(4)); + if (mode == ShutdownMode.Graceful) + { + Assert.True(elapsed.Elapsed >= TimeSpan.FromMilliseconds(850)); + Assert.Contains(server.HttpServerContext.Log.GetRecentEntries(), x => x.Message.Contains("deadline reached")); + } + } + finally + { + release.TrySetResult(); + await server.HttpServerContext.Lifetime.StopAsync(TestContext.Current.CancellationToken); + } + } + + /// + /// Ensures a request that ignores cancellation cannot keep a container draining indefinitely. + /// + /// A task that completes after the transport is aborted and the handler is released. + [Fact] + public async Task Kestrel_DeadlineAbortsBlockedRequest() + { + // arrange + var server = CreateServer(ShutdownMode.Graceful); + server.Settings.ShutdownTimeoutSeconds = 1; + using var hub = UnitTestFixture.CreateAndRegisterComponentHubMock(server.HttpServerContext); + hub.SitemapManager.Refresh(); + using var release = new ManualResetEventSlim(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var returned = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + TestRestApiB.BeforeGet = () => + { + entered.TrySetResult(); + release.Wait(TimeSpan.FromSeconds(10)); + returned.TrySetResult(); + }; + + try + { + Assert.True(server.Start()); + using var client = CreateClient(server); + var request = client.GetAsync("/server/appa/api/2/testrestapib", TestContext.Current.CancellationToken); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + // act + await server.StopAsync(TestContext.Current.CancellationToken) + .WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + // validation + Assert.False(returned.Task.IsCompleted); + await Assert.ThrowsAsync(() => request); + Assert.Contains(server.HttpServerContext.Log.GetRecentEntries(), x => x.Message.Contains("deadline reached")); + } + finally + { + release.Set(); + TestRestApiB.BeforeGet = null; + await server.StopAsync(TestContext.Current.CancellationToken); + if (entered.Task.IsCompleted) + { + await returned.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + } + } + } + + /// + /// Keeps failed workers and stop callbacks from skipping another worker's drain. + /// + /// A task that completes after every admitted worker has returned. + [Fact] + public async Task Lifetime_FailureDoesNotSkipDrain() + { + // arrange + var context = UnitTestFixture.CreateHttpServerContextMock(); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = context.Lifetime.Stopping.Register(() => throw new InvalidOperationException("callback failure")); + context.Lifetime.TryRun(() => throw new InvalidOperationException("worker failure")); + context.Lifetime.TryRun(_ => release.Task); + + // act + var drain = context.Lifetime.StopAsync(TestContext.Current.CancellationToken); + + // validation + Assert.True(context.Lifetime.Stopping.IsCancellationRequested); + Assert.False(drain.IsCompleted); + release.SetResult(); + await drain.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + Assert.Contains(context.Log.GetRecentEntries(), x => x.Message.Contains("worker failure")); + Assert.Contains(context.Log.GetRecentEntries(), x => x.Message.Contains("callback failure")); + } + + /// + /// Ensures idle upgraded connections receive a close frame without consuming the drain budget. + /// + /// A task that completes after a real WebSocket receives the shutdown close frame. + [Fact] + public async Task Kestrel_ShutdownClosesIdleWebSocket() + { + // arrange + var server = CreateServer(ShutdownMode.Graceful); + using var hub = UnitTestFixture.CreateAndRegisterComponentHubMock(server.HttpServerContext); + var application = hub.ApplicationManager.GetApplications(typeof(TestApplicationA)).First(); + var socket = (SocketContext)hub.SocketManager.GetSockets(application).First(); + socket.SupportedSubProtocol = "wxmsg"; + hub.SitemapManager.Refresh(); + using var client = new ClientWebSocket(); + client.Options.AddSubProtocol("wxmsg"); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(5)); + + try + { + Assert.True(server.Start()); + using var http = CreateClient(server); + var target = new UriBuilder(http.BaseAddress) { Scheme = "ws", Path = socket.Route.ToString() }.Uri; + await client.ConnectAsync(target, timeout.Token); + + // act + var shutdown = server.StopAsync(timeout.Token); + var result = await client.ReceiveAsync(new ArraySegment(new byte[128]), timeout.Token); + await shutdown.WaitAsync(timeout.Token); + + // validation + Assert.Equal(WebSocketMessageType.Close, result.MessageType); + Assert.Equal(WebSocketCloseStatus.NormalClosure, result.CloseStatus); + Assert.Equal("server shutdown", result.CloseStatusDescription); + } + finally + { + client.Abort(); + await server.StopAsync(TestContext.Current.CancellationToken); + } + } + + /// + /// Exercises the scheduler and task manager instead of only the shared lifetime abstraction. + /// + /// A task that completes once admitted jobs and ad-hoc tasks finish. + [Fact] + public async Task Managers_DrainScheduledAndAdHocWorkBeforeDisposal() + { + // arrange + var context = UnitTestFixture.CreateHttpServerContextMock(); + using var hub = UnitTestFixture.CreateComponentHubMock(context); + var manager = (JobManager)hub.JobManager; + using var release = new ManualResetEventSlim(); + var jobEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var taskEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var job = new BlockingJob(jobEntered, release); + var item = new ScheduleItem(hub, context, null, null, new JobContext { Cron = new Cron(context) }, typeof(Job)) + { + Instance = job + }; + var flags = BindingFlags.Instance | BindingFlags.NonPublic; + var schedules = (List)typeof(JobManager).GetField("_dynamicScheduleList", flags).GetValue(manager); + schedules.Add(item); + var clock = (Clock)typeof(JobManager).GetField("_clock", flags).GetValue(manager); + var previousMinute = DateTime.Now.AddMinutes(-1); + typeof(Clock).GetField("_dateTime", flags).SetValue(clock, + new DateTime(previousMinute.Year, previousMinute.Month, previousMinute.Day, previousMinute.Hour, previousMinute.Minute, 0)); + var task = hub.TaskManager.CreateTask("shutdown-test", (_, _) => + { + taskEntered.TrySetResult(); + release.Wait(TimeSpan.FromSeconds(10)); + }); + + try + { + manager.Execute(); + task.Run(); + await jobEntered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + await taskEntered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + // act + var drain = context.Lifetime.StopAsync(TestContext.Current.CancellationToken); + + // validation + Assert.False(drain.IsCompleted); + Assert.Equal(0, job.Disposals); + release.Set(); + await drain.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + hub.Dispose(); + hub.Dispose(); + Assert.Equal(1, job.Calls); + Assert.Equal(1, job.Disposals); + } + finally + { + release.Set(); + await context.Lifetime.StopAsync(TestContext.Current.CancellationToken); + } + } + + /// + /// Ensures the real host returns once, drains work, and continues after an exit callback fails. + /// + /// A task that completes after the host execution thread has exited normally. + [Fact] + public async Task Execution_RequestShutdownReturnsAndReleasesComponentsOnce() + { + // arrange + var directory = Path.Combine(Path.GetTempPath(), "webexpress-shutdown-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + var settingsFile = Path.Combine(directory, "webexpress.settings.json"); + File.WriteAllText(settingsFile, JsonSerializer.Serialize(new + { + WebExpress = new + { + Shutdown = "graceful", + ShutdownTimeoutSeconds = 5, + Endpoints = new[] { new { Uri = "http://127.0.0.1:0/" } }, + PackagePath = Path.Combine(directory, "packages"), + AssetPath = Path.Combine(directory, "assets"), + DataPath = Path.Combine(directory, "data"), + Culture = "en-US" + } + })); + var app = new WebEx(); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var workerFinished = false; + var exits = 0; + var disposed = 0; + app.Start += (_, _) => + { + var hub = (ComponentHub)WebEx.ComponentHub; + var dictionary = (ComponentDictionary)typeof(ComponentHub) + .GetField("_dictionary", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(hub); + dictionary[new WebPlugin.PluginContext()] = [new ComponentItem + { + ComponentInstance = new CleanupProbe(() => + { + Assert.True(workerFinished); + disposed++; + }) + }]; + var serverContext = (IHttpServerContext)typeof(ComponentHub) + .GetField("_httpServerContext", BindingFlags.Instance | BindingFlags.NonPublic).GetValue(hub); + serverContext.Lifetime.TryRun(async stopping => + { + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, stopping); + } + catch (OperationCanceledException) + { + workerFinished = true; + } + }); + started.SetResult(); + }; + app.Exit += (_, _) => throw new InvalidOperationException("exit callback failure"); + app.Exit += (_, _) => exits++; + var execution = Task.Run(() => app.Execution(["-config", settingsFile]), TestContext.Current.CancellationToken); + + try + { + await started.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + + // act + app.RequestShutdown(); + app.RequestShutdown(); + var code = await execution.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + + // validation + Assert.Equal(0, code); + Assert.True(workerFinished); + Assert.Equal(1, exits); + Assert.Equal(1, disposed); + } + finally + { + app.RequestShutdown(); + await execution.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + Directory.Delete(directory, true); + } + } + + /// + /// Records release ordering without adding a discoverable production component. + /// + /// The ordering assertion executed during cleanup. + private sealed class CleanupProbe(Action dispose) : IComponentManager + { + /// + /// Runs the release assertion after admitted work has completed. + /// + public void Dispose() + { + dispose(); + } + } + + /// + /// Models a transaction that must return before its owning job is disposed. + /// + /// The signal raised after execution begins. + /// The signal allowing the transaction to finish. + private sealed class BlockingJob(TaskCompletionSource entered, ManualResetEventSlim release) : Job + { + /// + /// Gets the number of executions to detect duplicate dynamic scheduling. + /// + public int Calls { get; private set; } + + /// + /// Gets the number of releases to detect duplicate component cleanup. + /// + public int Disposals { get; private set; } + + /// + /// Keeps a transaction active until the test permits it to finish. + /// + public override void Process() + { + Calls++; + entered.TrySetResult(); + release.Wait(TimeSpan.FromSeconds(10)); + } + + /// + /// Records the release of resources owned by the completed transaction. + /// + public override void Dispose() + { + Disposals++; + } + } + + /// + /// Creates an isolated listener with a bounded shutdown policy. + /// + /// The shutdown policy under test. + /// A server that binds an ephemeral loopback port when started. + private static HttpServer CreateServer(ShutdownMode mode) + { + return new HttpServer(UnitTestFixture.CreateHttpServerContextMock()) + { + Settings = new HttpServerSettings + { + Endpoints = [new EndpointSettings { Uri = "http://127.0.0.1:0/" }], + Shutdown = mode, + ShutdownTimeoutSeconds = 10 + } + }; + } + + /// + /// Connects to the actual ephemeral listener rather than guessing an available port. + /// + /// The server whose listener has started. + /// A client with a bounded request timeout. + private static HttpClient CreateClient(HttpServer server) + { + var kestrel = (IServer)typeof(HttpServer).GetProperty("Kestrel", BindingFlags.Instance | BindingFlags.NonPublic) + .GetValue(server); + return new HttpClient + { + BaseAddress = new Uri(kestrel.Features.Get().Addresses.Single()), + Timeout = TimeSpan.FromSeconds(5) + }; + } + } +} diff --git a/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs b/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs index f2c9edc3..43c4ff8b 100644 --- a/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs +++ b/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs @@ -1,4 +1,9 @@ -using WebExpress.WebCore.Test.Fixture; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Primitives; +using WebExpress.WebCore.Test.Data; +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebSetting; namespace WebExpress.WebCore.Test.Server { @@ -8,6 +13,287 @@ namespace WebExpress.WebCore.Test.Server [Collection("NonParallelTests")] public class UnitTestHttpServer { + // a policy-free endpoint, so the request is answered by its handler and nothing + // in between - the access check, the login prompt - decides the outcome + private const string Endpoint = "/server/appa/api/2/testrestapib"; + + /// + /// Preserves the configured public URI when the server creates its shared context. + /// + [Fact] + public void ExternalUriIsAvailableFromServerContext() + { + // arrange + const string externalUri = "https://www.example.com/"; + var server = new HttpServer(UnitTestFixture.CreateHttpServerContextMock(externalUri: externalUri)); + + // validation + Assert.Equal(externalUri, server.HttpServerContext.ExternalUri); + } + + /// + /// Prevents a missing HTTPS certificate from silently registering an unencrypted listener. + /// + /// The configured HTTPS spelling normalized by the URI parser. + [Theory] + [InlineData("https")] + [InlineData("HTTPS")] + public void HttpsWithoutCertificateNeverRegistersPlainHttpListener(string scheme) + { + var server = new HttpServer(UnitTestFixture.CreateHttpServerContextMock()); + var options = new Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions(); + var wrapper = new Microsoft.Extensions.Options.OptionsWrapper(options); + var endpoint = new EndpointSettings + { + Uri = $"{scheme}://localhost:5001/", + PfxFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".pfx") + }; + var flags = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic; + var addEndpoint = typeof(HttpServer).GetMethod("AddEndpoint", flags, null, + [wrapper.GetType(), typeof(EndpointSettings), typeof(Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols?)], null); + + Assert.Throws(() => addEndpoint.Invoke(server, [wrapper, endpoint, null])); + + var listeners = typeof(Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions) + .GetProperty("CodeBackedListenOptions", flags).GetValue(options); + Assert.Empty((System.Collections.IEnumerable)listeners); + } + + /// + /// An endpoint another process already holds - a second instance, say - ends the start + /// with a result instead of an exception, so the host can log it and exit cleanly. + /// + [Fact] + public void Start_EndpointInUse_ReturnsFalseInsteadOfThrowing() + { + // arrange + using var occupant = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + occupant.Start(); + var port = ((System.Net.IPEndPoint)occupant.LocalEndpoint).Port; + var server = new HttpServer(UnitTestFixture.CreateHttpServerContextMock()) + { + Settings = new HttpServerSettings { Endpoints = [new() { Uri = $"http://127.0.0.1:{port}" }] } + }; + + var errors = server.HttpServerContext.Log.ErrorCount; + + try + { + // act + var started = true; + var exception = Record.Exception(() => started = server.Start()); + + // validation + Assert.Null(exception); + Assert.False(started); + Assert.Equal(errors + 1, server.HttpServerContext.Log.ErrorCount); + Assert.Empty(server.ListeningEndpoints); + + // act - the port is still taken, so a retry fails the same way instead of on a disposed part + exception = Record.Exception(() => started = server.Start()); + + // validation + Assert.Null(exception); + Assert.False(started); + Assert.Empty(server.ListeningEndpoints); + } + finally + { + server.Stop(); + } + } + + /// + /// Preserves normal and missing-route responses when startup constructs the server before its component hub. + /// + /// The application route whose response must survive the production startup order. + /// The expected response status after the component hub becomes available. + /// A task that completes after the HTTP response has been verified. + [Theory] + [InlineData(Endpoint, 400)] + [InlineData("/server/appa/no/such/route", 404)] + public async Task ProcessRequestAsync_ServerCreatedBeforeHub_PreservesResponse(string path, int status) + { + var hubField = typeof(WebEx).GetField("_componentHub", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); + var previousHub = WebEx.ComponentHub; + WebComponent.ComponentHub componentHub = null; + try + { + hubField.SetValue(null, null); + var server = new HttpServer(UnitTestFixture.CreateHttpServerContextMock()); + componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + componentHub.SitemapManager.Refresh(); + var context = UnitTestFixture.CreateHttpContextMock($"GET {path} HTTP/1.1\r\nCookie:\r\n\r\n"); + var response = new HttpResponseFeature(); + using var output = new MemoryStream(); + context.Features.Set(response); + context.Features.Set(new StreamResponseBodyFeature(output)); + + await server.ProcessRequestAsync(context); + + Assert.Equal(status, response.StatusCode); + Assert.Null(((WebMessage.RequestBase)context.Request).ExistingSession); + Assert.DoesNotContain("session=", response.Headers.SetCookie.ToString()); + } + finally + { + componentHub?.IdentityProviderManager.Dispose(); + hubField.SetValue(null, previousHub); + } + } + + /// + /// A bad request is the caller's to fix, so its message is shown - encoded, since it + /// commonly quotes what the caller sent - while the internals behind it follow the same + /// switch as any other error page. A rejection that was never thrown carries no stack trace + /// and must still produce a page. + /// + /// The configured switch. + [Theory] + [InlineData(false)] + [InlineData(true)] + public void BadRequest_EncodesTheMessage(bool detailedErrors) + { + var hubField = typeof(WebEx).GetField("_componentHub", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); + var previousHub = WebEx.ComponentHub; + var server = new HttpServer(UnitTestFixture.CreateHttpServerContextMock()) + { + Settings = new HttpServerSettings { Security = new SecuritySettings { DetailedErrors = detailedErrors } } + }; + var create = typeof(HttpServer).GetMethod("CreateBadRequestResponse", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); + var rejection = new WebMessage.BadRequestException("unknown value "); + + try + { + // without a hub the built-in page renders the message as given, so nothing else encodes it + hubField.SetValue(null, null); + + var response = (WebMessage.IResponse)create.Invoke(server, [rejection, null, null]); + var content = response.Content as string; + + Assert.Equal(400, response.Status); + Assert.Contains("unknown value <script>", content); + Assert.DoesNotContain(" & Anwendung", + Route = new RouteEndpoint("server/app") + }; + + // act + var response = RootEndpoint.Handle(request, new RouteEndpoint("server"), [application], new RootSettings { RedirectEnabled = false }); + + // validation + var html = response.Content.ToString(); + Assert.Contains("lang=\"de\"", html); + Assert.Contains("Anwendungen", html); + Assert.Contains("<script>", html); + Assert.Contains("& Anwendung", html); + Assert.DoesNotContain("