Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.claude/
5 changes: 4 additions & 1 deletion lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ pre-commit:
lint:
glob: '*.{ts,tsx,js,json,md}'
run: npm run lint
coverage:
glob: '*.{ts,tsx}'
run: npm run test:coverage
test:
glob: '*.{ts,tsx}'
run: npm test
run: npm run test
87 changes: 82 additions & 5 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@ import { join } from 'node:path'

vi.mock('node:fs', () => ({
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
chmodSync: vi.fn(),
}))

vi.mock('node:os', () => ({
homedir: vi.fn(() => '/tmp/test-home'),
}))

import { readFileSync } from 'node:fs'
import { loadConfig, parseConfig, resolveTilde } from '../core/config.js'
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs'
import { loadConfig, parseConfig, resolveTilde, saveConfig, defaultConfig } from '../core/config.js'

const mockReadFileSync = vi.mocked(readFileSync)

Expand Down Expand Up @@ -63,7 +66,7 @@ describe('loadConfig', () => {
const config = loadConfig()
expect(config.mode).toBe('standalone')
expect(config.server).toEqual({ host: '127.0.0.1', port: 2274 })
expect(config.store.path).toBe(join('/tmp/test-home', '.2kc', 'secrets.json'))
expect(config.store.path).toBe(join('/tmp/test-home', '.2kc', 'secrets.enc.json'))
expect(config.discord).toBeUndefined()
expect(config.requireApproval).toEqual({})
expect(config.defaultRequireApproval).toBe(false)
Expand Down Expand Up @@ -100,7 +103,7 @@ describe('parseConfig', () => {
const config = parseConfig(minimalValid)
expect(config.mode).toBe('standalone')
expect(config.server).toEqual({ host: '127.0.0.1', port: 2274 })
expect(config.store.path).toBe(join('/tmp/test-home', '.2kc', 'secrets.json'))
expect(config.store.path).toBe(join('/tmp/test-home', '.2kc', 'secrets.enc.json'))
expect(config.discord).toBeUndefined()
expect(config.requireApproval).toEqual({})
expect(config.defaultRequireApproval).toBe(false)
Expand Down Expand Up @@ -173,7 +176,7 @@ describe('parseConfig', () => {

it('uses default store.path when missing', () => {
const config = parseConfig({})
expect(config.store.path).toBe(join('/tmp/test-home', '.2kc', 'secrets.json'))
expect(config.store.path).toBe(join('/tmp/test-home', '.2kc', 'secrets.enc.json'))
})

it('allows config with no discord section (all optional)', () => {
Expand Down Expand Up @@ -289,4 +292,78 @@ describe('parseConfig', () => {
it('throws if unlock is not an object', () => {
expect(() => parseConfig({ unlock: 'bad' })).toThrow('unlock must be an object')
})

it('throws if store.path is empty string', () => {
expect(() => parseConfig({ store: { path: '' } })).toThrow(
'store.path must be a non-empty string',
)
})

it('throws if store.path is non-string', () => {
expect(() => parseConfig({ store: { path: 123 } })).toThrow(
'store.path must be a non-empty string',
)
})

it('throws if store is not an object', () => {
expect(() => parseConfig({ store: 'bad' })).toThrow('store must be an object')
})

it('throws if server is not an object', () => {
expect(() => parseConfig({ server: 'bad' })).toThrow('server must be an object')
})

it('throws if server.host is empty string', () => {
expect(() => parseConfig({ server: { host: '' } })).toThrow(
'server.host must be a non-empty string',
)
})

it('throws if discord is not an object', () => {
expect(() => parseConfig({ discord: 'bad' })).toThrow('discord must be an object')
})
})

describe('saveConfig', () => {
const mockWriteFileSync = vi.mocked(writeFileSync)
const mockMkdirSync = vi.mocked(mkdirSync)
const mockChmodSync = vi.mocked(chmodSync)

afterEach(() => {
vi.restoreAllMocks()
})

it('writes config JSON to the specified path with 0600 permissions', () => {
const config = defaultConfig()
saveConfig(config, '/tmp/test-config/config.json')

expect(mockMkdirSync).toHaveBeenCalledWith('/tmp/test-config', { recursive: true })
expect(mockWriteFileSync).toHaveBeenCalledWith(
'/tmp/test-config/config.json',
expect.any(String),
'utf-8',
)
expect(mockChmodSync).toHaveBeenCalledWith('/tmp/test-config/config.json', 0o600)

// Verify JSON is valid and matches config
const writtenJson = mockWriteFileSync.mock.calls[0][1] as string
const parsed = JSON.parse(writtenJson)
expect(parsed.mode).toBe('standalone')
})
})

describe('loadConfig edge cases', () => {
afterEach(() => {
vi.restoreAllMocks()
})

it('re-throws non-ENOENT errors from readFileSync', () => {
const err = new Error('EACCES') as NodeJS.ErrnoException
err.code = 'EACCES'
mockReadFileSync.mockImplementation(() => {
throw err
})

expect(() => loadConfig()).toThrow('EACCES')
})
})
56 changes: 56 additions & 0 deletions src/__tests__/encrypted-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,62 @@ describe('EncryptedSecretStore', () => {
expect(store.getByRef('by-ref-test').ref).toBe('by-ref-test')
expect(store.getValueByRef('by-ref-test')).toBe('ref-value')
})

it('getValueByRef throws for non-existent ref', async () => {
const store = await createStore()
store.add('existing-ref', 'val')

expect(() => store.getValueByRef('no-such-ref')).toThrow(
'Secret with ref "no-such-ref" not found',
)
})
})

describe('resolveRef', () => {
it('resolves by UUID and returns uuid + decrypted value', async () => {
const store = await createStore()
const uuid = store.add('resolve-ref-test', 'the-value')

const result = store.resolveRef(uuid)
expect(result.uuid).toBe(uuid)
expect(result.value).toBe('the-value')
})

it('resolves by ref and returns uuid + decrypted value', async () => {
const store = await createStore()
const uuid = store.add('resolve-by-name', 'name-value')

const result = store.resolveRef('resolve-by-name')
expect(result.uuid).toBe(uuid)
expect(result.value).toBe('name-value')
})

it('falls back to ref lookup when UUID is not found', async () => {
const store = await createStore()
const uuid = store.add('fallback-ref', 'fb-value')

// Use a valid UUID format that does not exist in the store
// It should fall through UUID lookup and find by ref
const result = store.resolveRef('fallback-ref')
expect(result.uuid).toBe(uuid)
expect(result.value).toBe('fb-value')
})

it('throws when neither UUID nor ref matches', async () => {
const store = await createStore()

expect(() => store.resolveRef('nonexistent')).toThrow(
'Secret with ref "nonexistent" not found',
)
})

it('throws when locked', async () => {
const store = await createStore()
store.add('locked-resolve', 'val')
store.lock()

expect(() => store.resolveRef('locked-resolve')).toThrow('Store is locked')
})
})

describe('file format', () => {
Expand Down
31 changes: 31 additions & 0 deletions src/__tests__/grant.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,37 @@ describe('GrantManager', () => {
})
})

describe('getGrantByRequestId', () => {
it('returns grant matching the requestId', () => {
const manager = new GrantManager()
const request = makeApprovedRequest()
const grant = manager.createGrant(request)

const found = manager.getGrantByRequestId(request.id)
expect(found).toBeDefined()
expect(found!.id).toBe(grant.id)
expect(found!.requestId).toBe(request.id)
})

it('returns a copy (not the original)', () => {
const manager = new GrantManager()
const request = makeApprovedRequest()
manager.createGrant(request)

const found = manager.getGrantByRequestId(request.id)
if (found) {
found.used = true
}
const again = manager.getGrantByRequestId(request.id)
expect(again!.used).toBe(false)
})

it('returns undefined when no grant matches', () => {
const manager = new GrantManager()
expect(manager.getGrantByRequestId('nonexistent')).toBeUndefined()
})
})

describe('getGrantSecrets', () => {
it('returns secretUuids array for valid grant', () => {
const manager = new GrantManager()
Expand Down
Loading