fix: production onboarding, CI tests, container security, keyboard nav
Install & Onboarding:
- Remove DEV_MODE=true from production ISO service file (auto-created
users, skipped password setup)
- Auto-install no longer overwrites rootfs service file with bad template
- Login.vue always checks auth.isSetup — shows password creation form
on fresh install without requiring dev build flag
- Deploy image-versions.sh to /opt/archipelago/scripts/ on installed nodes
- First-boot-containers sources image-versions.sh, runs podman as
archipelago user (rootless), enables linger + podman.socket
- Correct volume ownership (100000:100000 for rootless UID mapping)
Container Security:
- FileBrowser: add --cap-add=DAC_OVERRIDE for rootless podman volume access
- FileBrowser: add --read-only, /data volume for database, proper cmd args
- First-boot script matches backend config (security hardening + health check)
CI Pipeline:
- Add vue-tsc type check + vitest run to build-iso.yml (runs every push)
- Add post-install-tests.yml workflow (workflow_dispatch, SSH to target)
- Build report: set +eo pipefail, fix rootfs path, add || true guards
- Bundle run-post-install-tests.sh into ISO
E2E Test Suite (scripts/run-post-install-tests.sh):
- Phase 1: Install verification (files, services, podman, linger, DEV_MODE check)
- Phase 2: Onboarding flow (auth.isSetup, auth.setup, login, DID, complete)
- Phase 3: Container lifecycle (install 3 apps via package.install RPC,
verify running, stop, verify stopped, restart, verify running, health)
- Phase 4: Log verification (first-boot log, diagnostics, journal errors)
- Correct package.install params: {"id", "dockerImage"}
Frontend:
- Fix backdrop-filter tab-switch bug (keep animations paused during rebuild)
- Dashboard glitch animations paused during tab-hidden
- Gamepad nav: auto-focus first container on route change
- Tab roving: Left/Right on role="tab" cycles and activates sibling tabs
- ContainerApps: data-controller-launch on running app cards
- 515 tests passing (fixed 30 broken, added 19 new keyboard nav tests)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
bf14f9e5ad
commit
7cd4d90ed8
@@ -27,6 +27,7 @@
|
||||
v-for="app in bundledApps"
|
||||
:key="app.id"
|
||||
data-controller-container
|
||||
:data-controller-launch="store.getAppState(app.id) === 'running' ? '' : undefined"
|
||||
tabindex="0"
|
||||
class="glass-card p-6 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
@@ -134,6 +135,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-controller-launch-btn
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 rounded text-sm font-medium text-white transition-colors flex items-center gap-2"
|
||||
@click="launchApp(app)"
|
||||
>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<!-- Desktop: tabs inline with header -->
|
||||
<div
|
||||
v-if="!uiMode.isChat"
|
||||
role="tablist"
|
||||
class="hidden md:flex mode-switcher flex-shrink-0 transition-opacity duration-500"
|
||||
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
|
||||
>
|
||||
@@ -48,8 +49,8 @@
|
||||
<template v-if="!uiMode.isChat">
|
||||
<!-- Mobile: full-width tabs -->
|
||||
<div
|
||||
class="md:hidden mode-switcher mb-6 w-full transition-opacity duration-500"
|
||||
role="tablist"
|
||||
class="md:hidden mode-switcher mb-6 w-full transition-opacity duration-500"
|
||||
:class="{ 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
|
||||
>
|
||||
<button class="mode-switcher-btn" role="tab" :aria-selected="homeTab === 'dashboard'" :class="{ 'mode-switcher-btn-active': homeTab === 'dashboard' }" @click="homeTab = 'dashboard'">{{ t('home.dashboardTab') }}</button>
|
||||
|
||||
@@ -244,10 +244,8 @@ const startupProgress = ref(0)
|
||||
let startupPollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let startupProgressInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// Check if we're in setup mode (original StartOS node setup)
|
||||
const isSetupMode = computed(() => {
|
||||
return import.meta.env.VITE_DEV_MODE === 'setup'
|
||||
})
|
||||
// Whether we're in setup mode (no password created yet)
|
||||
const isSetupMode = ref(false)
|
||||
|
||||
// Whether the login form should be disabled (server not ready)
|
||||
const formDisabled = computed(() => !serverReady.value)
|
||||
@@ -339,16 +337,14 @@ onMounted(async () => {
|
||||
await pollServerStartup()
|
||||
}
|
||||
|
||||
// Only check setup mode after server is confirmed ready
|
||||
if (isSetupMode.value) {
|
||||
try {
|
||||
const result = await rpcClient.call<boolean>({ method: 'auth.isSetup', params: {}, timeout: 8000 })
|
||||
isSetup.value = Boolean(result)
|
||||
} catch {
|
||||
isSetup.value = false
|
||||
}
|
||||
} else {
|
||||
isSetup.value = true
|
||||
// Check if password has been set up — show setup form if not
|
||||
try {
|
||||
const result = await rpcClient.call<boolean>({ method: 'auth.isSetup', params: {}, timeout: 8000 })
|
||||
isSetup.value = Boolean(result)
|
||||
isSetupMode.value = !isSetup.value
|
||||
} catch {
|
||||
isSetup.value = false
|
||||
isSetupMode.value = true
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -43,10 +43,12 @@ vi.mock('@/components/AnimatedLogo.vue', () => ({
|
||||
default: defineComponent({ name: 'AnimatedLogo', render: () => h('div') }),
|
||||
}))
|
||||
|
||||
const pushMock = vi.fn()
|
||||
const pushMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: pushMock }),
|
||||
useRoute: () => ({ query: {} }),
|
||||
createRouter: vi.fn(() => ({ push: pushMock, install: vi.fn(), currentRoute: { value: { path: '/' } }, beforeEach: vi.fn(), afterEach: vi.fn(), isReady: vi.fn().mockResolvedValue(undefined) })),
|
||||
createWebHistory: vi.fn(),
|
||||
}))
|
||||
|
||||
// Stub fetch for server health check
|
||||
|
||||
@@ -1,317 +1,13 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { shallowMount, VueWrapper } from '@vue/test-utils'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { defineComponent, h } from 'vue'
|
||||
|
||||
// Mock rpc-client before importing anything that uses it
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn().mockResolvedValue({ backups: [] }),
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
totpStatus: vi.fn().mockResolvedValue({ enabled: false }),
|
||||
totpSetupBegin: vi.fn(),
|
||||
totpSetupConfirm: vi.fn(),
|
||||
totpDisable: vi.fn(),
|
||||
getTorAddress: vi.fn().mockResolvedValue({ tor_address: null }),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock websocket module
|
||||
vi.mock('@/api/websocket', () => ({
|
||||
wsClient: {
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
disconnect: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(false),
|
||||
onConnectionStateChange: vi.fn(),
|
||||
},
|
||||
applyDataPatch: vi.fn(),
|
||||
}))
|
||||
|
||||
// Stub the ControllerIndicator component
|
||||
vi.mock('@/components/ControllerIndicator.vue', () => ({
|
||||
default: defineComponent({ name: 'ControllerIndicator', render: () => h('div') }),
|
||||
}))
|
||||
|
||||
// Mock useModalKeyboard composable
|
||||
vi.mock('@/composables/useModalKeyboard', () => ({
|
||||
useModalKeyboard: vi.fn(),
|
||||
}))
|
||||
|
||||
// Stub vue-router
|
||||
const pushMock = vi.fn()
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({
|
||||
push: pushMock,
|
||||
}),
|
||||
RouterLink: defineComponent({
|
||||
name: 'RouterLink',
|
||||
props: { to: { type: String, default: '' } },
|
||||
setup(_, { slots }) {
|
||||
return () => h('a', {}, slots.default?.())
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// Stub global fetch for the Claude status check in onMounted
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not available')))
|
||||
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import en from '@/locales/en.json'
|
||||
import Settings from '../Settings.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en } })
|
||||
|
||||
const mockedRpc = vi.mocked(rpcClient)
|
||||
|
||||
function mountSettings(storeOverrides?: Partial<ReturnType<typeof useAppStore>>): VueWrapper {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
|
||||
const store = useAppStore()
|
||||
// Set default store state for tests
|
||||
store.isAuthenticated = true
|
||||
store.$patch({
|
||||
data: {
|
||||
'server-info': {
|
||||
id: 'test-node',
|
||||
version: '0.1.0-alpha',
|
||||
name: 'Test Node',
|
||||
pubkey: 'test-pubkey',
|
||||
'status-info': { restarting: false, 'shutting-down': false, updated: false, 'backup-progress': null, 'update-progress': null },
|
||||
'lan-address': '192.168.1.100',
|
||||
'tor-address': null,
|
||||
unread: 0,
|
||||
'wifi-ssids': [],
|
||||
'zram-enabled': false,
|
||||
},
|
||||
'package-data': {},
|
||||
ui: { name: null, 'ack-welcome': '', marketplace: { 'selected-hosts': [], 'known-hosts': {} }, theme: 'dark' },
|
||||
},
|
||||
})
|
||||
|
||||
if (storeOverrides) {
|
||||
store.$patch(storeOverrides as Record<string, unknown>)
|
||||
}
|
||||
|
||||
return shallowMount(Settings, {
|
||||
global: {
|
||||
plugins: [pinia, i18n],
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
RouterLink: defineComponent({
|
||||
name: 'RouterLink',
|
||||
props: { to: { type: String, default: '' } },
|
||||
setup(_, { slots }) {
|
||||
return () => h('a', {}, slots.default?.())
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('Settings View', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
mockedRpc.totpStatus.mockResolvedValue({ enabled: false })
|
||||
mockedRpc.call.mockResolvedValue({ backups: [] })
|
||||
mockedRpc.getTorAddress.mockResolvedValue({ tor_address: null })
|
||||
pushMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('renders without errors', () => {
|
||||
const wrapper = mountSettings()
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays the Account section heading', () => {
|
||||
const wrapper = mountSettings()
|
||||
const heading = wrapper.find('h2')
|
||||
expect(heading.exists()).toBe(true)
|
||||
expect(heading.text()).toBe('Account')
|
||||
})
|
||||
|
||||
it('displays the Account section with server name and version', () => {
|
||||
const wrapper = mountSettings()
|
||||
const html = wrapper.html()
|
||||
|
||||
// Account section heading
|
||||
const sectionHeadings = wrapper.findAll('h2')
|
||||
const accountHeading = sectionHeadings.find((h) => h.text() === 'Account')
|
||||
expect(accountHeading).toBeDefined()
|
||||
|
||||
// Server name rendered
|
||||
expect(html).toContain('Test Node')
|
||||
|
||||
// Version rendered
|
||||
expect(html).toContain('0.1.0')
|
||||
})
|
||||
|
||||
it('displays the version from server info', () => {
|
||||
const wrapper = mountSettings()
|
||||
const html = wrapper.html()
|
||||
expect(html).toContain('0.1.0')
|
||||
expect(html).toContain('Version')
|
||||
})
|
||||
|
||||
it('displays the Interface Mode section', () => {
|
||||
const wrapper = mountSettings()
|
||||
const sectionHeadings = wrapper.findAll('h2')
|
||||
const modeHeading = sectionHeadings.find((h) => h.text() === 'Interface Mode')
|
||||
expect(modeHeading).toBeDefined()
|
||||
})
|
||||
|
||||
it('displays the Claude Authentication section', () => {
|
||||
const wrapper = mountSettings()
|
||||
const sectionHeadings = wrapper.findAll('h2')
|
||||
const claudeHeading = sectionHeadings.find((h) => h.text() === 'Claude Authentication')
|
||||
expect(claudeHeading).toBeDefined()
|
||||
})
|
||||
|
||||
it('displays the AI Data Access section', () => {
|
||||
const wrapper = mountSettings()
|
||||
const sectionHeadings = wrapper.findAll('h2')
|
||||
const aiHeading = sectionHeadings.find((h) => h.text() === 'AI Data Access')
|
||||
expect(aiHeading).toBeDefined()
|
||||
})
|
||||
|
||||
it('displays the System Updates section', () => {
|
||||
const wrapper = mountSettings()
|
||||
const sectionHeadings = wrapper.findAll('h2')
|
||||
const updatesHeading = sectionHeadings.find((h) => h.text() === 'System Updates')
|
||||
expect(updatesHeading).toBeDefined()
|
||||
})
|
||||
|
||||
it('displays the Backup & Restore section', () => {
|
||||
const wrapper = mountSettings()
|
||||
const sectionHeadings = wrapper.findAll('h2')
|
||||
const backupHeading = sectionHeadings.find((h) => h.text().includes('Backup'))
|
||||
expect(backupHeading).toBeDefined()
|
||||
})
|
||||
|
||||
it('displays the Network section', () => {
|
||||
const wrapper = mountSettings()
|
||||
const sectionHeadings = wrapper.findAll('h2')
|
||||
const networkHeading = sectionHeadings.find((h) => h.text() === 'Network')
|
||||
expect(networkHeading).toBeDefined()
|
||||
})
|
||||
|
||||
it('displays a Logout button', () => {
|
||||
const wrapper = mountSettings()
|
||||
const buttons = wrapper.findAll('button')
|
||||
const logoutButton = buttons.find((b) => b.text().includes('Logout'))
|
||||
expect(logoutButton).toBeDefined()
|
||||
expect(logoutButton!.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('logout button triggers store logout and navigates to login', async () => {
|
||||
const wrapper = mountSettings()
|
||||
const store = useAppStore()
|
||||
const logoutSpy = vi.spyOn(store, 'logout').mockResolvedValue()
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const logoutButton = buttons.find((b) => b.text().includes('Logout'))
|
||||
expect(logoutButton).toBeDefined()
|
||||
|
||||
await logoutButton!.trigger('click')
|
||||
// Allow async handlers to settle
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
expect(logoutSpy).toHaveBeenCalled()
|
||||
expect(pushMock).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
|
||||
it('displays a Change Password button', () => {
|
||||
const wrapper = mountSettings()
|
||||
const buttons = wrapper.findAll('button')
|
||||
const changePasswordButton = buttons.find((b) => b.text().includes('Change Password'))
|
||||
expect(changePasswordButton).toBeDefined()
|
||||
expect(changePasswordButton!.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('displays Two-Factor Authentication section with status', () => {
|
||||
const wrapper = mountSettings()
|
||||
const html = wrapper.html()
|
||||
expect(html).toContain('Two-Factor Authentication')
|
||||
})
|
||||
|
||||
it('shows Enable 2FA button when TOTP is not enabled', () => {
|
||||
const wrapper = mountSettings()
|
||||
const buttons = wrapper.findAll('button')
|
||||
const enable2faButton = buttons.find((b) => b.text().includes('Enable 2FA'))
|
||||
expect(enable2faButton).toBeDefined()
|
||||
})
|
||||
|
||||
it('displays session status as currently logged in', () => {
|
||||
const wrapper = mountSettings()
|
||||
expect(wrapper.html()).toContain('Currently logged in')
|
||||
})
|
||||
|
||||
it('shows server name from the store', () => {
|
||||
const wrapper = mountSettings()
|
||||
expect(wrapper.html()).toContain('Server Name')
|
||||
expect(wrapper.html()).toContain('Test Node')
|
||||
})
|
||||
|
||||
it('defaults version to 0.0.0 when server info has no version', () => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const store = useAppStore()
|
||||
store.$patch({
|
||||
isAuthenticated: true,
|
||||
data: {
|
||||
'server-info': {
|
||||
id: 'test',
|
||||
version: '',
|
||||
name: null,
|
||||
pubkey: '',
|
||||
'status-info': { restarting: false, 'shutting-down': false, updated: false, 'backup-progress': null, 'update-progress': null },
|
||||
'lan-address': null,
|
||||
'tor-address': null,
|
||||
unread: 0,
|
||||
'wifi-ssids': [],
|
||||
},
|
||||
'package-data': {},
|
||||
ui: { name: null, 'ack-welcome': '', marketplace: { 'selected-hosts': [], 'known-hosts': {} }, theme: 'dark' },
|
||||
},
|
||||
})
|
||||
|
||||
const wrapper = shallowMount(Settings, {
|
||||
global: {
|
||||
plugins: [pinia, i18n],
|
||||
stubs: {
|
||||
Teleport: true,
|
||||
RouterLink: defineComponent({
|
||||
name: 'RouterLink',
|
||||
props: { to: { type: String, default: '' } },
|
||||
setup(_, { slots }) {
|
||||
return () => h('a', {}, slots.default?.())
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// When version is empty string, computed returns '0.0.0' from the fallback
|
||||
const html = wrapper.html()
|
||||
expect(html).toContain('0.0.0')
|
||||
})
|
||||
|
||||
it('calls totpStatus on mount to check 2FA state', async () => {
|
||||
mountSettings()
|
||||
// onMounted calls loadTotpStatus which calls rpcClient.totpStatus
|
||||
expect(mockedRpc.totpStatus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls backup.list on mount to load backups', async () => {
|
||||
mountSettings()
|
||||
// onMounted calls loadBackups which calls rpcClient.call with backup.list
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({ method: 'backup.list' })
|
||||
it('renders AccountSection and SystemSection', () => {
|
||||
setActivePinia(createPinia())
|
||||
const wrapper = shallowMount(Settings)
|
||||
expect(wrapper.findComponent({ name: 'AccountSection' }).exists()).toBe(true)
|
||||
expect(wrapper.findComponent({ name: 'SystemSection' }).exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -869,6 +869,13 @@ aside:not(.sidebar-animate) .sidebar-logout-btn {
|
||||
animation: dashboard-glitch-scan 5s ease-out infinite;
|
||||
}
|
||||
|
||||
/* Pause dashboard glitch animations during tab switch (backdrop-filter fix) */
|
||||
html.tab-hidden .dashboard-glitch-1,
|
||||
html.tab-hidden .dashboard-glitch-2,
|
||||
html.tab-hidden .dashboard-glitch-scan {
|
||||
animation-play-state: paused !important;
|
||||
}
|
||||
|
||||
@keyframes dashboard-glitch-shift {
|
||||
0%, 82% { transform: translate(0,0); clip-path: inset(0% 0 0 0); opacity: 0; }
|
||||
82.1% { opacity: 0.28; }
|
||||
|
||||
Reference in New Issue
Block a user