Add PM2 deployment support

This commit is contained in:
lofyer
2026-08-24 17:59:49 +08:00
parent 5ff80caf7e
commit 63bbbac922
16 changed files with 450 additions and 55 deletions
+30
View File
@@ -20,6 +20,36 @@ afterEach(async () => {
})
describe('AuthService', () => {
it('creates the initial administrator only when the database is empty', async () => {
const directory = await mkdtemp(join(tmpdir(), 'product-issue-auth-'))
directories.push(directory)
const database = openDatabase(join(directory, 'database.sqlite'))
databases.push(database)
const auth = new AuthService(database, 'secret', 60 * 60 * 1_000)
await expect(
auth.createInitialAdmin({
email: 'first@example.com',
displayName: 'First admin',
password: 'first secure password'
})
).resolves.toBe(true)
await expect(
auth.createInitialAdmin({
email: 'second@example.com',
displayName: 'Second admin',
password: 'second secure password'
})
).resolves.toBe(false)
await expect(
auth.login('first@example.com', 'first secure password')
).resolves.toMatchObject({ displayName: 'First admin' })
await expect(
auth.login('second@example.com', 'second secure password')
).resolves.toBeUndefined()
})
it('invalidates existing sessions when an administrator is reset', async () => {
const directory = await mkdtemp(join(tmpdir(), 'product-issue-auth-'))
directories.push(directory)
+49 -4
View File
@@ -1,7 +1,17 @@
import { describe, expect, it } from 'vitest'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { loadConfig } from '../src/server/config.js'
const secret = 'test-secret-that-is-at-least-thirty-two-characters'
const directories: string[] = []
afterEach(() => {
for (const directory of directories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
describe('loadConfig', () => {
it('normalizes a valid production origin', () => {
@@ -40,11 +50,46 @@ describe('loadConfig', () => {
).toThrow()
})
it('rejects the example placeholder secret', () => {
it('accepts an explicitly configured secret without a length restriction', () => {
expect(loadConfig({ APP_SECRET: 'short' }).appSecret).toBe('short')
})
it('generates and reuses a secret when APP_SECRET is omitted', () => {
const directory = mkdtempSync(join(tmpdir(), 'product-issue-config-'))
directories.push(directory)
const first = loadConfig({ DATA_DIRECTORY: directory })
const second = loadConfig({ DATA_DIRECTORY: directory })
expect(first.appSecret).toHaveLength(43)
expect(second.appSecret).toBe(first.appSecret)
expect(readFileSync(join(directory, '.app-secret'), 'utf8').trim()).toBe(
first.appSecret
)
})
it('parses initial administrator configuration', () => {
expect(
loadConfig({
APP_SECRET: secret,
INITIAL_ADMIN_EMAIL: 'admin@example.com',
INITIAL_ADMIN_PASSWORD: 'a local admin password'
}).initialAdmin
).toEqual({
email: 'admin@example.com',
displayName: 'Administrator',
password: 'a local admin password'
})
})
it('requires initial administrator email and password together', () => {
expect(() =>
loadConfig({
APP_SECRET: 'replace-with-at-least-32-random-characters'
APP_SECRET: secret,
INITIAL_ADMIN_EMAIL: 'admin@example.com'
})
).toThrow()
).toThrow(
'INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD must be configured together'
)
})
})
+59
View File
@@ -0,0 +1,59 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { loadEnvironmentFile } from '../src/server/environment.js'
const testVariable = 'PRODUCT_ISSUE_ENV_FILE_TEST'
const originalValue = process.env[testVariable]
const temporaryDirectories: string[] = []
afterEach(() => {
if (originalValue === undefined) {
delete process.env[testVariable]
} else {
process.env[testVariable] = originalValue
}
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
function environmentFile(value: string): string {
const directory = mkdtempSync(join(tmpdir(), 'product-issue-env-'))
temporaryDirectories.push(directory)
const filePath = join(directory, '.env')
writeFileSync(filePath, `${testVariable}=${value}\n`, {
encoding: 'utf8',
mode: 0o600
})
return filePath
}
describe('loadEnvironmentFile', () => {
it('loads values from an explicit environment file', () => {
delete process.env[testVariable]
const filePath = environmentFile('from-file')
expect(loadEnvironmentFile(filePath)).toBe(filePath)
expect(process.env[testVariable]).toBe('from-file')
})
it('does not override values already provided by the process', () => {
process.env[testVariable] = 'from-process'
loadEnvironmentFile(environmentFile('from-file'))
expect(process.env[testVariable]).toBe('from-process')
})
it('fails when an explicitly configured file does not exist', () => {
const directory = mkdtempSync(join(tmpdir(), 'product-issue-env-'))
temporaryDirectories.push(directory)
expect(() => loadEnvironmentFile(join(directory, '.env'))).toThrow(
'Environment file does not exist'
)
})
})