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
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user