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>
323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { sanitizePath } from '../filebrowser-client'
|
|
|
|
const mockFetch = vi.fn()
|
|
vi.stubGlobal('fetch', mockFetch)
|
|
|
|
// FileBrowserClient reads window.location.origin in constructor, so stub it
|
|
Object.defineProperty(window, 'location', {
|
|
value: { origin: 'http://localhost', protocol: 'http:', hostname: 'localhost', pathname: '/app/filebrowser' },
|
|
writable: true,
|
|
})
|
|
|
|
// Import after stubs
|
|
const { fileBrowserClient } = await import('../filebrowser-client')
|
|
|
|
function jsonResponse(body: unknown, status = 200): Response {
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
statusText: status === 200 ? 'OK' : 'Error',
|
|
json: () => Promise.resolve(body),
|
|
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
|
|
blob: () => Promise.resolve(new Blob([JSON.stringify(body)])),
|
|
headers: new Headers(),
|
|
redirected: false,
|
|
type: 'basic' as ResponseType,
|
|
url: '',
|
|
clone: () => jsonResponse(body, status),
|
|
body: null,
|
|
bodyUsed: false,
|
|
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
|
formData: () => Promise.resolve(new FormData()),
|
|
bytes: () => Promise.resolve(new Uint8Array()),
|
|
}
|
|
}
|
|
|
|
/** Set up authenticated state — bypasses jsdom cookie path restrictions */
|
|
function setAuthenticated() {
|
|
;(fileBrowserClient as any)._authenticated = true
|
|
document.cookie = 'auth=test-token'
|
|
}
|
|
|
|
describe('FileBrowserClient', () => {
|
|
beforeEach(() => {
|
|
mockFetch.mockReset()
|
|
;(fileBrowserClient as any)._authenticated = false
|
|
document.cookie = 'auth=; expires=Thu, 01 Jan 1970 00:00:00 GMT'
|
|
})
|
|
|
|
describe('login', () => {
|
|
it('authenticates via backend RPC and stores token', async () => {
|
|
mockFetch.mockResolvedValueOnce(jsonResponse({ result: { token: 'jwt-token-123' } }))
|
|
|
|
const result = await fileBrowserClient.login()
|
|
|
|
expect(result).toBe(true)
|
|
expect(fileBrowserClient.isAuthenticated).toBe(true)
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
'/rpc/v1',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({ method: 'app.filebrowser-token' }),
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('returns false on failed login', async () => {
|
|
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
|
|
|
|
const result = await fileBrowserClient.login()
|
|
|
|
expect(result).toBe(false)
|
|
})
|
|
|
|
it('returns false on network error', async () => {
|
|
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
|
|
|
const result = await fileBrowserClient.login()
|
|
|
|
expect(result).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('listDirectory', () => {
|
|
it('lists items in a directory', async () => {
|
|
setAuthenticated()
|
|
|
|
const mockItems = {
|
|
items: [
|
|
{ name: 'photos', path: '/photos', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
|
|
{ name: 'readme.txt', path: '/readme.txt', size: 1024, modified: '2026-01-01', isDir: false, type: '', extension: 'txt' },
|
|
],
|
|
numDirs: 1,
|
|
numFiles: 1,
|
|
sorting: { by: 'name', asc: true },
|
|
}
|
|
mockFetch.mockResolvedValueOnce(jsonResponse(mockItems))
|
|
|
|
const items = await fileBrowserClient.listDirectory('/')
|
|
|
|
expect(items).toHaveLength(2)
|
|
expect(items[0]!.name).toBe('photos')
|
|
expect(items[1]!.extension).toBe('txt')
|
|
})
|
|
|
|
it('adds leading slash if missing', async () => {
|
|
setAuthenticated()
|
|
|
|
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], numDirs: 0, numFiles: 0, sorting: { by: 'name', asc: true } }))
|
|
|
|
await fileBrowserClient.listDirectory('photos')
|
|
|
|
const [url] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
|
expect(url).toContain('/api/resources/photos')
|
|
})
|
|
|
|
it('throws on non-OK response', async () => {
|
|
setAuthenticated()
|
|
|
|
mockFetch.mockResolvedValueOnce(jsonResponse(null, 404))
|
|
|
|
await expect(fileBrowserClient.listDirectory('/missing')).rejects.toThrow('Failed to list directory: 404')
|
|
})
|
|
})
|
|
|
|
describe('downloadUrl', () => {
|
|
it('constructs download URL for file path', async () => {
|
|
const url = fileBrowserClient.downloadUrl('/photos/sunset.jpg')
|
|
|
|
expect(url).toContain('/api/raw/photos/sunset.jpg')
|
|
})
|
|
|
|
it('adds leading slash if missing', async () => {
|
|
const url = fileBrowserClient.downloadUrl('file.txt')
|
|
|
|
expect(url).toContain('/api/raw/file.txt')
|
|
})
|
|
})
|
|
|
|
describe('upload', () => {
|
|
it('uploads a file to the correct path', async () => {
|
|
setAuthenticated()
|
|
|
|
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
|
const file = new File(['hello'], 'test.txt', { type: 'text/plain' })
|
|
|
|
await fileBrowserClient.upload('/documents', file)
|
|
|
|
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
|
expect(url).toContain('/api/resources/documents/test.txt')
|
|
expect(url).toContain('override=true')
|
|
expect(init.method).toBe('POST')
|
|
expect(init.body).toBe(file)
|
|
})
|
|
|
|
it('throws on upload failure', async () => {
|
|
setAuthenticated()
|
|
|
|
mockFetch.mockResolvedValueOnce(jsonResponse('Disk full', 507))
|
|
const file = new File(['data'], 'big.bin')
|
|
|
|
await expect(fileBrowserClient.upload('/', file)).rejects.toThrow('Upload failed (507)')
|
|
})
|
|
})
|
|
|
|
describe('createFolder', () => {
|
|
it('creates a folder at the correct path', async () => {
|
|
setAuthenticated()
|
|
|
|
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
|
|
|
await fileBrowserClient.createFolder('/documents', 'photos')
|
|
|
|
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
|
expect(url).toContain('/api/resources/documents/photos/')
|
|
expect(init.method).toBe('POST')
|
|
})
|
|
|
|
it('throws on failure', async () => {
|
|
setAuthenticated()
|
|
|
|
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
|
|
|
|
await expect(fileBrowserClient.createFolder('/', 'test')).rejects.toThrow('Create folder failed: 500')
|
|
})
|
|
})
|
|
|
|
describe('deleteItem', () => {
|
|
it('sends DELETE request for the item', async () => {
|
|
setAuthenticated()
|
|
|
|
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
|
|
|
await fileBrowserClient.deleteItem('/photos/old.jpg')
|
|
|
|
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
|
expect(url).toContain('/api/resources/photos/old.jpg')
|
|
expect(init.method).toBe('DELETE')
|
|
})
|
|
|
|
it('throws on failure', async () => {
|
|
setAuthenticated()
|
|
|
|
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
|
|
|
|
await expect(fileBrowserClient.deleteItem('/protected')).rejects.toThrow('Delete failed: 403')
|
|
})
|
|
})
|
|
|
|
describe('getUsage', () => {
|
|
it('returns usage summary for root directory', async () => {
|
|
setAuthenticated()
|
|
|
|
const mockData = {
|
|
items: [
|
|
{ name: 'photos', path: '/photos', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
|
|
{ name: 'file1.txt', path: '/file1.txt', size: 500, modified: '2026-01-01', isDir: false, type: '', extension: 'txt' },
|
|
{ name: 'file2.jpg', path: '/file2.jpg', size: 1500, modified: '2026-01-01', isDir: false, type: '', extension: 'jpg' },
|
|
],
|
|
numDirs: 1,
|
|
numFiles: 2,
|
|
sorting: { by: 'name', asc: true },
|
|
}
|
|
mockFetch.mockResolvedValueOnce(jsonResponse(mockData))
|
|
|
|
const usage = await fileBrowserClient.getUsage()
|
|
|
|
expect(usage.totalSize).toBe(2000)
|
|
expect(usage.folderCount).toBe(1)
|
|
expect(usage.fileCount).toBe(2)
|
|
})
|
|
|
|
it('returns zeros on failed request', async () => {
|
|
setAuthenticated()
|
|
|
|
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
|
|
|
|
const usage = await fileBrowserClient.getUsage()
|
|
|
|
expect(usage).toEqual({ totalSize: 0, folderCount: 0, fileCount: 0 })
|
|
})
|
|
})
|
|
|
|
describe('isTextFile', () => {
|
|
it('identifies text file extensions', () => {
|
|
expect(fileBrowserClient.isTextFile('readme.md')).toBe(true)
|
|
expect(fileBrowserClient.isTextFile('config.json')).toBe(true)
|
|
expect(fileBrowserClient.isTextFile('script.py')).toBe(true)
|
|
expect(fileBrowserClient.isTextFile('main.rs')).toBe(true)
|
|
expect(fileBrowserClient.isTextFile('style.css')).toBe(true)
|
|
})
|
|
|
|
it('returns false for binary files', () => {
|
|
expect(fileBrowserClient.isTextFile('photo.jpg')).toBe(false)
|
|
expect(fileBrowserClient.isTextFile('video.mp4')).toBe(false)
|
|
expect(fileBrowserClient.isTextFile('archive.zip')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('rename', () => {
|
|
it('sends PATCH request with new destination', async () => {
|
|
setAuthenticated()
|
|
|
|
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
|
|
|
await fileBrowserClient.rename('/photos/old.jpg', 'new.jpg')
|
|
|
|
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
|
expect(url).toContain('/api/resources/photos/old.jpg')
|
|
expect(init.method).toBe('PATCH')
|
|
expect(JSON.parse(init.body)).toEqual({ destination: '/photos/new.jpg' })
|
|
})
|
|
|
|
it('throws on rename failure', async () => {
|
|
setAuthenticated()
|
|
|
|
mockFetch.mockResolvedValueOnce(jsonResponse(null, 409))
|
|
|
|
await expect(fileBrowserClient.rename('/a.txt', 'b.txt')).rejects.toThrow('Rename failed: 409')
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('sanitizePath', () => {
|
|
it('returns / for empty path', () => {
|
|
expect(sanitizePath('')).toBe('/')
|
|
})
|
|
|
|
it('preserves simple paths', () => {
|
|
expect(sanitizePath('/photos')).toBe('/photos')
|
|
expect(sanitizePath('/docs/readme.md')).toBe('/docs/readme.md')
|
|
})
|
|
|
|
it('adds leading slash', () => {
|
|
expect(sanitizePath('photos/image.jpg')).toBe('/photos/image.jpg')
|
|
})
|
|
|
|
it('resolves . segments', () => {
|
|
expect(sanitizePath('/photos/./image.jpg')).toBe('/photos/image.jpg')
|
|
})
|
|
|
|
it('resolves .. segments', () => {
|
|
expect(sanitizePath('/photos/../etc/passwd')).toBe('/etc/passwd')
|
|
})
|
|
|
|
it('prevents traversal past root', () => {
|
|
expect(sanitizePath('/../../../etc/passwd')).toBe('/etc/passwd')
|
|
expect(sanitizePath('/../../..')).toBe('/')
|
|
})
|
|
|
|
it('handles multiple consecutive .. at root', () => {
|
|
expect(sanitizePath('/../../../etc/shadow')).toBe('/etc/shadow')
|
|
})
|
|
|
|
it('handles mixed . and .. segments', () => {
|
|
expect(sanitizePath('/a/./b/../c')).toBe('/a/c')
|
|
})
|
|
|
|
it('removes trailing slashes in segments', () => {
|
|
expect(sanitizePath('/photos//image.jpg')).toBe('/photos/image.jpg')
|
|
})
|
|
})
|