OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / test / unit / notebook-crypto.test.js

239 lines · javascript ref:
import test from 'brittle'
import { MockRegistry } from '../helpers/fixtures.js'
import { NotebookCrypto } from '../../lib/notebook-crypto.js'

// ==================== FIELD ENCRYPTION ====================

test('encryptField / decryptField — round-trip', async (t) => {
  const registry = new MockRegistry()
  const crypto = new NotebookCrypto(registry)

  // Enable encryption to get a notebook key
  const key = await crypto.enableEncryption('test-notebook', 'password123')

  const plaintext = 'Hello, this is a secret note!'
  const encrypted = crypto.encryptField(plaintext, key)

  // Encrypted is hex nonce:ciphertext
  t.ok(encrypted.includes(':'), 'encrypted contains nonce:ciphertext separator')
  t.not(encrypted, plaintext, 'encrypted differs from plaintext')

  const decrypted = crypto.decryptField(encrypted, key)
  t.is(decrypted, plaintext, 'decrypted matches original')
})

test('encryptField — different nonces per call', async (t) => {
  const registry = new MockRegistry()
  const crypto = new NotebookCrypto(registry)

  const key = await crypto.enableEncryption('nonce-test', 'password')

  const enc1 = crypto.encryptField('same content', key)
  const enc2 = crypto.encryptField('same content', key)

  // Same plaintext, different nonces → different ciphertexts
  t.not(enc1, enc2, 'two encryptions produce different ciphertexts')

  // Both decrypt to same plaintext
  t.is(crypto.decryptField(enc1, key), 'same content')
  t.is(crypto.decryptField(enc2, key), 'same content')
})

test('encryptField — handles empty string', async (t) => {
  const registry = new MockRegistry()
  const crypto = new NotebookCrypto(registry)

  const key = await crypto.enableEncryption('empty-test', 'password')

  const encrypted = crypto.encryptField('', key)
  const decrypted = crypto.decryptField(encrypted, key)
  t.is(decrypted, '', 'empty string round-trips')
})

test('encryptField — handles unicode', async (t) => {
  const registry = new MockRegistry()
  const crypto = new NotebookCrypto(registry)

  const key = await crypto.enableEncryption('unicode-test', 'password')

  const plaintext = 'Hello World! Emoji: \u{1F680}\u{1F30D} CJK: \u4F60\u597D Diacritics: caf\u00E9'
  const encrypted = crypto.encryptField(plaintext, key)
  const decrypted = crypto.decryptField(encrypted, key)
  t.is(decrypted, plaintext, 'unicode round-trips correctly')
})

// ==================== KEY MANAGEMENT ====================

test('enableEncryption — stores key in registry', async (t) => {
  const registry = new MockRegistry()
  const crypto = new NotebookCrypto(registry)

  const key = await crypto.enableEncryption('my-notebook', 'secretpass')

  t.ok(key, 'returns notebook key')
  t.is(key.length, 32, 'key is 32 bytes')

  // Registry has the entry
  const entry = await registry.get('notebook-key:my-notebook')
  t.ok(entry, 'key record stored in registry')
  t.ok(entry.value.salt, 'has salt')
  t.ok(entry.value.nonce, 'has nonce')
  t.ok(entry.value.ciphertext, 'has ciphertext')
  t.ok(entry.value.opslimit, 'has opslimit')
  t.ok(entry.value.memlimit, 'has memlimit')
})

test('enableEncryption — rejects duplicate', async (t) => {
  const registry = new MockRegistry()
  const crypto = new NotebookCrypto(registry)

  await crypto.enableEncryption('dup-test', 'password')

  try {
    await crypto.enableEncryption('dup-test', 'password')
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err.message.includes('already has encryption'), 'throws on duplicate')
  }
})

test('getNotebookKey — correct password returns key', async (t) => {
  const registry = new MockRegistry()
  const crypto = new NotebookCrypto(registry)

  const originalKey = await crypto.enableEncryption('key-test', 'correct-password')
  const retrievedKey = await crypto.getNotebookKey('key-test', 'correct-password')

  t.alike(retrievedKey, originalKey, 'retrieved key matches original')
})

