feat: implement core-storage package with unified promise-based interface and encrypted-at-rest support
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SpanStatusCode } from '@opentelemetry/api';
|
||||
import type { InternalAxiosRequestConfig, AxiosResponse, AxiosError, AxiosHeaders } from 'axios';
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# @repo/core-storage
|
||||
|
||||
The **Enterprise-grade storage engine** for the monorepo.
|
||||
|
||||
This package provides a unified, Promise-based interface for interacting with browser storage (`localStorage` and `IndexedDB`). It enforces strict type safety, prevents key collisions, and automatically provides **AES encryption at rest** for sensitive payloads (like access tokens) using `@repo/utils`.
|
||||
|
||||
---
|
||||
|
||||
## π― Primary Goals & Separation of Concerns
|
||||
|
||||
* **Separation from `@repo/core-api`**: Storage is a fundamental primitive. While the API client uses storage (to retrieve tokens), storage itself does not need to know about HTTP requests.
|
||||
* **Dual Backend Strategy**:
|
||||
* `secureStorage` (localStorage): Ideal for small, synchronous-like data (tokens, user preferences).
|
||||
* `IndexedDBService`: Built for large, asynchronous data (offline drafts, cached API responses, blobs) without the 5MB size limit.
|
||||
* **Security by Default**: Developers don't need to manually encrypt/decrypt data. If a key is marked as sensitive, the library handles AES encryption transparently.
|
||||
|
||||
---
|
||||
|
||||
## β¨ Key Features
|
||||
|
||||
| Feature | Description |
|
||||
|---|---|
|
||||
| π **Selective Encryption** | Uses `@repo/utils` `EncryptionUtils` to automatically AES-encrypt payloads whose keys are listed in `ENCRYPTED_KEYS`. |
|
||||
| π‘οΈ **Type-Safe Keys** | All keys must be registered in `storage.key.ts`. Prevents typos and key collisions across the monorepo. |
|
||||
| π **Unified Promise API** | Both `localStorage` and `IndexedDB` implement the same async `IStorageService` interface. |
|
||||
| 𧬠**Strict Generics** | Read and write operations enforce payload types via generics (e.g., `getItem<UserProfile>('user_profile')`). |
|
||||
| π©Ή **Corrupt Data Resilience** | If parsing or decryption fails (e.g., tampered data), the corrupt entry is safely removed and returns `null`. |
|
||||
|
||||
---
|
||||
|
||||
## π Usage Examples
|
||||
|
||||
### 1. Secure Local Storage (Tokens, Profile)
|
||||
|
||||
Use `demoSecureStorage` (or your app's configured instance) for small payloads. If the key is in `ENCRYPTED_KEYS`, it will be encrypted at rest.
|
||||
|
||||
```typescript
|
||||
import { demoSecureStorage, StorageKey } from '@repo/core-storage';
|
||||
import type { UserProfile } from '@/types';
|
||||
|
||||
// CREATE / UPDATE
|
||||
// If StorageKey.USER_PROFILE is in ENCRYPTED_KEYS, this is AES-encrypted automatically.
|
||||
await demoSecureStorage.setItem(StorageKey.USER_PROFILE, {
|
||||
id: 1,
|
||||
name: 'Firman',
|
||||
role: 'admin'
|
||||
});
|
||||
|
||||
// READ
|
||||
const profile = await demoSecureStorage.getItem<UserProfile>(StorageKey.USER_PROFILE);
|
||||
if (profile) {
|
||||
console.log('Welcome back,', profile.name);
|
||||
}
|
||||
|
||||
// DELETE
|
||||
await demoSecureStorage.removeItem(StorageKey.USER_PROFILE);
|
||||
```
|
||||
|
||||
### 2. IndexedDB (Offline Data, Large Payloads)
|
||||
|
||||
Use the pre-configured `demoIndexedDB` (or instantiate your own) for large, asynchronous data. It uses the exact same API and encryption pipeline.
|
||||
|
||||
```typescript
|
||||
import { demoIndexedDB } from '@repo/core-storage';
|
||||
|
||||
interface DraftData {
|
||||
id: string;
|
||||
content: string;
|
||||
lastModified: number;
|
||||
}
|
||||
|
||||
// Save a large draft offline
|
||||
await demoIndexedDB.setItem('offline_draft_123', {
|
||||
id: '123',
|
||||
content: 'Huge text content...',
|
||||
lastModified: Date.now()
|
||||
});
|
||||
|
||||
// Retrieve the draft
|
||||
const draft = await demoIndexedDB.getItem<DraftData>('offline_draft_123');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## π Adding New Keys
|
||||
|
||||
To maintain type safety and avoid collisions, **all** `localStorage` keys must be registered in `packages/core-storage/src/storage.key.ts`.
|
||||
|
||||
### 1. Register the Key
|
||||
|
||||
Add your key to the `StorageKey` object:
|
||||
|
||||
```typescript
|
||||
export const StorageKey = {
|
||||
// ... existing keys
|
||||
MY_NEW_FEATURE: 'my_new_feature_key',
|
||||
} as const;
|
||||
```
|
||||
|
||||
### 2. Define Encryption (If Needed)
|
||||
|
||||
If the data stored under this key is sensitive (e.g., PII, tokens, financials), add it to the `ENCRYPTED_KEYS` set.
|
||||
|
||||
```typescript
|
||||
export const ENCRYPTED_KEYS: ReadonlySet<string> = new Set<string>([
|
||||
StorageKey.ACCESS_TOKEN,
|
||||
StorageKey.REFRESH_TOKEN,
|
||||
StorageKey.USER_PROFILE,
|
||||
StorageKey.MY_NEW_FEATURE, // <--- Now encrypted at rest!
|
||||
]);
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> If you add an existing plain-text key to `ENCRYPTED_KEYS`, existing users will experience a parsing failure on their next session (because the library expects encrypted data but finds plain text). The resilient parser will catch this and clear the key, effectively logging them out or resetting the preference.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@repo/core-storage",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"lint": "eslint \"**/*.ts\"",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest --watch",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/utils": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"eslint": "^8.57.1",
|
||||
"typescript": "5.5.4",
|
||||
"vitest": "^4.0.17"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// βββ Interfaces βββββββββββββββββββββββββββββββββββββββββββββββββ
|
||||
export type { IStorageService } from './storage.interface';
|
||||
|
||||
// βββ Key Registry βββββββββββββββββββββββββββββββββββββββββββββββ
|
||||
export { StorageKey, ENCRYPTED_KEYS } from './storage.key';
|
||||
export type { StorageKeyValue } from './storage.key';
|
||||
|
||||
// βββ Service Classes ββββββββββββββββββββββββββββββββββββββββββββ
|
||||
export { LocalStorageService } from './local-storage.service';
|
||||
export { IndexedDBService } from './indexed-db.service';
|
||||
|
||||
// βββ Pre-configured Instances βββββββββββββββββββββββββββββββββββ
|
||||
|
||||
import { LocalStorageService } from './local-storage.service';
|
||||
import { IndexedDBService } from './indexed-db.service';
|
||||
|
||||
/**
|
||||
* Default secure localStorage instance.
|
||||
*
|
||||
* Keys listed in `ENCRYPTED_KEYS` are automatically encrypted via
|
||||
* `@repo/utils` `EncryptionUtils`. All other keys are plain JSON.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { demoSecureStorage, StorageKey } from '@repo/core-storage';
|
||||
*
|
||||
* await demoSecureStorage.setItem(StorageKey.ACCESS_TOKEN, 'eyJhbGci...');
|
||||
* const token = await demoSecureStorage.getItem<string>(StorageKey.ACCESS_TOKEN);
|
||||
* ```
|
||||
*/
|
||||
export const demoSecureStorage = new LocalStorageService();
|
||||
|
||||
/**
|
||||
* Default IndexedDB instance.
|
||||
*
|
||||
* Uses `app_db` database with a `kv_store` object store.
|
||||
* Sensitive keys are encrypted at rest using the same
|
||||
* `EncryptionUtils` pipeline as `demoSecureStorage`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { demoIndexedDB } from '@repo/core-storage';
|
||||
*
|
||||
* await demoIndexedDB.setItem('offline_draft', { content: '...' });
|
||||
* const draft = await demoIndexedDB.getItem<Draft>('offline_draft');
|
||||
* ```
|
||||
*/
|
||||
export const demoIndexedDB = new IndexedDBService({ dbName: 'app_db', storeName: 'kv_store' });
|
||||
export const demoIndexedDB2 = new IndexedDBService({ dbName: 'app_db', storeName: 'kv_store_2' });
|
||||
@@ -0,0 +1,174 @@
|
||||
import { EncryptionUtils } from '@repo/utils';
|
||||
import type { IStorageService } from './storage.interface';
|
||||
import { ENCRYPTED_KEYS } from './storage.key';
|
||||
|
||||
// βββ Types ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
||||
|
||||
interface IndexedDBConfig {
|
||||
/** Database name. @default 'app_db' */
|
||||
dbName?: string;
|
||||
/** Object store name. @default 'kv_store' */
|
||||
storeName?: string;
|
||||
/** Database version. @default 1 */
|
||||
version?: number;
|
||||
}
|
||||
|
||||
// βββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
||||
|
||||
/**
|
||||
* Open (or create) an IndexedDB database with a simple key-value store.
|
||||
* Returns a Promise that resolves with the IDBDatabase instance.
|
||||
*/
|
||||
function openDatabase(
|
||||
dbName: string,
|
||||
storeName: string,
|
||||
version: number,
|
||||
): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, version);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(storeName)) {
|
||||
db.createObjectStore(storeName);
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single IndexedDB transaction and return the result.
|
||||
* Handles open β transaction β request β close lifecycle cleanly.
|
||||
*/
|
||||
function withTransaction<R>(
|
||||
db: IDBDatabase,
|
||||
storeName: string,
|
||||
mode: IDBTransactionMode,
|
||||
operation: (store: IDBObjectStore) => IDBRequest<R>,
|
||||
): Promise<R> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, mode);
|
||||
const store = tx.objectStore(storeName);
|
||||
const request = operation(store);
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// βββ Service ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
||||
|
||||
/**
|
||||
* Enterprise-grade IndexedDB wrapper with optional AES encryption.
|
||||
*
|
||||
* Uses a simple key-value object store pattern. Keys listed in
|
||||
* `ENCRYPTED_KEYS` are automatically encrypted/decrypted using
|
||||
* `@repo/utils` `EncryptionUtils`.
|
||||
*
|
||||
* Unlike localStorage, IndexedDB has no 5MB size limit β making
|
||||
* it suitable for large payloads like cached API responses, offline
|
||||
* data, or file blobs.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const idb = new IndexedDBService({ dbName: 'my_app' });
|
||||
* await idb.setItem('large_dataset', hugePayload);
|
||||
* const data = await idb.getItem<HugePayload>('large_dataset');
|
||||
* ```
|
||||
*/
|
||||
export class IndexedDBService implements IStorageService {
|
||||
private readonly encryption: EncryptionUtils;
|
||||
private readonly dbName: string;
|
||||
private readonly storeName: string;
|
||||
private readonly version: number;
|
||||
private dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
constructor(config?: IndexedDBConfig, encryptionUtils?: EncryptionUtils) {
|
||||
this.encryption = encryptionUtils ?? EncryptionUtils.getInstance();
|
||||
this.dbName = config?.dbName ?? 'app_db';
|
||||
this.storeName = config?.storeName ?? 'kv_store';
|
||||
this.version = config?.version ?? 1;
|
||||
}
|
||||
|
||||
/** Lazy-open the database connection (cached). */
|
||||
private getDB(): Promise<IDBDatabase> {
|
||||
if (!this.dbPromise) {
|
||||
this.dbPromise = openDatabase(this.dbName, this.storeName, this.version);
|
||||
}
|
||||
return this.dbPromise;
|
||||
}
|
||||
|
||||
private shouldEncrypt(key: string): boolean {
|
||||
return ENCRYPTED_KEYS.has(key);
|
||||
}
|
||||
|
||||
async setItem<T>(key: string, value: T): Promise<void> {
|
||||
const db = await this.getDB();
|
||||
const serialized = JSON.stringify(value);
|
||||
const payload = this.shouldEncrypt(key)
|
||||
? this.encryption.encrypt(serialized)
|
||||
: serialized;
|
||||
|
||||
await withTransaction(db, this.storeName, 'readwrite', (store) =>
|
||||
store.put(payload, key),
|
||||
);
|
||||
}
|
||||
|
||||
async getItem<T>(key: string): Promise<T | null> {
|
||||
const db = await this.getDB();
|
||||
|
||||
const raw = await withTransaction<string | undefined>(
|
||||
db,
|
||||
this.storeName,
|
||||
'readonly',
|
||||
(store) => store.get(key) as IDBRequest<string | undefined>,
|
||||
);
|
||||
|
||||
if (raw === undefined || raw === null) return null;
|
||||
|
||||
try {
|
||||
if (this.shouldEncrypt(key)) {
|
||||
const decrypted = this.encryption.decrypt(raw);
|
||||
if (!decrypted) return null;
|
||||
return JSON.parse(decrypted) as T;
|
||||
}
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
console.warn(`[core-storage] Failed to parse IndexedDB key "${key}". Removing corrupt entry.`);
|
||||
await this.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async removeItem(key: string): Promise<void> {
|
||||
const db = await this.getDB();
|
||||
await withTransaction(db, this.storeName, 'readwrite', (store) =>
|
||||
store.delete(key),
|
||||
);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
const db = await this.getDB();
|
||||
await withTransaction(db, this.storeName, 'readwrite', (store) =>
|
||||
store.clear(),
|
||||
);
|
||||
}
|
||||
|
||||
async hasItem(key: string): Promise<boolean> {
|
||||
const value = await this.getItem(key);
|
||||
return value !== null;
|
||||
}
|
||||
|
||||
async keys(): Promise<string[]> {
|
||||
const db = await this.getDB();
|
||||
return withTransaction<string[]>(
|
||||
db,
|
||||
this.storeName,
|
||||
'readonly',
|
||||
(store) => store.getAllKeys() as IDBRequest<string[]>,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { EncryptionUtils } from '@repo/utils';
|
||||
import type { IStorageService } from './storage.interface';
|
||||
import { ENCRYPTED_KEYS } from './storage.key';
|
||||
|
||||
/**
|
||||
* Enterprise-grade localStorage wrapper with optional AES encryption.
|
||||
*
|
||||
* Keys listed in `ENCRYPTED_KEYS` are automatically encrypted before
|
||||
* writing and decrypted on read using `@repo/utils` `EncryptionUtils`.
|
||||
* All other keys are stored as plain JSON.
|
||||
*
|
||||
* All methods are async (returning Promises) to conform to the
|
||||
* `IStorageService` interface, ensuring consumers can swap between
|
||||
* localStorage and IndexedDB without code changes.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const storage = new LocalStorageService();
|
||||
*
|
||||
* // Encrypted at rest (ACCESS_TOKEN is in ENCRYPTED_KEYS)
|
||||
* await storage.setItem(StorageKey.ACCESS_TOKEN, 'eyJhbGci...');
|
||||
*
|
||||
* // Plain JSON (THEME is NOT in ENCRYPTED_KEYS)
|
||||
* await storage.setItem(StorageKey.THEME, 'dark');
|
||||
* ```
|
||||
*/
|
||||
export class LocalStorageService implements IStorageService {
|
||||
private readonly encryption: EncryptionUtils;
|
||||
|
||||
constructor(encryptionUtils?: EncryptionUtils) {
|
||||
this.encryption = encryptionUtils ?? EncryptionUtils.getInstance();
|
||||
}
|
||||
|
||||
/** Check if a key should be encrypted. */
|
||||
private shouldEncrypt(key: string): boolean {
|
||||
return ENCRYPTED_KEYS.has(key);
|
||||
}
|
||||
|
||||
async setItem<T>(key: string, value: T): Promise<void> {
|
||||
const serialized = JSON.stringify(value);
|
||||
|
||||
if (this.shouldEncrypt(key)) {
|
||||
const encrypted = this.encryption.encrypt(serialized);
|
||||
localStorage.setItem(key, encrypted);
|
||||
} else {
|
||||
localStorage.setItem(key, serialized);
|
||||
}
|
||||
}
|
||||
|
||||
async getItem<T>(key: string): Promise<T | null> {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) return null;
|
||||
|
||||
try {
|
||||
if (this.shouldEncrypt(key)) {
|
||||
const decrypted = this.encryption.decrypt(raw);
|
||||
if (!decrypted) return null;
|
||||
return JSON.parse(decrypted) as T;
|
||||
}
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
console.warn(`[core-storage] Failed to parse key "${key}". Removing corrupt entry.`);
|
||||
localStorage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async removeItem(key: string): Promise<void> {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
localStorage.clear();
|
||||
}
|
||||
|
||||
async hasItem(key: string): Promise<boolean> {
|
||||
return localStorage.getItem(key) !== null;
|
||||
}
|
||||
|
||||
async keys(): Promise<string[]> {
|
||||
const result: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key !== null) result.push(key);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Generic storage interface contract.
|
||||
*
|
||||
* All storage implementations (localStorage, IndexedDB) must
|
||||
* conform to this interface. Methods use generics to enforce
|
||||
* type-safe serialization/deserialization at the consumer level.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const user = await storage.getItem<UserProfile>(StorageKey.USER_PROFILE);
|
||||
* ```
|
||||
*/
|
||||
export interface IStorageService {
|
||||
/**
|
||||
* Persist a value under the given key.
|
||||
* The value is JSON-serialized before storage.
|
||||
* If encryption is enabled, the serialized payload is encrypted at rest.
|
||||
*/
|
||||
setItem<T>(key: string, value: T): Promise<void>;
|
||||
|
||||
/**
|
||||
* Retrieve and deserialize a value by key.
|
||||
* Returns `null` if the key does not exist or decryption/parsing fails.
|
||||
*/
|
||||
getItem<T>(key: string): Promise<T | null>;
|
||||
|
||||
/** Remove a single key from storage. */
|
||||
removeItem(key: string): Promise<void>;
|
||||
|
||||
/** Remove all keys managed by this storage instance. */
|
||||
clear(): Promise<void>;
|
||||
|
||||
/** Check if a key exists in storage. */
|
||||
hasItem(key: string): Promise<boolean>;
|
||||
|
||||
/** Get all keys currently in storage. */
|
||||
keys(): Promise<string[]>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Centralized storage key registry.
|
||||
*
|
||||
* ALL keys used across the application MUST be registered here
|
||||
* as string literal constants. This prevents key collisions,
|
||||
* enables grep-ability, and provides a single source of truth
|
||||
* for what data is persisted in the browser.
|
||||
*
|
||||
* Convention: `SCREAMING_SNAKE_CASE` for the constant,
|
||||
* `kebab-case` or `snake_case` for the actual string value.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* await secureStorage.setItem(StorageKey.ACCESS_TOKEN, token);
|
||||
* ```
|
||||
*/
|
||||
export const StorageKey = {
|
||||
// ββ Auth βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
||||
ACCESS_TOKEN: 'access_token',
|
||||
REFRESH_TOKEN: 'refresh_token',
|
||||
USER_PROFILE: 'user_profile',
|
||||
USER_PERMISSIONS: 'user_permissions',
|
||||
|
||||
// ββ App Preferences ββββββββββββββββββββββββββββββββββββββββββ
|
||||
THEME: 'app_theme',
|
||||
LOCALE: 'app_locale',
|
||||
SIDEBAR_COLLAPSED: 'sidebar_collapsed',
|
||||
|
||||
// ββ Session ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
||||
FARO_SESSION: 'faroSession',
|
||||
LAST_ACTIVE_ROUTE: 'last_active_route',
|
||||
|
||||
// ββ Feature Flags / Cache ββββββββββββββββββββββββββββββββββββ
|
||||
FEATURE_FLAGS: 'feature_flags',
|
||||
CACHE_VERSION: 'cache_version',
|
||||
} as const;
|
||||
|
||||
/** Union type of all registered storage key values. */
|
||||
export type StorageKeyValue = (typeof StorageKey)[keyof typeof StorageKey];
|
||||
|
||||
/**
|
||||
* Keys that require encryption at rest.
|
||||
*
|
||||
* Any key listed here will be automatically encrypted before
|
||||
* writing to storage and decrypted on read. All other keys
|
||||
* are stored as plain JSON.
|
||||
*/
|
||||
export const ENCRYPTED_KEYS: ReadonlySet<string> = new Set<string>([
|
||||
StorageKey.ACCESS_TOKEN,
|
||||
StorageKey.REFRESH_TOKEN,
|
||||
StorageKey.USER_PROFILE,
|
||||
StorageKey.USER_PERMISSIONS,
|
||||
]);
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { LocalStorageService } from './local-storage.service';
|
||||
import { StorageKey, ENCRYPTED_KEYS } from './storage.key';
|
||||
|
||||
// βββ Mock @repo/utils EncryptionUtils βββββββββββββββββββββββββββ
|
||||
|
||||
const mockEncrypt = vi.fn((data: string) => `ENC[${data}]`);
|
||||
const mockDecrypt = vi.fn((data: string) => {
|
||||
// Strip the ENC[] wrapper
|
||||
const match = data.match(/^ENC\[(.+)\]$/);
|
||||
return match ? match[1] : '';
|
||||
});
|
||||
|
||||
const mockEncryptionUtils = {
|
||||
encrypt: mockEncrypt,
|
||||
decrypt: mockDecrypt,
|
||||
};
|
||||
|
||||
// βββ Mock browser localStorage ββββββββββββββββββββββββββββββββββ
|
||||
|
||||
const store: Record<string, string> = {};
|
||||
|
||||
const mockLocalStorage: Storage = {
|
||||
getItem: vi.fn((key: string): string | null => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string): void => { store[key] = value; }),
|
||||
removeItem: vi.fn((key: string): void => { delete store[key]; }),
|
||||
clear: vi.fn((): void => { for (const key of Object.keys(store)) delete store[key]; }),
|
||||
get length() { return Object.keys(store).length; },
|
||||
key(index: number): string | null { return Object.keys(store)[index] ?? null; },
|
||||
};
|
||||
|
||||
// Install mock
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: mockLocalStorage,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// βββ Test Data ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
||||
|
||||
interface TestUser {
|
||||
id: number;
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const testUser: TestUser = { id: 1, name: 'Firman', role: 'admin' };
|
||||
|
||||
// βββ Tests ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
||||
|
||||
describe('LocalStorageService', () => {
|
||||
let storage: LocalStorageService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const key of Object.keys(store)) delete store[key];
|
||||
// Pass mock encryption utils to avoid importing real crypto-js
|
||||
storage = new LocalStorageService(mockEncryptionUtils as never);
|
||||
});
|
||||
|
||||
// ββ setItem / getItem βββββββββββββββββββββββββββββββββββββββββ
|
||||
|
||||
describe('setItem / getItem', () => {
|
||||
it('stores and retrieves a plain object (non-encrypted key)', async () => {
|
||||
await storage.setItem(StorageKey.THEME, 'dark');
|
||||
|
||||
const result = await storage.getItem<string>(StorageKey.THEME);
|
||||
expect(result).toBe('dark');
|
||||
});
|
||||
|
||||
it('stores plain JSON without encryption for non-sensitive keys', async () => {
|
||||
await storage.setItem(StorageKey.LOCALE, 'en-US');
|
||||
|
||||
expect(mockEncrypt).not.toHaveBeenCalled();
|
||||
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
|
||||
StorageKey.LOCALE,
|
||||
'"en-US"',
|
||||
);
|
||||
});
|
||||
|
||||
it('encrypts sensitive keys (ACCESS_TOKEN)', async () => {
|
||||
const token = 'eyJhbGciOiJIUzI1NiJ9.test';
|
||||
await storage.setItem(StorageKey.ACCESS_TOKEN, token);
|
||||
|
||||
expect(mockEncrypt).toHaveBeenCalledWith(JSON.stringify(token));
|
||||
// The stored value should be the encrypted payload
|
||||
expect(store[StorageKey.ACCESS_TOKEN]).toBe(`ENC[${JSON.stringify(token)}]`);
|
||||
});
|
||||
|
||||
it('decrypts sensitive keys on read', async () => {
|
||||
const token = 'secret_token_123';
|
||||
await storage.setItem(StorageKey.ACCESS_TOKEN, token);
|
||||
|
||||
const result = await storage.getItem<string>(StorageKey.ACCESS_TOKEN);
|
||||
expect(mockDecrypt).toHaveBeenCalled();
|
||||
expect(result).toBe(token);
|
||||
});
|
||||
|
||||
it('stores and retrieves complex objects with generics', async () => {
|
||||
await storage.setItem(StorageKey.THEME, testUser);
|
||||
|
||||
const result = await storage.getItem<TestUser>(StorageKey.THEME);
|
||||
expect(result).toEqual(testUser);
|
||||
});
|
||||
|
||||
it('stores complex objects encrypted for sensitive keys', async () => {
|
||||
await storage.setItem(StorageKey.USER_PROFILE, testUser);
|
||||
|
||||
expect(mockEncrypt).toHaveBeenCalledWith(JSON.stringify(testUser));
|
||||
|
||||
const result = await storage.getItem<TestUser>(StorageKey.USER_PROFILE);
|
||||
expect(result).toEqual(testUser);
|
||||
});
|
||||
|
||||
it('returns null for non-existent keys', async () => {
|
||||
const result = await storage.getItem<string>('nonexistent');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('handles corrupt/invalid JSON gracefully', async () => {
|
||||
store[StorageKey.THEME] = '{invalid json';
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const result = await storage.getItem<string>(StorageKey.THEME);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to parse key'),
|
||||
);
|
||||
// Corrupt entry should be cleaned up
|
||||
expect(store[StorageKey.THEME]).toBeUndefined();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles failed decryption gracefully', async () => {
|
||||
// Write raw garbage to an encrypted key
|
||||
store[StorageKey.ACCESS_TOKEN] = 'not-encrypted-data';
|
||||
mockDecrypt.mockReturnValueOnce('');
|
||||
|
||||
const result = await storage.getItem<string>(StorageKey.ACCESS_TOKEN);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ββ removeItem ββββββββββββββββββββββββββββββββββββββββββββββββ
|
||||
|
||||
describe('removeItem', () => {
|
||||
it('removes a key from storage', async () => {
|
||||
await storage.setItem(StorageKey.THEME, 'dark');
|
||||
await storage.removeItem(StorageKey.THEME);
|
||||
|
||||
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(StorageKey.THEME);
|
||||
const result = await storage.getItem<string>(StorageKey.THEME);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ββ clear βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
||||
|
||||
describe('clear', () => {
|
||||
it('clears all keys from storage', async () => {
|
||||
await storage.setItem(StorageKey.THEME, 'dark');
|
||||
await storage.setItem(StorageKey.LOCALE, 'en');
|
||||
await storage.clear();
|
||||
|
||||
expect(mockLocalStorage.clear).toHaveBeenCalled();
|
||||
expect(Object.keys(store)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ββ hasItem βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
||||
|
||||
describe('hasItem', () => {
|
||||
it('returns true for existing keys', async () => {
|
||||
await storage.setItem(StorageKey.THEME, 'dark');
|
||||
expect(await storage.hasItem(StorageKey.THEME)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-existent keys', async () => {
|
||||
expect(await storage.hasItem('ghost_key')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ββ keys ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
||||
|
||||
describe('keys', () => {
|
||||
it('returns all stored keys', async () => {
|
||||
await storage.setItem(StorageKey.THEME, 'dark');
|
||||
await storage.setItem(StorageKey.LOCALE, 'en');
|
||||
|
||||
const allKeys = await storage.keys();
|
||||
expect(allKeys).toContain(StorageKey.THEME);
|
||||
expect(allKeys).toContain(StorageKey.LOCALE);
|
||||
expect(allKeys).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ββ Encryption Key Classification βββββββββββββββββββββββββββββ
|
||||
|
||||
describe('encryption key classification', () => {
|
||||
it('ACCESS_TOKEN is in ENCRYPTED_KEYS', () => {
|
||||
expect(ENCRYPTED_KEYS.has(StorageKey.ACCESS_TOKEN)).toBe(true);
|
||||
});
|
||||
|
||||
it('REFRESH_TOKEN is in ENCRYPTED_KEYS', () => {
|
||||
expect(ENCRYPTED_KEYS.has(StorageKey.REFRESH_TOKEN)).toBe(true);
|
||||
});
|
||||
|
||||
it('USER_PROFILE is in ENCRYPTED_KEYS', () => {
|
||||
expect(ENCRYPTED_KEYS.has(StorageKey.USER_PROFILE)).toBe(true);
|
||||
});
|
||||
|
||||
it('THEME is NOT in ENCRYPTED_KEYS', () => {
|
||||
expect(ENCRYPTED_KEYS.has(StorageKey.THEME)).toBe(false);
|
||||
});
|
||||
|
||||
it('LOCALE is NOT in ENCRYPTED_KEYS', () => {
|
||||
expect(ENCRYPTED_KEYS.has(StorageKey.LOCALE)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/library.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user