diff --git a/client/Cargo.toml b/client/Cargo.toml index 0e1058a..0cef51f 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -61,6 +61,7 @@ windows-sys = { version = "0.52", features = [ "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", + "Win32_UI_Input_KeyboardAndMouse", ] } windows = { version = "0.61", features = [ "Win32_Media_Audio", diff --git a/client/android/app/src/androidTest/java/cloud/betterportal/frame/ViewerSessionTest.kt b/client/android/app/src/androidTest/java/cloud/betterportal/frame/ViewerSessionTest.kt index 08cdcba..76fcb54 100644 --- a/client/android/app/src/androidTest/java/cloud/betterportal/frame/ViewerSessionTest.kt +++ b/client/android/app/src/androidTest/java/cloud/betterportal/frame/ViewerSessionTest.kt @@ -18,12 +18,18 @@ import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.CountDownLatch import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference @RunWith(AndroidJUnit4::class) class ViewerSessionTest { - @Test fun secureEnrollmentDisplaysHtmlAndClearsUnassignedCache() { + @Test fun secureEnrollmentDisplaysHtmlAndClearsUnassignedCache() = exerciseEnrollment(false) + @Test fun demoUsesNormalPlaybackAndExitsWithoutServerDeletion() = exerciseEnrollment(true) + + @Test fun demoRetriesAfterTemporaryEnrollmentFailure() = exerciseEnrollment(true, true) + + private fun exerciseEnrollment(demo: Boolean, retryDemo: Boolean = false) { val instrumentation = InstrumentationRegistry.getInstrumentation() val context = instrumentation.targetContext val directory = File(context.cacheDir, "session-test-${System.nanoTime()}").apply { mkdirs() } @@ -31,6 +37,9 @@ class ViewerSessionTest { override fun getNoBackupFilesDir(): File = directory override fun getApplicationContext(): Context = this } + val demoRequested = AtomicBoolean(false) + val demoConfirmed = AtomicBoolean(false) + val demoAttempts = AtomicInteger() val unassigned = AtomicBoolean(false) val profile = AtomicReference(null) val profileRejected = LinkedBlockingQueue() @@ -64,12 +73,23 @@ class ViewerSessionTest { val body = JSONObject(request.body.readUtf8()) check(body.getBoolean("secure_claim")) { "Secure enrollment was not requested" } check(body.getJSONArray("capabilities").toString().contains("android-viewer")) { "Android capabilities missing" } - json("""{"code":"ABCD12","polling_secret":"test-poll-secret","poll_after_ms":1000}""") + json("""{"code":"ABCD12","polling_secret":"test-poll-secret","poll_after_ms":1000,"allowDemo":$demo}""") + } + "/api/pair/demo" -> { + check(demo) + val body = JSONObject(request.body.readUtf8()) + check(body.getString("code") == "ABCD12" && body.getString("polling_secret") == "test-poll-secret") + if (demoAttempts.incrementAndGet() == 1 && retryDemo) json("{}", 503) + else { + demoConfirmed.set(true) + json("{}") + } } "/api/pair/claim", "/api/pair/ack" -> { val body = JSONObject(request.body.readUtf8()) check(body.getString("code") == "ABCD12" && body.getString("polling_secret") == "test-poll-secret") { "Enrollment polling secret was not preserved" } - if (path.endsWith("claim")) json("""{"status":"claimed","kiosk_id":"1","kiosk_key":"test-device-key","encrypt_key":"0000000000000000000000000000000000000000000000000000000000000000"}""") + if (path.endsWith("claim") && demo && !demoConfirmed.get()) json("""{"status":"pending"}""", 202) + else if (path.endsWith("claim")) json("""{"status":"claimed","demo":$demo,"kiosk_id":"1","kiosk_key":"test-device-key","encrypt_key":"0000000000000000000000000000000000000000000000000000000000000000"}""") else { // The secure device identity must already be durable before acknowledging. check(ProtectedStore(isolated).read().getJSONObject("identity").getString("kiosk_key") == "test-device-key") { "Acknowledged before saving identity" } @@ -109,9 +129,16 @@ class ViewerSessionTest { session.set(ViewerSession(isolated, object : ViewerSession.Listener { override fun onStatus(message: String) { statuses.add(message) + if (retryDemo && message.startsWith("Demo enrollment failed")) { + if (!session.get().allowDemo || !ProtectedStore(isolated).read().getJSONObject("pending").optBoolean("allowDemo")) { + failures.add("Transient failure disabled the pending demo session") + } + session.get().enterDemo() + } if (message.contains("connection unavailable")) offlineRetained.countDown() } override fun onPairing(code: String) { + if (code.isNotBlank() && demo && session.get().allowDemo && demoRequested.compareAndSet(false, true)) session.get().enterDemo() if (code.isBlank() && clearing.get()) enrollmentCleared.countDown() } override fun onPlan(plan: JSONObject) { @@ -146,6 +173,9 @@ class ViewerSessionTest { assertTrue(ProtectedStore(isolated).read().has("bundle")) assertEquals("android-viewer-v1", ProtectedStore(isolated).read().getString("bundle_profile")) assertTrue(requests.contains("/api/pair/ack")) + assertEquals(demo, session.get().isDemo) + assertEquals(demo, ProtectedStore(isolated).read().getJSONObject("identity").optBoolean("demo")) + assertEquals(if (retryDemo) 2 else if (demo) 1 else 0, requests.count { it == "/api/pair/demo" }) val fetchedBeforeDowngrade = requests.count { it == "/api/kiosk/bundle" } interruptHeartbeat.set(true) instrumentation.runOnMainSync { session.get().refresh() } @@ -169,6 +199,8 @@ class ViewerSessionTest { instrumentation.runOnMainSync { session.get().unpair() } assertTrue("Unpair did not finish browser/cache cleanup", enrollmentCleared.await(10, TimeUnit.SECONDS)) assertEquals(0, ProtectedStore(isolated).read().length()) + assertFalse(session.get().isDemo) + assertFalse(requests.any { it.contains("delete") || it.contains("unpair") }) } finally { instrumentation.runOnMainSync { session.get()?.close() } server.shutdown() diff --git a/client/android/app/src/main/java/cloud/betterportal/frame/MainActivity.kt b/client/android/app/src/main/java/cloud/betterportal/frame/MainActivity.kt index 6943338..8fbc42c 100644 --- a/client/android/app/src/main/java/cloud/betterportal/frame/MainActivity.kt +++ b/client/android/app/src/main/java/cloud/betterportal/frame/MainActivity.kt @@ -150,7 +150,25 @@ class MainActivity : Activity(), ViewerSession.Listener { private fun kioskRoot() = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) } + private fun updateDemoButton() { + val existing = root.findViewWithTag + +

Automatic Updates

diff --git a/server/tests/demo-postgres.test.ts b/server/tests/demo-postgres.test.ts new file mode 100644 index 0000000..06a2ced --- /dev/null +++ b/server/tests/demo-postgres.test.ts @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { randomBytes } from "node:crypto"; +import { PgAdapter } from "../src/shared/db/pg-adapter.js"; +import { initDb } from "../src/shared/db/init.js"; +import { prepareDemo, demoTenant, enrollDemo, cleanupDemo, demoAvailable, demoTenantHidden, visibleTenants } from "../src/shared/demo.js"; +import { initiatePairing, confirmPairing, claimPairing } from "../src/shared/pairing.js"; + +const url = process.env["BF_TEST_PG_URL"]; +const log = { info() {}, warn() {} }; +const secrets = { encryptString: (v: string) => `encrypted:${v}`, decryptString: (v: string) => v.slice(10) }; +const auth = { hashPassword: async (v: string) => `hash:${v}` }; + +test("demo enrollment, display defaults and bounded tenant-only cleanup", { skip: !url }, async () => { + const admin = new PgAdapter(url!); + const name = `bf_demo_${randomBytes(8).toString("hex")}`; + await admin.exec(`CREATE DATABASE "${name}"`); + const testUrl = new URL(url!); testUrl.pathname = `/${name}`; + const opened = await initDb({ url: testUrl.toString(), host: "", port: 5432, user: "", password: "", database: name, poolMax: 6 }, log); + const repo = opened.repo; + const initiate = () => initiatePairing(repo, { proposedName: "Demo kiosk", hardwareModel: null, capabilities: [], codeTtlSeconds: 600, secureClaim: true }); + try { + assert.equal(await prepareDemo(repo, false), null); + assert.equal(await demoTenant(repo), null); + const legacy = await repo.createTenant({ name: "Existing customer", slug: "demo" }); + assert.equal(await demoTenantHidden(repo, false, legacy), false); + assert.ok((await visibleTenants(repo, false)).some(t => t.id === legacy.id)); + await assert.rejects(prepareDemo(repo, true), /already in use/); + assert.equal(await demoAvailable(repo, true), false); + await repo.deleteTenant(legacy.id); + const [tenant, again] = await Promise.all([prepareDemo(repo, true), prepareDemo(repo, true)]); + assert.ok(tenant); assert.equal(again?.id, tenant.id); + assert.equal(await demoTenantHidden(repo, false, tenant), true); + assert.equal(await demoTenantHidden(repo, true, tenant), false); + assert.ok(!(await visibleTenants(repo, false)).some(t => t.id === tenant.id)); + assert.equal(await demoAvailable(repo, false), false); + assert.equal(await demoAvailable(repo, true), true); + await repo.updateTenant(tenant.id, { is_active: false }); + assert.equal(await demoAvailable(repo, true), false); + const disabled = await initiate(); + await assert.rejects(enrollDemo(repo, auth as never, secrets as never, true, disabled.code, disabled.pollingSecret), /unavailable/); + await repo.updateTenant(tenant.id, { is_active: true }); + assert.equal(await demoAvailable(repo, true), true); + const settings = await repo.adapter.withSearchPath(tenant.schema_name, () => repo.getDisplayDefaults()); + assert.equal(settings.layoutIds.length, 1); + assert.equal(settings.defaultLayoutId, settings.layoutIds[0]); + await repo.adapter.withSearchPath(tenant.schema_name, async () => { + await repo.updateLayout(settings.layoutIds[0]!, { name: "Operator edited" }); + }); + await prepareDemo(repo, true); + assert.equal(await repo.adapter.withSearchPath(tenant.schema_name, async () => (await repo.listLayouts())[0]?.name), "Operator edited"); + + const session = await initiate(); + const enroll = (enabled = true, secret = session.pollingSecret) => enrollDemo(repo, auth as never, secrets as never, enabled, session.code, secret); + await assert.rejects(enroll(false), /unavailable/); + await assert.rejects(enroll(true, "forged-secret"), /Invalid/); + await repo.updateTenant(tenant.id, { max_kiosks: 1 }); + assert.equal(await demoAvailable(repo, true), true); + await Promise.all([enroll(), enroll()]); + assert.equal(await demoAvailable(repo, true), false); + await enroll(); // A lost response can be retried even at capacity. + const overflow = await initiate(); + await assert.rejects(enrollDemo(repo, auth as never, secrets as never, true, overflow.code, overflow.pollingSecret), /capacity/); + await repo.updateTenant(tenant.id, { max_kiosks: null }); + assert.equal(await demoAvailable(repo, true), true); + const claim = await claimPairing(repo, session.code, secrets as never, undefined, session.pollingSecret); + assert.equal(claim.demo, true); assert.ok(claim.kioskId); + assert.equal((await claimPairing(repo, session.code, secrets as never)).status, "failed"); + await assert.rejects(confirmPairing(repo, auth as never, secrets as never, { code: session.code }), /already used/); + await repo.adapter.withSearchPath(tenant.schema_name, async () => { + assert.equal((await repo.listKiosks()).length, 1); + const display = (await repo.listDisplaysForKiosk(claim.kioskId!))[0]!; + assert.equal(display.active_layout_id, settings.defaultLayoutId); + assert.equal(display.default_layout_id, settings.defaultLayoutId); + assert.equal((await repo.listLayoutsForDisplay(display.id)).length, 1); + await repo.detachLayoutFromDisplay(display.id, settings.layoutIds[0]!); + const later = await repo.createDisplayForKiosk(claim.kioskId!, { name: "Second display" }); + assert.equal((await repo.listLayoutsForDisplay(later.id)).length, 1); + assert.equal((await repo.listLayoutsForDisplay(display.id)).length, 0); // No enforcement. + await repo.setSetupExtra("display_defaults", { layoutIds: ["deleted-id"], defaultLayoutId: "deleted-id" }); + const third = await repo.createDisplayForKiosk(claim.kioskId!, { name: "Third display" }); + assert.equal(third.default_layout_id, null); + await repo.setSetupExtra("display_defaults", settings); + }); + + // Ordinary tenants use exactly the same defaults and never enter demo cleanup. + const ordinary = await initiate(); + const normal = await confirmPairing(repo, auth as never, secrets as never, { code: ordinary.code }); + const normalLayout = await repo.createLayout({ name: "Marketing" }); + await repo.setSetupExtra("display_defaults", { layoutIds: [normalLayout.id], defaultLayoutId: normalLayout.id }); + const normalDisplay = await repo.createDisplayForKiosk(normal.kioskId, { name: "Marketing display" }); + assert.equal(normalDisplay.default_layout_id, normalLayout.id); + assert.equal((await claimPairing(repo, ordinary.code, secrets as never, undefined, ordinary.pollingSecret)).demo, false); + await assert.rejects(enrollDemo(repo, auth as never, secrets as never, true, ordinary.code, ordinary.pollingSecret), /already used/); + + const now = new Date("2030-01-02T12:00:00.000Z"); + const stamp = async (id: string, age: number, idle: number | null) => repo.adapter.withSearchPath(tenant.schema_name, () => + repo.adapter.run("UPDATE kiosks SET paired_at = ?, last_seen_at = ? WHERE id = ?", [new Date(+now - age).toISOString(), idle == null ? null : new Date(+now - idle).toISOString(), id])); + await stamp(claim.kioskId!, 299999, null); + assert.equal(await cleanupDemo(repo, now), 0); + await stamp(claim.kioskId!, 300000, null); + await repo.updateTenant(tenant.id, { max_kiosks: 1 }); + assert.equal(await demoAvailable(repo, true), false); + assert.equal(await cleanupDemo(repo, now), 1); + assert.equal(await demoAvailable(repo, true), true); + assert.equal((await claimPairing(repo, session.code, secrets as never, undefined, session.pollingSecret)).status, "revoked"); + assert.ok(await repo.getKioskById(normal.kioskId)); + await repo.adapter.withSearchPath(tenant.schema_name, async () => { + assert.equal((await repo.listDisplays()).length, 0); + assert.equal((await repo.listLayouts()).length, 1); + }); + const active = await initiate(); + await enrollDemo(repo, auth as never, secrets as never, true, active.code, active.pollingSecret); + const activeClaim = await claimPairing(repo, active.code, secrets as never, undefined, active.pollingSecret); + await stamp(activeClaim.kioskId!, 86399999, 0); + assert.equal(await cleanupDemo(repo, now), 0); + await stamp(activeClaim.kioskId!, 86400000, 0); + const counts = await Promise.all([cleanupDemo(repo, now), cleanupDemo(repo, now)]); + assert.equal(counts.reduce((a, b) => a + b, 0), 1); + assert.ok(await demoTenant(repo)); + await repo.deleteTenant(tenant.id); + assert.equal(await demoAvailable(repo, true), false); + } finally { + await opened.close(); + await admin.exec(`DROP DATABASE "${name}" WITH (FORCE)`); + await admin.close(); + } +}); diff --git a/server/tests/tenant-auth.test.ts b/server/tests/tenant-auth.test.ts index 6d2d263..832d97a 100644 --- a/server/tests/tenant-auth.test.ts +++ b/server/tests/tenant-auth.test.ts @@ -263,3 +263,35 @@ test("default-tenant queries use a scoped search path", async () => { assert.equal(await withDefaultTenant(repo as never, "tenant_site", async () => "ok"), "ok"); assert.deepEqual(calls, ["public"]); }); + +test("disabled demo hides only the managed tenant ID, including header and cookie selection", async () => { + const customer = { ...tenant, slug: "demo", name: "Existing customer" }; + let managedId: string | null = null; + const app = new H3(); + registerMiddleware(app, { + cookieName: "betterframe_session", + enableDemoTenant: false, + repo: { + adapter: { dialect: () => "postgres", withSearchPath: async (_schema: string, fn: () => unknown) => fn() }, + getTenantBySlug: async (slug: string) => slug === "demo" ? customer : defaultTenant, + getTenantById: async (id: string) => id === customer.id ? customer : null, + getSetupExtra: async () => managedId, + getUserByUsername: async () => admin, + isSetupComplete: async () => true, + }, + auth: { resolveSession: async () => ({ user: admin, session, tenant: defaultTenant }) }, + } as never); + app.get("/admin/demo-test", event => ({ tenantId: event.context.tenant?.id })); + for (const viaHeader of [false, true]) { + const headers: Record = { cookie: `betterframe_session=signed${viaHeader ? "" : "; bf_tenant=demo"}` }; + if (viaHeader) headers["x-betterframe-tenant"] = "demo"; + managedId = null; + const legacy = await app.request("http://betterframe.test/admin/demo-test", { headers }); + assert.equal(legacy.status, 200); + assert.deepEqual(await legacy.json(), { tenantId: customer.id }); + managedId = customer.id; + const managed = await app.request("http://betterframe.test/admin/demo-test", { headers }); + assert.equal(managed.status, 200); + assert.deepEqual(await managed.json(), { tenantId: defaultTenant.id }); + } +});