hermes-explorigin 9758e80a02 Add TOTP (2FA) support to entries
- 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.
2026-08-27 01:11:48 +00:00

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)
})
})