Skip to content

Commit be9ff6c

Browse files
msujawsclaude
andcommitted
fix(test): use dependency injection for storage in ApiKeyStorage
- Add Storage interface and inject it via constructor - Remove all global mocking (vi.stubGlobal) from tests - Use simple in-memory storage mock for deterministic testing - This eliminates environment-specific issues with jsdom in CI Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 504bce3 commit be9ff6c

File tree

2 files changed

+49
-48
lines changed

2 files changed

+49
-48
lines changed

src/lib/storage/api-key-storage.test.ts

Lines changed: 29 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,32 @@
1-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2-
import { ApiKeyStorage } from './api-key-storage'
1+
import { describe, it, expect, beforeEach } from 'vitest'
2+
import { ApiKeyStorage, type Storage } from './api-key-storage'
33

44
describe('ApiKeyStorage', () => {
55
let storage: ApiKeyStorage
6-
let localStorageMock: Record<string, string>
6+
let mockStorageData: Record<string, string>
7+
let mockStorage: Storage
78
const testKeyMaterial = 'test-key-material-for-encryption'
89

910
beforeEach(() => {
10-
// Mock localStorage
11-
localStorageMock = {}
12-
13-
vi.stubGlobal('localStorage', {
11+
// Create in-memory storage mock (no global stubbing needed)
12+
mockStorageData = {}
13+
mockStorage = {
1414
// eslint-disable-next-line unicorn/no-null
15-
getItem: vi.fn((key: string) => localStorageMock[key] ?? null), // null is correct for localStorage API
16-
setItem: vi.fn((key: string, value: string) => {
17-
localStorageMock[key] = value
18-
}),
19-
removeItem: vi.fn((key: string) => {
15+
getItem: (key: string) => mockStorageData[key] ?? null,
16+
setItem: (key: string, value: string) => {
17+
mockStorageData[key] = value
18+
},
19+
removeItem: (key: string) => {
2020
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
21-
delete localStorageMock[key]
22-
}),
23-
clear: vi.fn(() => {
24-
localStorageMock = {}
25-
}),
26-
length: 0,
27-
key: vi.fn(),
21+
delete mockStorageData[key]
22+
},
23+
}
24+
25+
// Inject both key material and storage for deterministic testing
26+
storage = new ApiKeyStorage({
27+
keyMaterial: testKeyMaterial,
28+
storage: mockStorage,
2829
})
29-
30-
// Use constructor parameter for consistent key derivation in tests
31-
storage = new ApiKeyStorage(testKeyMaterial)
32-
})
33-
34-
afterEach(() => {
35-
vi.restoreAllMocks()
36-
vi.unstubAllGlobals()
3730
})
3831

3932
describe('saveApiKey', () => {
@@ -42,9 +35,7 @@ describe('ApiKeyStorage', () => {
4235

4336
await storage.saveApiKey(apiKey)
4437

45-
// eslint-disable-next-line @typescript-eslint/unbound-method
46-
expect(localStorage.setItem).toHaveBeenCalled()
47-
const savedValue = localStorageMock['bugzilla_api_key']
38+
const savedValue = mockStorageData['bugzilla_api_key']
4839
expect(savedValue).toBeDefined()
4940
expect(savedValue).not.toBe(apiKey) // Should be encrypted
5041
})
@@ -53,10 +44,10 @@ describe('ApiKeyStorage', () => {
5344
const apiKey = 'test-api-key-123'
5445

5546
await storage.saveApiKey(apiKey)
56-
const firstSave = localStorageMock['bugzilla_api_key']
47+
const firstSave = mockStorageData['bugzilla_api_key']
5748

5849
await storage.saveApiKey(apiKey)
59-
const secondSave = localStorageMock['bugzilla_api_key']
50+
const secondSave = mockStorageData['bugzilla_api_key']
6051

6152
// Should have different encrypted values due to random IV
6253
expect(firstSave).not.toBe(secondSave)
@@ -65,8 +56,7 @@ describe('ApiKeyStorage', () => {
6556
it('should handle empty API key', async () => {
6657
await storage.saveApiKey('')
6758

68-
// eslint-disable-next-line @typescript-eslint/unbound-method
69-
expect(localStorage.setItem).toHaveBeenCalled()
59+
expect(mockStorageData['bugzilla_api_key']).toBeDefined()
7060
})
7161
})
7262

@@ -87,7 +77,7 @@ describe('ApiKeyStorage', () => {
8777
})
8878

8979
it('should return undefined when localStorage value is invalid', async () => {
90-
localStorageMock['bugzilla_api_key'] = 'invalid-encrypted-data'
80+
mockStorageData['bugzilla_api_key'] = 'invalid-encrypted-data'
9181

9282
const retrieved = await storage.getApiKey()
9383

@@ -96,7 +86,7 @@ describe('ApiKeyStorage', () => {
9686

9787
it('should handle decryption errors gracefully', async () => {
9888
// Set malformed encrypted data
99-
localStorageMock['bugzilla_api_key'] = JSON.stringify({
89+
mockStorageData['bugzilla_api_key'] = JSON.stringify({
10090
iv: 'invalid-iv',
10191
encryptedData: 'invalid-data',
10292
})
@@ -113,9 +103,7 @@ describe('ApiKeyStorage', () => {
113103

114104
storage.clearApiKey()
115105

116-
// eslint-disable-next-line @typescript-eslint/unbound-method
117-
expect(localStorage.removeItem).toHaveBeenCalledWith('bugzilla_api_key')
118-
expect(localStorageMock['bugzilla_api_key']).toBeUndefined()
106+
expect(mockStorageData['bugzilla_api_key']).toBeUndefined()
119107
})
120108

121109
it('should not throw error when no key exists', () => {
@@ -144,10 +132,10 @@ describe('ApiKeyStorage', () => {
144132
describe('encryption', () => {
145133
it('should encrypt different keys to different values', async () => {
146134
await storage.saveApiKey('key1')
147-
const encrypted1 = localStorageMock['bugzilla_api_key']
135+
const encrypted1 = mockStorageData['bugzilla_api_key']
148136

149137
await storage.saveApiKey('key2')
150-
const encrypted2 = localStorageMock['bugzilla_api_key']
138+
const encrypted2 = mockStorageData['bugzilla_api_key']
151139

152140
expect(encrypted1).not.toBe(encrypted2)
153141
})

src/lib/storage/api-key-storage.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,29 @@ interface EncryptedData {
1010
encryptedData: string // Base64 encoded encrypted data
1111
}
1212

13+
/**
14+
* Storage interface for dependency injection (enables testing)
15+
*/
16+
export interface Storage {
17+
getItem(key: string): string | null
18+
setItem(key: string, value: string): void
19+
removeItem(key: string): void
20+
}
21+
1322
export class ApiKeyStorage {
1423
private encoder = new TextEncoder()
1524
private decoder = new TextDecoder()
1625
private keyMaterialOverride?: string
26+
private storage: Storage
1727

1828
/**
19-
* @param keyMaterialOverride - Optional override for key derivation (for testing only)
29+
* @param options - Optional configuration for testing
30+
* @param options.keyMaterial - Override for key derivation
31+
* @param options.storage - Override for storage backend (defaults to localStorage)
2032
*/
21-
constructor(keyMaterialOverride?: string) {
22-
this.keyMaterialOverride = keyMaterialOverride
33+
constructor(options?: { keyMaterial?: string; storage?: Storage }) {
34+
this.keyMaterialOverride = options?.keyMaterial
35+
this.storage = options?.storage ?? localStorage
2336
}
2437

2538
/**
@@ -127,14 +140,14 @@ export class ApiKeyStorage {
127140
*/
128141
async saveApiKey(apiKey: string): Promise<void> {
129142
const encrypted = await this.encrypt(apiKey)
130-
localStorage.setItem(STORAGE_KEY, JSON.stringify(encrypted))
143+
this.storage.setItem(STORAGE_KEY, JSON.stringify(encrypted))
131144
}
132145

133146
/**
134147
* Retrieve API key from localStorage (decrypted)
135148
*/
136149
async getApiKey(): Promise<string | undefined> {
137-
const stored = localStorage.getItem(STORAGE_KEY)
150+
const stored = this.storage.getItem(STORAGE_KEY)
138151

139152
if (!stored) {
140153
return undefined
@@ -153,13 +166,13 @@ export class ApiKeyStorage {
153166
* Remove API key from localStorage
154167
*/
155168
clearApiKey(): void {
156-
localStorage.removeItem(STORAGE_KEY)
169+
this.storage.removeItem(STORAGE_KEY)
157170
}
158171

159172
/**
160173
* Check if API key exists in localStorage
161174
*/
162175
hasApiKey(): boolean {
163-
return localStorage.getItem(STORAGE_KEY) !== null
176+
return this.storage.getItem(STORAGE_KEY) !== null
164177
}
165178
}

0 commit comments

Comments
 (0)