Skip to content

Commit f0a2aa0

Browse files
authored
feat(desktop-main): add electron-store and safeStorage wrapper for secrets (#227)
Closes #150 ## Summary - Adds `electron-store ^11.0.2` as the persistent app-state backing store under `app.getPath('userData')/electron-store.json` - Introduces `SafeStorageService` wrapping Electron's `safeStorage.encryptString`/`decryptString` with graceful non-Electron degradation (plaintext passthrough in CI/tests) - Introduces `ElectronStoreService` with a typed `AppStoreSchema` (wizardCompleted, activeCloud, aws.*) and transparent encryption of secret fields (accessKeyId, secretAccessKey) via SafeStorageService ## Changes ``` app/packages/desktop-main/package.json | 1 + electron-store ^11.0.2 added app/packages/desktop-main/src/app.module.ts | 4 + both services registered as providers src/services/ElectronStoreService.ts | 161 +++ new service + AppStoreSchema src/services/SafeStorageService.ts | 134 +++ new service src/services/ElectronStoreService.test.ts | 233 +++ 12 unit tests src/services/SafeStorageService.test.ts | 209 +++ 16 unit tests (inc. keychain-unavailable path) ``` ## Test plan - [x] 553 unit tests pass (`npm run app:test`) - [x] Lint passes (0 errors) - [x] TypeScript clean on new files - [ ] Manual: app state (wizardCompleted, activeCloud, aws.region/profile) survives an Electron restart - [ ] Manual: accessKeyId/secretAccessKey unreadable without OS keychain (base64 blob on disk) - [ ] Manual: graceful degradation on Linux CI runner without libsecret (plaintext passthrough with warning log) ## Design notes - `SafeStorageService.encrypt()`/`decrypt()` gate on `isAvailable()` (Electron present **and** OS keychain unlocked), not just `readIsElectron()` — prevents throws on Linux without libsecret - `aws.region`/`aws.profile` are optional in `AppStoreSchema` so secrets can be written before the wizard completes region/profile selection - Outside Electron both services degrade to an in-memory Map; the public API is identical so callers need no branching <details> <summary>Mission log</summary> ``` pre-launch ✓ 6 tasks planned liftoff ✓ 6/6 tasks passed (Apollo→Faraday) systems-check ✓ 3 findings fixed (Glenn, Hadfield, Irwin) ``` </details> 🤖 Generated via /mission
1 parent e671f8d commit f0a2aa0

7 files changed

Lines changed: 1004 additions & 2 deletions

File tree

app/packages/desktop-main/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"@nestjs/core": "^10.4.0",
2323
"@nestjs/microservices": "^10.4.0",
2424
"@nestjs/platform-express": "^10.4.0",
25+
"electron-store": "^11.0.2",
2526
"express": "^4.19.2",
2627
"fix-path": "^4.0.0",
2728
"nestjs-electron-ipc-transport": "^1.0.2",

app/packages/desktop-main/src/app.module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { EnvController } from './controllers/env.controller.js';
1515
import { DiagnosticsController } from './controllers/diagnostics.controller.js';
1616
import { ApiTokenGuard } from './guards/api-token.guard.js';
1717
import { DiagnosticsService, DIAGNOSTICS_LOG_DIR } from './services/DiagnosticsService.js';
18+
import { SafeStorageService } from './services/SafeStorageService.js';
19+
import { ElectronStoreService } from './services/ElectronStoreService.js';
1820

1921
/**
2022
* Root Nest module. Wires the feature modules (`AwsModule`, `DiscordModule`) to
@@ -50,6 +52,8 @@ import { DiagnosticsService, DIAGNOSTICS_LOG_DIR } from './services/DiagnosticsS
5052
},
5153
},
5254
DiagnosticsService,
55+
SafeStorageService,
56+
ElectronStoreService,
5357
],
5458
})
5559
export class AppModule {}
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
/**
2+
* Unit tests for ElectronStoreService.
3+
*
4+
* `electron-store` is mocked at the module level so no real disk I/O or
5+
* Electron native modules are ever touched. Protected methods
6+
* (`readIsElectron`, `createStore`) are stubbed via `vi.spyOn` on the
7+
* prototype before each Electron-path construction so the constructor takes
8+
* the right branch.
9+
*/
10+
import 'reflect-metadata';
11+
import { describe, it, expect, vi, beforeEach } from 'vitest';
12+
import type Store from 'electron-store';
13+
14+
vi.mock('../logger.js', () => ({
15+
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
16+
}));
17+
18+
vi.mock('electron-store', () => {
19+
const MockStore = vi.fn().mockImplementation(() => ({
20+
get: vi.fn(),
21+
set: vi.fn(),
22+
}));
23+
return { default: MockStore };
24+
});
25+
26+
import { ElectronStoreService, type AppStoreSchema } from './ElectronStoreService.js';
27+
import { SafeStorageService } from './SafeStorageService.js';
28+
29+
// ---------------------------------------------------------------------------
30+
// Helpers
31+
// ---------------------------------------------------------------------------
32+
33+
/**
34+
* Creates a `SafeStorageService` whose `encrypt` / `decrypt` methods are
35+
* identity functions by default (outside-Electron degraded path).
36+
*/
37+
function makeSafeStorage(): SafeStorageService {
38+
return new SafeStorageService();
39+
}
40+
41+
/**
42+
* Builds a minimal mock `Store<AppStoreSchema>` compatible with what
43+
* `ElectronStoreService` calls on it.
44+
*/
45+
function makeMockStore(): Store<AppStoreSchema> {
46+
return {
47+
get: vi.fn(),
48+
set: vi.fn(),
49+
} as unknown as Store<AppStoreSchema>;
50+
}
51+
52+
// ---------------------------------------------------------------------------
53+
// Non-Electron path (Map fallback)
54+
// ---------------------------------------------------------------------------
55+
56+
describe('ElectronStoreService — non-Electron path (Map fallback)', () => {
57+
let service: ElectronStoreService;
58+
let safeStorage: SafeStorageService;
59+
60+
beforeEach(() => {
61+
safeStorage = makeSafeStorage();
62+
// process.versions['electron'] is not set in Vitest/Node, so the Map
63+
// fallback is used automatically — no spy needed.
64+
service = new ElectronStoreService(safeStorage);
65+
vi.clearAllMocks();
66+
});
67+
68+
it('should use Map fallback when not running in Electron', () => {
69+
expect(service.isElectron()).toBe(false);
70+
expect(service.get('wizardCompleted')).toBeUndefined();
71+
});
72+
73+
it('should store and retrieve a value in Map fallback', () => {
74+
service.set('wizardCompleted', true);
75+
76+
expect(service.get('wizardCompleted')).toBe(true);
77+
});
78+
79+
it('should store and retrieve a nested object in Map fallback', () => {
80+
const awsValue: AppStoreSchema['aws'] = { region: 'us-east-1', profile: 'default' };
81+
service.set('aws', awsValue);
82+
83+
expect(service.get('aws')).toEqual(awsValue);
84+
});
85+
});
86+
87+
// ---------------------------------------------------------------------------
88+
// Electron path (mocked Store)
89+
// ---------------------------------------------------------------------------
90+
91+
describe('ElectronStoreService — Electron path (mocked Store)', () => {
92+
let service: ElectronStoreService;
93+
let safeStorage: SafeStorageService;
94+
let mockStore: Store<AppStoreSchema>;
95+
96+
beforeEach(() => {
97+
safeStorage = makeSafeStorage();
98+
mockStore = makeMockStore();
99+
100+
// Stub prototype BEFORE construction so the constructor takes the Electron branch.
101+
vi.spyOn(
102+
ElectronStoreService.prototype as unknown as { readIsElectron(): boolean },
103+
'readIsElectron',
104+
).mockReturnValue(true);
105+
vi.spyOn(
106+
ElectronStoreService.prototype as unknown as { createStore(): Store<AppStoreSchema> },
107+
'createStore',
108+
).mockReturnValue(mockStore);
109+
110+
service = new ElectronStoreService(safeStorage);
111+
vi.clearAllMocks();
112+
});
113+
114+
it('should call store.get when running in Electron', () => {
115+
(mockStore.get as ReturnType<typeof vi.fn>).mockReturnValue(true);
116+
117+
const result = service.get('wizardCompleted');
118+
119+
expect(mockStore.get).toHaveBeenCalledWith('wizardCompleted');
120+
expect(result).toBe(true);
121+
});
122+
123+
it('should call store.set when running in Electron', () => {
124+
service.set('wizardCompleted', true);
125+
126+
expect(mockStore.set).toHaveBeenCalledWith('wizardCompleted', true);
127+
});
128+
});
129+
130+
// ---------------------------------------------------------------------------
131+
// Secret field — setSecretAccessKeyId / getSecretAccessKeyId
132+
// ---------------------------------------------------------------------------
133+
134+
describe('ElectronStoreService — setSecretAccessKeyId / getSecretAccessKeyId', () => {
135+
let service: ElectronStoreService;
136+
let safeStorage: SafeStorageService;
137+
138+
beforeEach(() => {
139+
safeStorage = makeSafeStorage();
140+
service = new ElectronStoreService(safeStorage);
141+
vi.clearAllMocks();
142+
});
143+
144+
it('should encrypt accessKeyId before storing', () => {
145+
vi.spyOn(safeStorage, 'encrypt').mockReturnValue('enc-key-id');
146+
147+
service.setSecretAccessKeyId('AKID123');
148+
149+
expect(safeStorage.encrypt).toHaveBeenCalledWith('AKID123');
150+
const stored = service.get('aws');
151+
expect(stored?.accessKeyId).toBe('enc-key-id');
152+
});
153+
154+
it('should decrypt accessKeyId when reading', () => {
155+
service.set('aws', { region: 'us-east-1', profile: 'default', accessKeyId: 'enc-key-id' });
156+
vi.spyOn(safeStorage, 'decrypt').mockReturnValue('AKID123');
157+
158+
const result = service.getSecretAccessKeyId();
159+
160+
expect(safeStorage.decrypt).toHaveBeenCalledWith('enc-key-id');
161+
expect(result).toBe('AKID123');
162+
});
163+
164+
it('should return undefined for accessKeyId when not stored', () => {
165+
expect(service.getSecretAccessKeyId()).toBeUndefined();
166+
});
167+
});
168+
169+
// ---------------------------------------------------------------------------
170+
// Secret field — setSecretAccessKey / getSecretAccessKey
171+
// ---------------------------------------------------------------------------
172+
173+
describe('ElectronStoreService — setSecretAccessKey / getSecretAccessKey', () => {
174+
let service: ElectronStoreService;
175+
let safeStorage: SafeStorageService;
176+
177+
beforeEach(() => {
178+
safeStorage = makeSafeStorage();
179+
service = new ElectronStoreService(safeStorage);
180+
vi.clearAllMocks();
181+
});
182+
183+
it('should encrypt secretAccessKey before storing', () => {
184+
vi.spyOn(safeStorage, 'encrypt').mockReturnValue('enc-secret-key');
185+
186+
service.setSecretAccessKey('MY_SECRET');
187+
188+
expect(safeStorage.encrypt).toHaveBeenCalledWith('MY_SECRET');
189+
const stored = service.get('aws');
190+
expect(stored?.secretAccessKey).toBe('enc-secret-key');
191+
});
192+
193+
it('should decrypt secretAccessKey when reading', () => {
194+
service.set('aws', { region: 'us-east-1', profile: 'default', secretAccessKey: 'enc-secret-key' });
195+
vi.spyOn(safeStorage, 'decrypt').mockReturnValue('MY_SECRET');
196+
197+
const result = service.getSecretAccessKey();
198+
199+
expect(safeStorage.decrypt).toHaveBeenCalledWith('enc-secret-key');
200+
expect(result).toBe('MY_SECRET');
201+
});
202+
203+
it('should return undefined for secretAccessKey when not stored', () => {
204+
expect(service.getSecretAccessKey()).toBeUndefined();
205+
});
206+
});
207+
208+
// ---------------------------------------------------------------------------
209+
// Round-trip
210+
// ---------------------------------------------------------------------------
211+
212+
describe('ElectronStoreService — round-trip', () => {
213+
let service: ElectronStoreService;
214+
let safeStorage: SafeStorageService;
215+
216+
beforeEach(() => {
217+
safeStorage = makeSafeStorage();
218+
service = new ElectronStoreService(safeStorage);
219+
vi.clearAllMocks();
220+
});
221+
222+
it('should encrypt and decrypt accessKeyId in a round-trip', () => {
223+
vi.spyOn(safeStorage, 'encrypt').mockImplementation((plaintext: string) => `enc-${plaintext}`);
224+
vi.spyOn(safeStorage, 'decrypt').mockImplementation((ciphertext: string) =>
225+
ciphertext.startsWith('enc-') ? ciphertext.slice(4) : ciphertext,
226+
);
227+
228+
service.setSecretAccessKeyId('AKID123');
229+
const result = service.getSecretAccessKeyId();
230+
231+
expect(result).toBe('AKID123');
232+
});
233+
});

0 commit comments

Comments
 (0)