diff --git a/test/shell.d/plugin-auth-lifecycle-test.sh b/test/shell.d/plugin-auth-lifecycle-test.sh new file mode 100755 index 00000000..603649a5 --- /dev/null +++ b/test/shell.d/plugin-auth-lifecycle-test.sh @@ -0,0 +1,163 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +# Exercise the #9485/#9618 lifecycle integration on releases without the +# video-wallpaper services alias. These are production JavaScript functions +# with a synchronous Qt test double, not native QObject/PAM/Wayland coverage. +run_node_test <<'JS' +const fs = require('fs') +const vm = require('vm') +const shellSource = fs.readFileSync(path.join(root, 'shell/shell.qml'), 'utf8') +const registrySource = fs.readFileSync(path.join(root, 'shell/services/PluginRegistry.qml'), 'utf8') +const storeSource = fs.readFileSync(path.join(root, 'shell/services/AuthServiceStore.js'), 'utf8') + +function loadFunctions(source, names, context) { + for (const name of names) { + const match = source.match(new RegExp(`^ function ${name}\\([^\\n]*\\) \\{[\\s\\S]*?^ \\}`, 'm')) + if (!match) fail(`production function is present: ${name}`) + vm.runInContext(match[0], context) + } +} + +function fixture() { + const store = vm.createContext({}) + vm.runInContext(storeSource, store) + const registry = vm.createContext({ + Util: { isPlainObject: value => !!value && typeof value === 'object' && !Array.isArray(value) } + }) + loadFunctions(registrySource, ['trustedCapabilities', 'stampHostCapabilities'], registry) + const firstParty = {} + for (const [id, file] of Object.entries({ + 'omarchy.lock': 'lock/manifest.json', + 'omarchy.polkit': 'polkit/manifest.json', + 'omarchy.idle': 'services/idle/manifest.json' + })) { + firstParty[id] = { + ...JSON.parse(fs.readFileSync(path.join(root, 'shell/plugins', file), 'utf8')), + __isFirstParty: true + } + } + const thirdParty = {} + for (const id of ['acme.lock', 'acme.ordinary', 'acme.transient']) { + thirdParty[id] = { + id, kinds: ['service'], entryPoints: { service: 'Service.qml' }, + keepLoaded: id !== 'acme.transient', __isFirstParty: false, + __sourceDir: '/fixture/plugins/' + id, + // Third-party declarations must not grant authentication capabilities. + omarchy: id === 'acme.lock' + ? { clonedFrom: 'omarchy.lock' } : { capabilities: ['authentication'] }, + __hostCapabilities: ['forged'] + } + } + registry.stampHostCapabilities(firstParty, thirdParty) + registry.installedPlugins = { ...firstParty, ...thirdParty } + const disabled = new Set() + registry.isEnabled = id => !disabled.has(id) + registry.entryPointUrl = manifest => manifest.id + registry.resolveEnabledId = id => id === 'omarchy.media' ? 'acme.ordinary' : id + const created = [] + const host = {} + const context = vm.createContext({ + console, pluginRegistry: registry, AuthServiceStore: store, + _services: {}, serviceHost: host, omarchyPath: root, + Component: { Ready: 1, Loading: 2, PreferSynchronous: 3 }, + Qt: { + createComponent: id => ({ + status: 1, + createObject(parent) { + const instance = { + id, parent, manifest: null, shell: null, omarchyPath: '', + destroyCount: 0, marker: 'survives-rescan', + destroy() { this.destroyCount++ } + } + created.push(instance) + return instance + } + }) + }, + // Facade construction is covered separately by the native boundary suite. + pluginShellFor: manifest => ({ pluginId: manifest.id }) + }) + context.shell = context + loadFunctions(shellSource, [ + 'publicPluginManifest', 'serviceFor', 'firstPartyServiceFor', + 'pluginOwnsTarget', 'pluginServiceFor', 'isAuthenticationService', + 'ensureService', '_syncServices', 'serviceKeepLoaded', 'unloadPluginServices' + ], context) + return { context, registry, store, disabled, created, host } +} + +const f = fixture() +f.context._syncServices() +const initial = Object.fromEntries(f.created.map(instance => [instance.id, instance])) +for (const id of ['omarchy.lock', 'omarchy.polkit', 'acme.lock']) { + assert(f.store.has(id) && f.context.serviceFor(id) === null && initial[id].parent === null, + `${id} is retained privately and created without a parent`) + assert(f.context.ensureService(id) === null && initial[id].destroyCount === 0, + `${id} cannot be retrieved or recreated through ensureService`) +} +assert(initial['acme.ordinary'].parent === null && initial['omarchy.idle'].parent === f.host, + 'only trusted non-authentication services use the service host') +assertDeepEqual(f.registry.installedPlugins['acme.ordinary'].__hostCapabilities, [], + 'third-party capabilities cannot forge authentication classification') +assert(!('services' in f.context) + && f.context.firstPartyServiceFor('omarchy.media') === initial['acme.ordinary'] + && f.context.pluginServiceFor('acme.ordinary', 'omarchy.media') === initial['acme.ordinary'] + && f.context.pluginServiceFor('acme.ordinary', 'omarchy.lock') === null, + 'enabled-clone and own-service lookups work without a services alias') + +f.context.unloadPluginServices() +for (const [id, manifest] of Object.entries(f.registry.installedPlugins)) { + f.registry.installedPlugins[id] = { ...manifest, name: 'Refreshed ' + id } +} +f.context._syncServices() +for (const id of ['omarchy.lock', 'omarchy.polkit', 'acme.lock', 'omarchy.idle', 'acme.ordinary']) { + assert(initial[id].destroyCount === 0 && initial[id].marker === 'survives-rescan' + && f.created.filter(instance => instance.id === id).length === 1 + && initial[id].manifest.name === 'Refreshed ' + id, + `${id} keeps its original instance and receives the refreshed manifest`) +} +assert(initial['acme.transient'].destroyCount === 1 + && f.context.serviceFor('acme.transient') !== initial['acme.transient'], + 'ordinary services without keepLoaded are replaced on rescan') +assert(!('__hostCapabilities' in initial['acme.lock'].manifest) + && !('__sourceDir' in initial['acme.lock'].manifest), + 'kept authentication clones receive detached public manifests') +initial['acme.lock'].manifest.name = 'plugin-local edit' +assert(f.registry.installedPlugins['acme.lock'].name === 'Refreshed acme.lock', + 'mutating a kept clone manifest cannot change the registry') + +for (const removal of ['disable', 'remove', 'drop-kind', 'drop-entry-point']) { + const test = fixture() + test.context._syncServices() + const originals = [...test.created] + for (const id of Object.keys(test.registry.installedPlugins)) { + if (removal === 'disable') test.disabled.add(id) + else if (removal === 'remove') delete test.registry.installedPlugins[id] + else if (removal === 'drop-kind') test.registry.installedPlugins[id].kinds = ['panel'] + else test.registry.installedPlugins[id].entryPoints = {} + } + test.context._syncServices() + assert(originals.every(instance => instance.destroyCount === 1) + && test.store.ids().length === 0 && Object.keys(test.context._services).length === 0, + `${removal} removes both private authentication and public ordinary services`) +} + +const promotion = fixture() +promotion.context._syncServices() +const published = promotion.context.serviceFor('acme.ordinary') +promotion.registry.installedPlugins['acme.ordinary'].__hostCapabilities = ['authentication'] +promotion.context._syncServices() +assert(published.destroyCount === 1 && promotion.store.has('acme.ordinary') + && promotion.context.serviceFor('acme.ordinary') === null, + 'a service gaining host-stamped authentication is removed from the public map') +promotion.registry.installedPlugins['acme.ordinary'].__hostCapabilities = [] +promotion.registry.installedPlugins['acme.ordinary'].keepLoaded = false +promotion.context.unloadPluginServices() +promotion.context._syncServices() +assert(promotion.store.has('acme.ordinary') && promotion.context.serviceFor('acme.ordinary') === null, + 'authentication classification survives metadata mutation and instance recreation') +JS