Skip to content

Testing & Publishing

This page covers how Callora tests itself (and how you test a plugin), the PublicAPI baseline workflow that keeps the public surface honest, and how to publish a signed plugin.

Testing

Callora tests with xUnit on .NET 10. The Engineering Rules require tests to assert behavior, not merely touch code paths, and every functional change to ship with tests (TDD-oriented). There are three test projects, split by what they need:

ProjectPurpose
tests/Callora.Core.TestsHost + plugin behavior, including slow DB integration tests
tests/Callora.Analyzers.TestsThe CAL0001CAL0004 analyzers, against in-memory compilations
tests/TestPlugins/ExportingPluginA real loadable plugin used as a runtime fixture

Run the suite:

bash
dotnet test

Fast unit tests vs. slow integration tests

Most tests are in-memory and fast. Integration tests that need a real Postgres use Testcontainers.PostgreSql and are marked so they can be filtered and skipped:

csharp
[Trait("Category", "Slow")]
public sealed class BackgroundJobFencingIntegrationTests : IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres =
        new PostgreSqlBuilder("postgres:16-alpine").Build();
    private bool _started;

    public async Task InitializeAsync()
    {
        try { await _postgres.StartAsync(); _started = true; }
        catch (Exception) { _started = false; }   // no Docker → skip, don't fail
    }

    [SkippableFact]
    public async Task Reclaimed_lease_rejects_the_stale_worker_write()
    {
        Skip.IfNot(_started, "Docker/Postgres container not available.");
        // …
    }
}

Two conventions make this robust:

  • [Trait("Category", "Slow")] tags the slow tests so CI (or you) can run only the fast set with dotnet test --filter "Category!=Slow".
  • [SkippableFact] + Skip.IfNot(...) (from Xunit.SkippableFact) turns "no Docker available" into a skipped test, not a failure — the container spins up in InitializeAsync inside a try/catch that records whether it started.

Apply the same pattern to a plugin whose tests need its plugin_<id> schema: start a Postgres container, run your MigrateAsync, exercise the entities, and gate on Skip.IfNot.

The analyzer test project

Callora.Analyzers.Tests does not wire the analyzer as a build analyzer. It references Callora.Analyzers as a normal library and drives it against in-memory C# compilations through AnalyzerTestHarness, which builds a CSharpCompilation and toggles the CalloraFrameworkAssembly MSBuild property to test both the framework-exempt and the plugin-enforced sides of CAL0001CAL0004. If you ship a governance analyzer of your own, this is the pattern to copy.

The PublicAPI baseline workflow

Every framework assembly runs Microsoft.CodeAnalysis.PublicApiAnalyzers against a tracked baseline, so the public surface cannot drift silently (Architecture). Two files sit next to each project:

  • PublicAPI.Shipped.txt — the surface released in a shipped version. Pre-1.0 this is essentially empty.
  • PublicAPI.Unshipped.txt — everything public added since the last release.

Two diagnostics enforce it, and because TreatWarningsAsErrors is on they fail the build:

IDFires when
RS0016A public symbol exists that is not recorded in either baseline file
RS0017A baseline file lists a symbol that no longer exists

The loop when you change a public signature

  1. Add or change a public type/member.
  2. Build — RS0016 (or RS0017) fails with the exact declaration line it expects.
  3. Add that line to PublicAPI.Unshipped.txt (the analyzer offers a code fix, "Add to public API", that does this for you).
  4. Build passes. At release, the shipped surface moves from Unshipped to Shipped.

For a plugin, the same discipline applies to any abstraction package you publish for other plugins to build against (e.g. Callora.Plugin.Communication.Abstractions): give it its own PublicAPI.*.txt baseline so its consumers get a stable, tracked contract. Combined with the CAL0003 XML-documentation requirement on the contract surface, this is the .NET equivalent of Shopware's BC-checker plus enforced API docs.

Publishing a plugin

Publishing is: build, sign, place, and let the host trust and load it.

1. Build

Build your plugin assembly and, if it has a surface UI, the IIFE bundles (via the calloraSurfacePlugin Vite preset — only Resources/public/<surface> is published). Ensure registry.json is complete: identity, contractVersion, capabilities, dependencies, and the compliance metadata.

2. Sign — the content manifest

Callora is trusted-in-process by provenance (ADR-013); a plugin is trusted because of who signed it. Produce a signed content manifest with the CLI (the plugin sign command):

bash
callora plugin sign \
  --plugin ./custom/plugins/MyPlugin \
  --key    ./keys/publisher-private.pem

This writes plugin.signature.json — a SHA-256 hash of every packaged file, plus the signer's public-key fingerprint, over an ECDSA-P256/SHA-256 signature. Keep the private key outside the plugin directory.

3. Trust the signer on the host

The host trusts a plugin when the signer's public-key fingerprint is in its trust store (ConfiguredPluginSignatureTrustStore), configured under backendHost in appsettings.json:

json
{
  "backendHost": {
    "trustedSigners": [
      {
        "publisherId": "my-company",
        "displayName": "My Company",
        "publicKey": "-----BEGIN PUBLIC KEY-----\n\n-----END PUBLIC KEY-----"
      }
    ],
    "allowUnsignedPlugins": false,
    "revokedSignerFingerprints": [],
    "revokedContentHashes": []
  }
}

On install, ManifestSignaturePluginPackageVerifier recomputes every file hash, rejects any un-manifested (injected) file, checks revocation, resolves the signer's public key by fingerprint, and verifies the ECDSA signature. An unsigned plugin is rejected unless allowUnsignedPlugins is explicitly true. You can re-verify all installed plugins at GET /api/plugins/signature-report.

4. Install and activate

Place the plugin under a discovered root (custom/plugins or custom/static-plugins) — or install it live through the operator API — and activate it. Both happen without a host restart (Install & Activate):

http
POST /api/plugins/install            # PluginCreate
POST /api/plugins/{pluginId}/activate    # PluginExecute

Once active, its listeners, decorators, routes, and views take effect on the next event, call, request, and render respectively.

Marketplace / paid distribution: the curated marketplace and paid-publisher gating are designed but not built. Today's distribution is curated/self-hosted with the signing and trust-store model above.

Status: planned — marketplace publishing pipeline and community-signed consent flow.