Files
boundless/server/__tests__/server.spec.ts
T
吴璨 fad9760265 自部署多端同步:服务端、实时协作、口令登录与油画加载动画
- 新增 Node 服务端(Hono + Hocuspocus + node:sqlite),数据保存在服务器
- 基于 Yjs 的多端实时同步,本机 IndexedDB 离线缓存,撤销只针对本机操作
- 空间(标签)有独立地址,增删改通过接口同步到所有设备
- 口令登录(签名 Cookie 会话、登录限流、同源校验)
- 图片上传到服务器,过大照片上传前自动压缩
- 历史版本(自动/手动保存、一键恢复)、跨空间搜索、在线设备
- 随机油画加载动画(Painting Loaders,本地收录 p5 / p5.brush)
- 选中样式与拖拽性能优化:拖动结束后才同步位置
- Docker / docker-compose 部署,README 与 CLAUDE.md 更新
2026-10-21 18:09:03 +08:00

139 lines
4.8 KiB
TypeScript

// @vitest-environment node
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import * as Y from 'yjs'
import { LoginLimiter, SessionManager, isSameOrigin, readCookie } from '../auth.ts'
import { Storage } from '../db.ts'
import { detectImageType } from '../images.ts'
describe('SessionManager', () => {
const sessions = new SessionManager('secret', 'password', 30)
it('签发的令牌可以通过校验', () => {
expect(sessions.verify(sessions.issue())).not.toBeNull()
})
it('拒绝被篡改或过期的令牌', () => {
const token = sessions.issue()
const [body, signature] = token.split('.')
expect(sessions.verify(`${body}x.${signature}`)).toBeNull()
expect(sessions.verify(`${body}.${signature}x`)).toBeNull()
expect(sessions.verify('garbage')).toBeNull()
expect(sessions.verify(sessions.issue(Date.now() - 31 * 86_400_000))).toBeNull()
})
it('修改口令后旧令牌失效', () => {
const token = sessions.issue()
expect(new SessionManager('secret', 'new-password', 30).verify(token)).toBeNull()
})
it('校验口令', () => {
expect(sessions.checkPassword('password')).toBe(true)
expect(sessions.checkPassword('Password')).toBe(false)
expect(sessions.checkPassword(undefined)).toBe(false)
})
it('有效期过半时提示续期', () => {
const now = Date.now()
expect(sessions.shouldRenew(sessions.verify(sessions.issue(now))!, now)).toBe(false)
const old = sessions.verify(sessions.issue(now - 20 * 86_400_000), now)!
expect(sessions.shouldRenew(old, now)).toBe(true)
})
})
describe('请求辅助函数', () => {
it('解析 Cookie', () => {
expect(readCookie('a=1; boundless_session=abc%3D; b=2', 'boundless_session')).toBe('abc=')
expect(readCookie(undefined, 'x')).toBeUndefined()
})
it('只接受同源请求,并识别反向代理转发的 Host', () => {
expect(isSameOrigin(undefined, ['example.com'])).toBe(true)
expect(isSameOrigin('https://example.com', ['example.com'])).toBe(true)
expect(isSameOrigin('https://evil.com', ['example.com'])).toBe(false)
expect(isSameOrigin('https://board.me', ['board.me', 'localhost:3000'])).toBe(true)
})
it('登录失败过多时暂时锁定', () => {
const limiter = new LoginLimiter(3, 1000)
limiter.fail('ip', 0)
limiter.fail('ip', 10)
expect(limiter.isBlocked('ip', 20)).toBe(false)
limiter.fail('ip', 30)
expect(limiter.isBlocked('ip', 40)).toBe(true)
expect(limiter.isBlocked('ip', 2000)).toBe(false)
})
})
describe('detectImageType', () => {
const bytes = (...values: Array<number | string>) =>
new Uint8Array(
values.flatMap((value) =>
typeof value === 'string' ? [...value].map((char) => char.charCodeAt(0)) : [value],
),
)
it('根据文件头识别格式', () => {
expect(detectImageType(bytes(0x89, 'PNG\r\n', 0x1a, '\n', 0, 0, 0, 0))).toBe('png')
expect(detectImageType(bytes(0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0))).toBe('jpg')
expect(detectImageType(bytes('RIFF', 0, 0, 0, 0, 'WEBP'))).toBe('webp')
expect(detectImageType(bytes('<svg xmlns="http://www.w3.org/2000/svg">'))).toBe('svg')
})
it('拒绝非图片文件', () => {
expect(detectImageType(bytes('<!doctype html><script>'))).toBeNull()
expect(detectImageType(bytes('MZ', 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))).toBeNull()
})
})
describe('Storage', () => {
let dir = ''
let storage: Storage | null = null
afterEach(() => {
storage?.close()
storage = null
if (dir) rmSync(dir, { recursive: true, force: true })
})
function open() {
dir = mkdtempSync(path.join(tmpdir(), 'boundless-'))
storage = new Storage(path.join(dir, 'test.sqlite'))
return storage
}
it('保存与读取文档', () => {
const db = open()
const doc = new Y.Doc()
doc.getMap('cards').set('a', 1)
db.saveDocument('board:x', Y.encodeStateAsUpdate(doc))
const loaded = new Y.Doc()
Y.applyUpdate(loaded, db.loadDocument('board:x')!)
expect(loaded.getMap('cards').get('a')).toBe(1)
db.deleteDocument('board:x')
expect(db.loadDocument('board:x')).toBeNull()
})
it('历史版本按时间倒序列出', () => {
const db = open()
const input = {
spaceId: 's',
data: new Uint8Array([1]),
stateVector: new Uint8Array([2]),
cardCount: 1,
preview: '预览',
}
const first = db.addSnapshot({ ...input, reason: 'auto' })
const second = db.addSnapshot({ ...input, reason: 'manual' })
expect(db.listSnapshots('s').map((item) => item.id)).toEqual([second, first])
expect(db.loadSnapshot('s', first)).toEqual(new Uint8Array([1]))
expect(db.loadSnapshot('other', first)).toBeNull()
db.deleteSnapshots('s')
expect(db.listSnapshots('s')).toEqual([])
})
})