test('getNotebookKey — wrong password throws', async (t) => {
  const registry = new MockRegistry()
  const crypto = new NotebookCrypto(registry)

  await crypto.enableEncryption('wrong-pw', 'correct')

  try {
    await crypto.getNotebookKey('wrong-pw', 'incorrect')
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err.message.includes('wrong password'), 'throws on wrong password')
  }
})

test('getNotebookKey — nonexistent notebook throws', async (t) => {
  const registry = new MockRegistry()
  const crypto = new NotebookCrypto(registry)

  try {
    await crypto.getNotebookKey('nonexistent', 'password')
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err.message.includes('No encryption key'), 'throws for nonexistent')
  }
})

test('isEncrypted — returns true/false', async (t) => {
  const registry = new MockRegistry()
  const crypto = new NotebookCrypto(registry)

  t.is(await crypto.isEncrypted('not-encrypted'), false)

  await crypto.enableEncryption('is-encrypted', 'password')
  t.is(await crypto.isEncrypted('is-encrypted'), true)
})

test('disableEncryption — removes key, requires correct password', async (t) => {
  const registry = new MockRegistry()
  const crypto = new NotebookCrypto(registry)

  const key = await crypto.enableEncryption('disable-test', 'mypassword')

  // Wrong password fails
  try {
    await crypto.disableEncryption('disable-test', 'wrongpassword')
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err.message.includes('wrong password'))
  }

  // Key still exists
  t.is(await crypto.isEncrypted('disable-test'), true)

  // Correct password removes it
  const returnedKey = await crypto.disableEncryption('disable-test', 'mypassword')
  t.alike(returnedKey, key, 'returns the notebook key')
  t.is(await crypto.isEncrypted('disable-test'), false)
})

test('enableEncryption — accepts string password', async (t) => {
  const registry = new MockRegistry()
  const crypto = new NotebookCrypto(registry)

  const key = await crypto.enableEncryption('str-pw', 'string-password')
  const retrieved = await crypto.getNotebookKey('str-pw', 'string-password')
  t.alike(retrieved, key)
})

test('enableEncryption — accepts Buffer password', async (t) => {
  const registry = new MockRegistry()
  const crypto = new NotebookCrypto(registry)

  const pwd = Buffer.from('buffer-password')
  const key = await crypto.enableEncryption('buf-pw', pwd)
  const retrieved = await crypto.getNotebookKey('buf-pw', pwd)
  t.alike(retrieved, key)
})

// ==================== INTEGRATION: ENCRYPT + DECRYPT FIELD ====================

test('full flow — enable, encrypt fields, retrieve key, decrypt fields', async (t) => {
  const registry = new MockRegistry()
  const crypto = new NotebookCrypto(registry)

  const password = 'my-secure-password'
  const key = await crypto.enableEncryption('full-flow', password)

  // Encrypt some fields
  const encTitle = crypto.encryptField('Secret Title', key)
  const encContent = crypto.encryptField('This is the secret content of my note.', key)

  // Later — retrieve key with password
  const key2 = await crypto.getNotebookKey('full-flow', password)

  // Decrypt
  t.is(crypto.decryptField(encTitle, key2), 'Secret Title')
  t.is(crypto.decryptField(encContent, key2), 'This is the secret content of my note.')
})

test('decryptField — wrong key fails', async (t) => {
  const registry = new MockRegistry()
  const crypto = new NotebookCrypto(registry)

  const key1 = await crypto.enableEncryption('key1', 'pass1')
  const key2 = await crypto.enableEncryption('key2', 'pass2')

  const encrypted = crypto.encryptField('secret', key1)

  try {
    crypto.decryptField(encrypted, key2)
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err.message.includes('decryption failed') || err.message.includes('wrong key'), 'wrong key throws')
  }
})

test('decryptField — invalid format throws', async (t) => {
  const registry = new MockRegistry()
  const crypto = new NotebookCrypto(registry)

  const key = await crypto.enableEncryption('fmt-test', 'password')

  try {
    crypto.decryptField('not-valid-encrypted-data', key)
    t.fail('should have thrown')
  } catch (err) {
    t.ok(err.message, 'throws on invalid format')
  }
})