106 lines
6.2 KiB
JavaScript
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 })
|
|
}
|