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