diff --git a/backend/test/auth-runtime-projection.test.ts b/backend/test/auth-runtime-projection.test.ts index 5a9a58f2..06809297 100644 --- a/backend/test/auth-runtime-projection.test.ts +++ b/backend/test/auth-runtime-projection.test.ts @@ -70,6 +70,7 @@ const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4"; const userId = "6ba7b810-9dad-4ed1-80b4-00c04fd430c8"; const roots: string[] = []; +const linuxTest = test.runIf(process.platform === "linux"); afterEach(() => { fsHook.path = undefined; @@ -276,7 +277,7 @@ function expectDenied(operation: () => unknown): void { } } -test("loads ready projection as one immutable auth and local-users snapshot", () => { +linuxTest("loads ready projection as one immutable auth and local-users snapshot", () => { const root = projectionRoot(); const fixture = localProjectionFixture("synthetic-user", passwordHash); const generation = writeReadyProjection(root, fixture); @@ -298,7 +299,7 @@ test("loads ready projection as one immutable auth and local-users snapshot", () ).toBe(true); }); -test("loads a complete OIDC projection without a users snapshot", () => { +linuxTest("loads a complete OIDC projection without a users snapshot", () => { const root = projectionRoot(); const generation = writeReadyOidcProjection(root); const loaded = createProjectedAuthenticationConfigProvider(root).current(); @@ -309,7 +310,7 @@ test("loads a complete OIDC projection without a users snapshot", () => { }); }); -test("loadConfig selects an immutable projected local provider and its in-memory registry", async () => { +linuxTest("loadConfig selects an immutable projected local provider and its in-memory registry", async () => { const root = projectionRoot(); writeReadyProjection(root, localProjectionFixture("projected-user", passwordHash)); @@ -323,7 +324,7 @@ test("loadConfig selects an immutable projected local provider and its in-memory expect(await registry?.findByUsername("PROJECTED-USER")).toMatchObject({ username: "projected-user" }); }); -test("loadConfig selects an immutable projected OIDC provider without direct-file fallback", () => { +linuxTest("loadConfig selects an immutable projected OIDC provider without direct-file fallback", () => { const root = projectionRoot(); writeReadyOidcProjection(root); @@ -338,7 +339,7 @@ test("loadConfig selects an immutable projected OIDC provider without direct-fil }); }); -test("rejects a trailing-slash runtime root", () => { +linuxTest("rejects a trailing-slash runtime root", () => { const root = projectionRoot(); writeReadyProjection( root, @@ -349,7 +350,7 @@ test("rejects a trailing-slash runtime root", () => { ); }); -test.each([ +linuxTest.each([ ["missing", undefined], [ "blocked", @@ -381,7 +382,7 @@ test.each([ ); }); -test.each([ +linuxTest.each([ "root traversal", "CURRENT symlink", "CURRENT hardlink", @@ -436,7 +437,7 @@ test.runIf(process.geteuid?.() === 0)( }, ); -test("rejects a foreign group with the correct owner", () => { +linuxTest("rejects a foreign group with the correct owner", () => { const root = projectionRoot(); writeReadyProjection( root, @@ -449,7 +450,7 @@ test("rejects a foreign group with the correct owner", () => { ); }); -test("enumerates closed namespaces without path-based readdirSync", () => { +linuxTest("enumerates closed namespaces without path-based readdirSync", () => { const root = projectionRoot(); const generation = writeReadyProjection( root, @@ -462,7 +463,7 @@ test("enumerates closed namespaces without path-based readdirSync", () => { ).toBe(generation); }); -test("rejects a symlinked runtime root", () => { +linuxTest("rejects a symlinked runtime root", () => { const root = projectionRoot(); writeReadyProjection( root, @@ -476,7 +477,7 @@ test("rejects a symlinked runtime root", () => { ); }); -test.each([ +linuxTest.each([ "root", "generations", "selected generation", @@ -514,7 +515,7 @@ test.each([ ); }); -test.each(["manifest", "generation", "size", "digest"])( +linuxTest.each(["manifest", "generation", "size", "digest"])( "rejects changed %s integrity data without secret disclosure", (kind) => { const root = projectionRoot(); @@ -542,7 +543,7 @@ test.each(["manifest", "generation", "size", "digest"])( }, ); -test("switches atomically to a later complete generation", () => { +linuxTest("switches atomically to a later complete generation", () => { const root = projectionRoot(); const first = writeReadyProjection( root, @@ -564,7 +565,7 @@ test("switches atomically to a later complete generation", () => { expect(provider.current().runtimeProjection?.generation).toBe(second); }); -test("retries once when CURRENT is atomically replaced between lstat and open", () => { +linuxTest("retries once when CURRENT is atomically replaced between lstat and open", () => { const root = projectionRoot(); const first = writeReadyProjection( root, @@ -591,7 +592,7 @@ test("retries once when CURRENT is atomically replaced between lstat and open", ).toBe(second); }); -test("retries once when CURRENT is replaced after the final identity read", () => { +linuxTest("retries once when CURRENT is replaced after the final identity read", () => { const root = projectionRoot(); const first = writeReadyProjection( root, @@ -628,7 +629,7 @@ test("retries once when CURRENT is replaced after the final identity read", () = expect(observations).toBe(3); }); -test("retries once when CURRENT is replaced between root descriptor and path observations", () => { +linuxTest("retries once when CURRENT is replaced between root descriptor and path observations", () => { const root = projectionRoot(); const first = writeReadyProjection( root, @@ -678,7 +679,7 @@ test("retries once when CURRENT is replaced between root descriptor and path obs expect(replacedCurrent).toBe(true); }); -test("rejects a second CURRENT replacement after the one permitted retry", () => { +linuxTest("rejects a second CURRENT replacement after the one permitted retry", () => { const root = projectionRoot(); const first = writeReadyProjection( root, @@ -717,7 +718,7 @@ test("rejects a second CURRENT replacement after the one permitted retry", () => ); }); -test("fails deterministically when generations is replaced during a load", () => { +linuxTest("fails deterministically when generations is replaced during a load", () => { const root = projectionRoot(); const generation = writeReadyProjection( root, @@ -748,7 +749,7 @@ test("fails deterministically when generations is replaced during a load", () => ); }); -test("fails deterministically when the selected generation directory is replaced during a load", () => { +linuxTest("fails deterministically when the selected generation directory is replaced during a load", () => { const root = projectionRoot(); const generation = writeReadyProjection( root, @@ -780,7 +781,7 @@ test("fails deterministically when the selected generation directory is replaced ); }); -test.each(["corrupt", "symlink"])( +linuxTest.each(["corrupt", "symlink"])( "rejects a %s retained predecessor generation", (kind) => { const root = projectionRoot(); @@ -819,7 +820,7 @@ test.each(["corrupt", "symlink"])( }, ); -test("has no direct-file fallback when CURRENT is absent", () => { +linuxTest("has no direct-file fallback when CURRENT is absent", () => { const root = projectionRoot(); const generation = writeReadyProjection( root, @@ -839,7 +840,7 @@ test("has no direct-file fallback when CURRENT is absent", () => { ); }); -test("in-flight snapshot authenticates A after selection B and deletion A, while a new load sees B", async () => { +linuxTest("in-flight snapshot authenticates A after selection B and deletion A, while a new load sees B", async () => { const root = projectionRoot(); const first = writeReadyProjection( root, diff --git a/backend/test/pi-management.test.ts b/backend/test/pi-management.test.ts index 452deae9..f0d79e8d 100644 --- a/backend/test/pi-management.test.ts +++ b/backend/test/pi-management.test.ts @@ -198,7 +198,10 @@ test("smoke uses the configured timeout and reports a sanitized timeout", async message: "Pi smoke check timed out", checkedAt: "2026-08-05T10:00:00.000Z", }); - expect(calls).toEqual([{ command: "/usr/local/bin/pi", args: ["--version"], timeout: 750 }]); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ command: "/usr/local/bin/pi", args: ["--version"] }); + expect(calls[0]!.timeout).toBeGreaterThan(0); + expect(calls[0]!.timeout).toBeLessThanOrEqual(750); }); // Catches a smoke endpoint that validates only the Pi binary/model catalogue and never makes a diff --git a/frontend/src/shell/AppShell.auth.test.tsx b/frontend/src/shell/AppShell.auth.test.tsx index 7617fd66..7146b812 100644 --- a/frontend/src/shell/AppShell.auth.test.tsx +++ b/frontend/src/shell/AppShell.auth.test.tsx @@ -65,7 +65,7 @@ describe("authenticated shell permissions", () => { expect(screen.getByRole("button", { name: "All sessions" })).toBeInTheDocument(); }); - test("hides identity and logout outside local authentication", async () => { + test("keeps identity but hides logout outside local authentication", async () => { let logoutCalls = 0; server.use(http.post("/api/auth/logout", () => { logoutCalls += 1; @@ -79,7 +79,7 @@ describe("authenticated shell permissions", () => { permissions: ["session.use"], }, false); - await waitFor(() => expect(screen.queryByText("portal-user")).not.toBeInTheDocument()); + expect(await screen.findByText("portal-user")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Log out" })).not.toBeInTheDocument(); expect(logoutCalls).toBe(0); }); diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index 388ee9e3..d7097cd8 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -716,14 +716,16 @@ export function AppShell({ canLogout }: AppShellProps) {
Human In The Loop

- {authenticatedUser && canLogout && ( + {authenticatedUser && (
{authenticatedUser.displayName ?? authenticatedUser.subject} - + {canLogout && ( + + )}
)} diff --git a/tools/tht/internal/authconfig/projection_transaction_test.go b/tools/tht/internal/authconfig/projection_transaction_test.go index fd082847..7c10d3bd 100644 --- a/tools/tht/internal/authconfig/projection_transaction_test.go +++ b/tools/tht/internal/authconfig/projection_transaction_test.go @@ -1,3 +1,5 @@ +//go:build linux + package authconfig import ( diff --git a/tools/tht/internal/backup/restore.go b/tools/tht/internal/backup/restore.go index db639f84..b96ffc6e 100644 --- a/tools/tht/internal/backup/restore.go +++ b/tools/tht/internal/backup/restore.go @@ -46,6 +46,7 @@ type restoreDependencies struct { checkpoint func(context.Context, *lifecycle.Transaction, config.Installation, CreateRequest) (Result, error) prepareRecovery func(context.Context, config.Installation, string) (PreflightResult, error) recover func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool, authProjectionRestoreTransaction) error + requireAuthProjection func() error beginAuthProjection func(context.Context, config.Installation) (authProjectionRestoreTransaction, error) cleanupCheckpoint func(string) error acquireTransaction func(config.Installation) (*lifecycle.Transaction, error) @@ -91,7 +92,7 @@ func restoreWithDependencies(ctx context.Context, installation config.Installati if request.Archive == "" { return RestoreResult{}, errors.New("restore archive is required") } - if deps.preflight == nil || deps.checkpoint == nil || deps.prepareRecovery == nil || deps.recover == nil || deps.cleanupCheckpoint == nil || deps.acquireTransaction == nil || deps.runner == nil || deps.restoreFile == nil || deps.restoreVolume == nil || deps.resetAuthenticationState == nil || deps.verify == nil { + if deps.preflight == nil || deps.checkpoint == nil || deps.prepareRecovery == nil || deps.recover == nil || deps.requireAuthProjection == nil || deps.cleanupCheckpoint == nil || deps.acquireTransaction == nil || deps.runner == nil || deps.restoreFile == nil || deps.restoreVolume == nil || deps.resetAuthenticationState == nil || deps.verify == nil { return RestoreResult{}, errors.New("restore dependencies are incomplete") } @@ -117,7 +118,7 @@ func restoreWithDependencies(ctx context.Context, installation config.Installati defer preflight.CloseArchive() authRestoreRequired := installation.HasRuntimeAuthProjection() && manifestArchivesAuthentication(preflight.Manifest) if authRestoreRequired { - if err := requireAuthProjectionRestorePrivilege(); err != nil { + if err := deps.requireAuthProjection(); err != nil { return result, err } } diff --git a/tools/tht/internal/backup/restore_host.go b/tools/tht/internal/backup/restore_host.go index 98436209..de262993 100644 --- a/tools/tht/internal/backup/restore_host.go +++ b/tools/tht/internal/backup/restore_host.go @@ -49,8 +49,9 @@ func productionRestoreDependencies(installation config.Installation) restoreDepe request.Output = path return createWithDependenciesTransaction(ctx, transaction, target, request, productionCreateDependencies(target)) }, - cleanupCheckpoint: cleanupRecoveryCheckpoint, - acquireTransaction: lifecycle.AcquireTransaction, + cleanupCheckpoint: cleanupRecoveryCheckpoint, + acquireTransaction: lifecycle.AcquireTransaction, + requireAuthProjection: requireAuthProjectionRestorePrivilege, beginAuthProjection: func(ctx context.Context, target config.Installation) (authProjectionRestoreTransaction, error) { projection := target.RuntimeAuthProjection() if projection == nil { diff --git a/tools/tht/internal/backup/restore_test.go b/tools/tht/internal/backup/restore_test.go index 8fd7e4eb..ff90ef48 100644 --- a/tools/tht/internal/backup/restore_test.go +++ b/tools/tht/internal/backup/restore_test.go @@ -2009,11 +2009,12 @@ func restoreTestDependencies(t *testing.T, runner archiveRunner) restoreDependen recover: func(context.Context, config.Installation, PreflightResult, *stagedArchive, bool, authProjectionRestoreTransaction) error { return nil }, - cleanupCheckpoint: func(string) error { return nil }, - acquireTransaction: lifecycle.AcquireTransaction, - runner: runner, - sleep: func(time.Duration) {}, - restoreFile: func(context.Context, config.Installation, ArchiveEntryMetadata, io.Reader) error { return nil }, + cleanupCheckpoint: func(string) error { return nil }, + acquireTransaction: lifecycle.AcquireTransaction, + requireAuthProjection: func() error { return nil }, + runner: runner, + sleep: func(time.Duration) {}, + restoreFile: func(context.Context, config.Installation, ArchiveEntryMetadata, io.Reader) error { return nil }, restoreVolume: func(context.Context, config.Installation, VolumeMetadata, io.Reader) error { return nil }, diff --git a/tools/tht/internal/setup/files_test.go b/tools/tht/internal/setup/files_test.go index ca024837..2f98bd68 100644 --- a/tools/tht/internal/setup/files_test.go +++ b/tools/tht/internal/setup/files_test.go @@ -208,6 +208,7 @@ func TestEnsureFilesRequiresExplicitNonInteractiveAnswers(t *testing.T) { } func TestEnsureFilesIncludesServerStorageLocations(t *testing.T) { + requireProjectedServerTestHost(t) root := newProject(t, "server profile") setNonInteractiveAnswers(t, newExternalSecrets(t, root)) result, err := EnsureFiles(Request{ProjectRoot: root, InstallationID: "server", Profile: "server", NonInteractive: true}, strings.NewReader(""), ioDiscard{}) @@ -225,6 +226,13 @@ func TestEnsureFilesIncludesServerStorageLocations(t *testing.T) { } } +func requireProjectedServerTestHost(t *testing.T) { + t.Helper() + if runtime.GOOS != "linux" || os.Geteuid() != 0 { + t.Skip("projected server filesystem integration requires Linux root") + } +} + func TestEnsureFilesProjectedServerRefusesBeforeAnyWriteWhenNotRoot(t *testing.T) { root := newProject(t, "projected server non-root") setNonInteractiveAnswers(t, newExternalSecrets(t, root)) diff --git a/tools/tht/internal/setup/run_test.go b/tools/tht/internal/setup/run_test.go index f7e083fa..0df79581 100644 --- a/tools/tht/internal/setup/run_test.go +++ b/tools/tht/internal/setup/run_test.go @@ -101,6 +101,7 @@ func TestRunConfiguresAndStaticallyValidatesLocalAuthBeforeComposeRender(t *test } func TestRunConfigureOnlyPublishesInitialProjectedServerAuthentication(t *testing.T) { + requireProjectedServerTestHost(t) projectRoot, request := setupRunFixture(t, true) request.Profile = "server" if err := os.WriteFile(filepath.Join(projectRoot, "deploy", "compose.auth-runtime-projection.yaml"), []byte("services: {}\n"), 0o600); err != nil { @@ -125,6 +126,7 @@ func TestRunConfigureOnlyPublishesInitialProjectedServerAuthentication(t *testin } func TestRunConfigureOnlyLeavesProjectedAuthenticationBlockedWhenInitialPublicationFails(t *testing.T) { + requireProjectedServerTestHost(t) projectRoot, request := setupRunFixture(t, true) request.Profile = "server" if err := os.WriteFile(filepath.Join(projectRoot, "deploy", "compose.auth-runtime-projection.yaml"), []byte("services: {}\n"), 0o600); err != nil { @@ -162,6 +164,7 @@ func TestRunConfigureOnlyLeavesProjectedAuthenticationBlockedWhenInitialPublicat } func TestRunConfigureOnlyVerifiesProjectedAuthenticationAfterPublication(t *testing.T) { + requireProjectedServerTestHost(t) projectRoot, request := setupRunFixture(t, true) request.Profile = "server" if err := os.WriteFile(filepath.Join(projectRoot, "deploy", "compose.auth-runtime-projection.yaml"), []byte("services: {}\n"), 0o600); err != nil {