- New src/lib/crypto/totp.js: native RFC 6238 TOTP using Web Crypto HMAC-SHA1 (no external crypto). base32Decode, extractSecret (bare base32 or otpauth:// URI), generateTotp, totpRemainingSeconds. - Entries gain an optional encryptedTotpSecret, stored AES-GCM-encrypted like passwords. schema.js createEntry/updateEntry/docs updated. - Export/import re-key the TOTP secret alongside passwords when sealing with a separate password, and decrypt/re-encrypt on import so TOTP survives moves. - EntryForm: optional 'TOTP Secret (2FA)' field (base32 or otpauth:// URI). - EntryDetail: live 6-digit TOTP display updating every second with a countdown and urgency indicator, plus copy; guarded cleanup timer on unmount. - Tests: RFC 6238 SHA-1 vectors (6 & 8 digit), base32/extractSecret, remaining seconds, schema round-trip. 157 total pass.
265 lines
8.1 KiB
JavaScript
265 lines
8.1 KiB
JavaScript
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
generateId,
|
|
createEntry,
|
|
updateEntry,
|
|
createGroup,
|
|
validateEntry,
|
|
validateGroup,
|
|
createTrashGroup,
|
|
isTrashGroup,
|
|
TRASH_GROUP_ID,
|
|
TRASH_GROUP_NAME,
|
|
TRASH_GROUP_COLOR,
|
|
} from '../../../src/lib/models/schema.js'
|
|
|
|
describe('generateId', () => {
|
|
it('should generate unique IDs', () => {
|
|
const ids = new Set()
|
|
for (let i = 0; i < 100; i++) {
|
|
ids.add(generateId())
|
|
}
|
|
expect(ids.size).toBe(100)
|
|
})
|
|
|
|
it('should produce sortable IDs (timestamp-based)', () => {
|
|
const id1 = generateId()
|
|
// Small delay to ensure different timestamp
|
|
// IDs are base36 timestamps + random, so they should be lexicographically ordered
|
|
expect(id1).toMatch(/^[a-z0-9]+_[0-9a-f]{8}$/)
|
|
})
|
|
|
|
it('should contain underscore separator', () => {
|
|
const id = generateId()
|
|
const parts = id.split('_')
|
|
expect(parts).toHaveLength(2)
|
|
expect(parts[1]).toHaveLength(8)
|
|
})
|
|
})
|
|
|
|
describe('createEntry', () => {
|
|
it('should create an entry with required fields', () => {
|
|
const entry = createEntry({
|
|
title: 'GitHub',
|
|
encryptedPassword: 'encrypted-blob',
|
|
})
|
|
expect(entry.id).toBeDefined()
|
|
expect(entry.title).toBe('GitHub')
|
|
expect(entry.encryptedPassword).toBe('encrypted-blob')
|
|
expect(entry.createdAt).toBeDefined()
|
|
expect(entry.updatedAt).toBe(entry.createdAt)
|
|
})
|
|
|
|
it('should store an encrypted TOTP secret', () => {
|
|
const entry = createEntry({
|
|
title: 'GitHub',
|
|
encryptedPassword: 'encrypted-blob',
|
|
encryptedTotpSecret: 'encrypted-totp',
|
|
})
|
|
expect(entry.encryptedTotpSecret).toBe('encrypted-totp')
|
|
})
|
|
|
|
it('should default encryptedTotpSecret to undefined when not provided', () => {
|
|
const entry = createEntry({ title: 'GitHub' })
|
|
expect(entry.encryptedTotpSecret).toBeUndefined()
|
|
})
|
|
|
|
it('should trim title and optional fields', () => {
|
|
const entry = createEntry({
|
|
title: ' GitHub ',
|
|
username: ' user@test.com ',
|
|
url: ' https://github.com ',
|
|
notes: ' some notes ',
|
|
encryptedPassword: 'encrypted-blob',
|
|
})
|
|
expect(entry.title).toBe('GitHub')
|
|
expect(entry.username).toBe('user@test.com')
|
|
expect(entry.url).toBe('https://github.com')
|
|
expect(entry.notes).toBe('some notes')
|
|
})
|
|
|
|
it('should set defaults for optional fields', () => {
|
|
const entry = createEntry({
|
|
title: 'Test',
|
|
encryptedPassword: 'encrypted-blob',
|
|
})
|
|
expect(entry.username).toBe('')
|
|
expect(entry.url).toBe('')
|
|
expect(entry.notes).toBe('')
|
|
expect(entry.groupId).toBe('')
|
|
expect(entry.tags).toEqual([])
|
|
})
|
|
|
|
it('should accept groupId and tags', () => {
|
|
const entry = createEntry({
|
|
title: 'Test',
|
|
encryptedPassword: 'encrypted-blob',
|
|
groupId: 'group-123',
|
|
tags: ['work', 'important'],
|
|
})
|
|
expect(entry.groupId).toBe('group-123')
|
|
expect(entry.tags).toEqual(['work', 'important'])
|
|
})
|
|
})
|
|
|
|
describe('updateEntry', () => {
|
|
it('should preserve id and createdAt', () => {
|
|
const existing = createEntry({
|
|
title: 'GitHub',
|
|
encryptedPassword: 'old-encrypted',
|
|
})
|
|
const updated = updateEntry(existing, { title: 'GitHub Pro' })
|
|
expect(updated.id).toBe(existing.id)
|
|
expect(updated.createdAt).toBe(existing.createdAt)
|
|
// updatedAt should be >= createdAt (ISO strings are lexicographically comparable)
|
|
expect(updated.updatedAt >= existing.updatedAt).toBe(true)
|
|
})
|
|
|
|
it('should update only specified fields', () => {
|
|
const existing = createEntry({
|
|
title: 'GitHub',
|
|
username: 'user',
|
|
encryptedPassword: 'old-encrypted',
|
|
url: 'https://github.com',
|
|
notes: 'old notes',
|
|
})
|
|
const updated = updateEntry(existing, { title: 'GitHub Pro' })
|
|
expect(updated.title).toBe('GitHub Pro')
|
|
expect(updated.username).toBe('user')
|
|
expect(updated.encryptedPassword).toBe('old-encrypted')
|
|
expect(updated.url).toBe('https://github.com')
|
|
expect(updated.notes).toBe('old notes')
|
|
})
|
|
|
|
it('should trim updated string fields', () => {
|
|
const existing = createEntry({
|
|
title: 'GitHub',
|
|
encryptedPassword: 'encrypted',
|
|
})
|
|
const updated = updateEntry(existing, { title: ' GitHub Pro ', notes: ' new notes ' })
|
|
expect(updated.title).toBe('GitHub Pro')
|
|
expect(updated.notes).toBe('new notes')
|
|
})
|
|
|
|
it('should handle undefined fields (no-op)', () => {
|
|
const existing = createEntry({
|
|
title: 'GitHub',
|
|
encryptedPassword: 'encrypted',
|
|
})
|
|
const updated = updateEntry(existing, { title: undefined })
|
|
expect(updated.title).toBe('GitHub')
|
|
})
|
|
})
|
|
|
|
describe('createGroup', () => {
|
|
it('should create a group with name and color', () => {
|
|
const group = createGroup('Work', '#ff0000')
|
|
expect(group.id).toBeDefined()
|
|
expect(group.name).toBe('Work')
|
|
expect(group.color).toBe('#ff0000')
|
|
expect(group.createdAt).toBeDefined()
|
|
})
|
|
|
|
it('should assign a random color when none provided', () => {
|
|
const group = createGroup('Personal')
|
|
expect(group.color).toMatch(/^#[0-9a-f]{6}$/)
|
|
})
|
|
|
|
it('should trim group name', () => {
|
|
const group = createGroup(' Work ')
|
|
expect(group.name).toBe('Work')
|
|
})
|
|
})
|
|
|
|
describe('validateEntry', () => {
|
|
it('should pass with valid data', () => {
|
|
const result = validateEntry({ title: 'GitHub', encryptedPassword: 'encrypted' })
|
|
expect(result.valid).toBe(true)
|
|
expect(result.errors).toEqual([])
|
|
})
|
|
|
|
it('should fail with empty title', () => {
|
|
const result = validateEntry({ title: '', encryptedPassword: 'encrypted' })
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors).toContain('Title is required')
|
|
})
|
|
|
|
it('should fail with whitespace-only title', () => {
|
|
const result = validateEntry({ title: ' ', encryptedPassword: 'encrypted' })
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors).toContain('Title is required')
|
|
})
|
|
|
|
it('should pass with no encryptedPassword (password optional)', () => {
|
|
const result = validateEntry({ title: 'GitHub' })
|
|
expect(result.valid).toBe(true)
|
|
expect(result.errors).toEqual([])
|
|
})
|
|
|
|
it('should pass with an empty password string', () => {
|
|
const result = validateEntry({ title: 'GitHub', encryptedPassword: '' })
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('should report only the title error when password is missing (not required)', () => {
|
|
const result = validateEntry({ title: '' })
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors.length).toBe(1)
|
|
expect(result.errors).toContain('Title is required')
|
|
})
|
|
})
|
|
|
|
describe('validateGroup', () => {
|
|
it('should pass with valid name', () => {
|
|
const result = validateGroup('Work')
|
|
expect(result.valid).toBe(true)
|
|
expect(result.errors).toEqual([])
|
|
})
|
|
|
|
it('should fail with empty name', () => {
|
|
const result = validateGroup('')
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors).toContain('Group name is required')
|
|
})
|
|
|
|
it('should fail with whitespace-only name', () => {
|
|
const result = validateGroup(' ')
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors).toContain('Group name is required')
|
|
})
|
|
|
|
it('should fail with name over 50 characters', () => {
|
|
const result = validateGroup('a'.repeat(51))
|
|
expect(result.valid).toBe(false)
|
|
expect(result.errors).toContain('Group name must be 50 characters or less')
|
|
})
|
|
|
|
it('should pass with exactly 50 characters', () => {
|
|
const result = validateGroup('a'.repeat(50))
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('createTrashGroup', () => {
|
|
it('should return a group with fixed trash ID', () => {
|
|
const group = createTrashGroup()
|
|
expect(group.id).toBe(TRASH_GROUP_ID)
|
|
expect(group.name).toBe(TRASH_GROUP_NAME)
|
|
expect(group.color).toBe(TRASH_GROUP_COLOR)
|
|
expect(group.createdAt).toBeDefined()
|
|
})
|
|
})
|
|
|
|
describe('isTrashGroup', () => {
|
|
it('should return true for trash group ID', () => {
|
|
expect(isTrashGroup(TRASH_GROUP_ID)).toBe(true)
|
|
})
|
|
|
|
it('should return false for other IDs', () => {
|
|
expect(isTrashGroup('group-123')).toBe(false)
|
|
expect(isTrashGroup('')).toBe(false)
|
|
expect(isTrashGroup('__trash')).toBe(false)
|
|
expect(isTrashGroup('__trash__extra')).toBe(false)
|
|
})
|
|
})
|