tests: run the E2E suite against a standalone Francis runtime

Adds a matrix entry that starts a SQLite-backed Francis runtime next to
Pocket ID and points FRANCIS_HOST at it, so the same Playwright suite runs
with the actor state, alarms, and placement owned by the runtime instead of
embedded in Pocket ID.

The suite needs no changes to work in that topology: the E2E reset seeds
every actor through actors.Service() and deliberately leaves the actor
store alone, so it behaves the same whichever side owns it.

The CLI spec is the exception, since export and import are the two commands
whose behaviour genuinely differs. It now picks the right Compose file,
expects an export to carry no francis.bin, feeds the import an archive
without one, and gains a case asserting that an archive that does carry one
is refused.

The runtime is reached over the Compose network on its UDP port, so nothing
is published to the host, and the cluster CA is left unpinned, which
exercises the same trust-on-first-use path an operator gets without
FRANCIS_CA. Pinning is covered by a unit test instead.
This commit is contained in:
Alessandro (Ale) Segala
2026-08-31 05:27:04 +00:00
parent 2e48aaf0a4
commit 22cf4eab93
7 changed files with 243 additions and 8 deletions
@@ -3,11 +3,18 @@ package bootstrap
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"log/slog"
"math/big"
"os"
"path/filepath"
"testing"
"time"
francishost "github.com/italypaleale/francis/host"
"github.com/italypaleale/francis/host/local"
@@ -125,6 +132,30 @@ func TestNewActorsSelectsTopology(t *testing.T) {
}
})
// The E2E suite runs the remote variant without a pinned CA, so this is the only place the pinning branch is exercised
t.Run("pinning the cluster CA builds a valid remote host", func(t *testing.T) {
cfg := baseConfig(t)
cfg.FrancisAddresses = []string{"runtime-1.example.com:8443"}
cfg.FrancisHostPSK = []byte("bootstrap-psk-that-is-long-enough")
cfg.FrancisCA = testCAPEM(t)
opts := NewActorsOpts{EnvConfig: cfg, InstanceID: "ee05c3eb-8129-47a6-a1c7-849998b6f876"}
h, err := opts.newRemoteHost(slog.New(slog.DiscardHandler))
require.NoError(t, err)
require.NotNil(t, h)
})
t.Run("an unparsable cluster CA is rejected", func(t *testing.T) {
cfg := baseConfig(t)
cfg.FrancisAddresses = []string{"runtime-1.example.com:8443"}
cfg.FrancisHostPSK = []byte("bootstrap-psk-that-is-long-enough")
cfg.FrancisCA = []byte("-----BEGIN CERTIFICATE-----\nnot a certificate\n-----END CERTIFICATE-----")
opts := NewActorsOpts{EnvConfig: cfg, InstanceID: "ee05c3eb-8129-47a6-a1c7-849998b6f876"}
_, err := opts.newRemoteHost(slog.New(slog.DiscardHandler))
require.Error(t, err)
})
t.Run("no bootstrap method is rejected by Francis", func(t *testing.T) {
cfg := baseConfig(t)
cfg.FrancisAddresses = []string{"runtime-1.example.com:8443"}
@@ -197,3 +228,26 @@ func TestNewActorsBackupProvider(t *testing.T) {
err = provider.Restore(t.Context(), bytes.NewReader(buf.Bytes()))
require.NoError(t, err)
}
// testCAPEM returns a self-signed CA certificate in PEM form, standing in for the cluster CA an operator would pin with FRANCIS_CA
func testCAPEM(t *testing.T) []byte {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "test-cluster-ca"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
IsCA: true,
KeyUsage: x509.KeyUsageCertSign,
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, pub, priv)
require.NoError(t, err)
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
}