Files
LinkPort/scripts/verify-desktop.mjs

106 lines
6.2 KiB
JavaScript

// Exercise native local startup, database-contained keys and isolated browser setup.
import assert from 'node:assert/strict'
import { spawn, execFileSync } from 'node:child_process'
import { randomBytes, X509Certificate } from 'node:crypto'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { resolveProjectPath } from './project-paths.mjs'
import { setTimeout as delay } from 'node:timers/promises'
import { DatabaseSync } from 'node:sqlite'
const require = createRequire(new URL('../web/package.json', import.meta.url))
const { chromium } = require('@playwright/test')
assert(process.argv[2], 'Provide the native linkport executable')
const binary = resolveProjectPath(process.argv[2])
const temp = mkdtempSync(join(tmpdir(), 'linkport-desktop-'))
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.toUpperCase().startsWith('LINKPORT_') && k.toUpperCase() !== 'APPDATA'))
Object.assign(env, { APPDATA: join(temp, 'unused-appdata'), LINKPORT_DATA: join(temp, 'data', 'linkport.sqlite'), LINKPORT_NO_BROWSER: '1' })
let child, browser
function embeddedKey(directory) {
const database = new DatabaseSync(join(directory, 'linkport.sqlite'), { readOnly: true })
try {
const { key } = database.prepare("SELECT value AS key FROM local_metadata WHERE name='master-key'").get()
assert.equal(Buffer.from(key, 'base64').length, 32)
for (const name of ['master-key', 'master-key.dpapi']) assert(!existsSync(join(directory, name)), `Startup created ${name}`)
return key
} finally { database.close() }
}
async function stop() {
const running = child
child = undefined
if (running && running.exitCode === null && running.signalCode === null) {
await new Promise(resolve => { running.once('exit', resolve); running.kill() })
}
}
async function start() {
let output = ''
child = spawn(binary, [], { cwd: temp, env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
child.stdout.on('data', b => { output += b.toString() })
child.stderr.on('data', b => { output += b.toString() })
child.on('error', e => { output += e.message })
for (let i = 0; i < 100; i++) {
const match = /http:\/\/127\.0\.0\.1:\d+(?:\/setup\?token=[\w-]+)?/.exec(output)
if (match) return match[0]
assert(child.exitCode === null, `Startup failed: ${output}`)
await delay(100)
}
throw new Error('Desktop startup timed out')
}
try {
const developmentURL = await start()
assert(!developmentURL.includes('setup'), 'Development startup must skip administrator setup')
const session = await fetch(developmentURL + '/api/v1/session')
assert.equal(session.status, 200)
assert.equal((await session.json()).authEnabled, false)
assert(existsSync(join(temp, 'data', 'linkport.sqlite')), 'Default database must use the working directory')
assert(!existsSync(env.APPDATA), 'Default startup must not initialize AppData')
await stop()
env.LINKPORT_AUTH_ENABLED = 'true'
const url = await start()
assert(url.includes('/setup?token='), 'First launch must offer setup')
const originalKey = embeddedKey(join(temp, 'data'))
if (process.platform !== 'win32') assert.equal(statSync(join(temp, 'data', 'linkport.sqlite')).mode & 0o777, 0o600)
assert(!existsSync(join(temp, 'data', 'tls')), 'Automatic TLS must be stored inside SQLite')
browser = await chromium.launch({ channel: process.env.LINKPORT_BROWSER_CHANNEL || (process.platform === 'win32' ? 'msedge' : undefined), headless: true })
const page = await browser.newPage()
await page.goto(url)
await page.getByRole('heading', { name: '欢迎使用 linkport' }).waitFor()
const password = randomBytes(24).toString('base64url')
await page.getByLabel('登录账号').fill('desktop-test')
await page.getByLabel('登录密码', { exact: true }).fill(password)
await page.getByLabel('确认密码').fill(password)
await page.getByRole('button', { name: '保存并进入登录页面' }).click()
await page.waitForURL(new URL('/', url).href)
const base = new URL(url).origin
const auth = await fetch(base + '/api/v1/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'desktop-test', password }) })
assert.equal(auth.status, 200)
await stop()
const secondURL = await start()
assert(!secondURL.includes('setup'), 'Restart must skip setup')
assert.equal(embeddedKey(join(temp, 'data')), originalKey, 'Restart replaced embedded key')
await page.goto(secondURL)
const login = await fetch(secondURL + '/api/v1/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'desktop-test', password }) })
assert.equal(login.status, 200, 'Saved administrator cannot log in after restart')
await stop()
const legacyDirectory = process.platform === 'win32' ? join(env.APPDATA, 'linkport') : join(temp, 'copied-data')
if (process.platform === 'win32') mkdirSync(env.APPDATA, { recursive: true })
renameSync(join(temp, 'data'), legacyDirectory)
if (process.platform === 'win32') delete env.LINKPORT_DATA
else env.LINKPORT_DATA = join(legacyDirectory, 'linkport.sqlite')
const legacyURL = await start()
assert(!legacyURL.includes('setup'), 'Relocated data must reuse the existing administrator')
assert(!existsSync(join(temp, 'data')), 'Relocated startup must not create a new local database')
assert.equal(embeddedKey(legacyDirectory), originalKey, 'Moving data replaced the embedded key')
const legacyLogin = await fetch(legacyURL + '/api/v1/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'desktop-test', password }) })
assert.equal(legacyLogin.status, 200, 'Saved administrator cannot log in using relocated data')
await stop()
const cert = new X509Certificate(execFileSync(binary, ['export-ca'], { cwd: temp, env, windowsHide: true }))
assert(cert.checkHost('localhost'))
console.log('Local startup verified: default no-login mode, optional administrator setup, automatic TLS, database-contained key persistence, no key files, authenticated restart and relocated data.')
} finally {
if (browser) await browser.close()
await stop()
rmSync(temp, { recursive: true, force: true })
}