refactor: decouple core-storage services from static keys and update i18n setup to use configurable storage adapters
This commit is contained in:
@@ -1,48 +1,8 @@
|
||||
// ─── Interfaces ─────────────────────────────────────────────────
|
||||
export type { IStorageService } from './storage.interface';
|
||||
|
||||
// ─── Key Registry ───────────────────────────────────────────────
|
||||
export { StorageKey, ENCRYPTED_KEYS } from './storage.key';
|
||||
export type { StorageKeyValue } from './storage.key';
|
||||
export type { StorageOptions } from './local-storage.service';
|
||||
export type { IndexedDBConfig } from './indexed-db.service';
|
||||
|
||||
// ─── 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 { secureStorage, StorageKey } from '@repo/core-storage';
|
||||
*
|
||||
* await secureStorage.setItem(StorageKey.ACCESS_TOKEN, 'eyJhbGci...');
|
||||
* const token = await secureStorage.getItem<string>(StorageKey.ACCESS_TOKEN);
|
||||
* ```
|
||||
*/
|
||||
export const secureStorage = 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 `secureStorage`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { secureIndexedDB } from '@repo/core-storage';
|
||||
*
|
||||
* await secureIndexedDB.setItem('offline_draft', { content: '...' });
|
||||
* const draft = await secureIndexedDB.getItem<Draft>('offline_draft');
|
||||
* ```
|
||||
*/
|
||||
export const secureIndexedDB = new IndexedDBService({ dbName: 'app_db', storeName: 'kv_store' });
|
||||
export { LocalStorageService, createLocalStorage } from './local-storage.service';
|
||||
export { IndexedDBService, createIndexedDB } from './indexed-db.service';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { EncryptionUtils } from '@repo/utils';
|
||||
import type { IStorageService } from './storage.interface';
|
||||
import { ENCRYPTED_KEYS } from './storage.key';
|
||||
import type { StorageOptions } from './local-storage.service';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
|
||||
interface IndexedDBConfig {
|
||||
export interface IndexedDBConfig<TKey extends string> extends StorageOptions<TKey> {
|
||||
/** Database name. @default 'app_db' */
|
||||
dbName?: string;
|
||||
/** Object store name. @default 'kv_store' */
|
||||
@@ -63,34 +63,23 @@ function withTransaction<R>(
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
export class IndexedDBService<TKey extends string> implements IStorageService<TKey> {
|
||||
private readonly encryption: EncryptionUtils;
|
||||
private readonly dbName: string;
|
||||
private readonly storeName: string;
|
||||
private readonly version: number;
|
||||
private readonly encryptedKeys: Set<TKey>;
|
||||
private readonly plainTextKeys: Set<TKey>;
|
||||
private dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
constructor(config?: IndexedDBConfig, encryptionUtils?: EncryptionUtils) {
|
||||
constructor(config?: IndexedDBConfig<TKey>, encryptionUtils?: EncryptionUtils) {
|
||||
this.encryption = encryptionUtils ?? EncryptionUtils.getInstance();
|
||||
this.dbName = config?.dbName ?? 'app_db';
|
||||
this.storeName = config?.storeName ?? 'kv_store';
|
||||
this.version = config?.version ?? 1;
|
||||
this.encryptedKeys = config?.encryptedKeys ?? new Set();
|
||||
this.plainTextKeys = config?.plainTextKeys ?? new Set();
|
||||
}
|
||||
|
||||
/** Lazy-open the database connection (cached). */
|
||||
@@ -101,11 +90,18 @@ export class IndexedDBService implements IStorageService {
|
||||
return this.dbPromise;
|
||||
}
|
||||
|
||||
private shouldEncrypt(key: string): boolean {
|
||||
return ENCRYPTED_KEYS.has(key);
|
||||
private validateKey(key: TKey): void {
|
||||
if (!this.encryptedKeys.has(key) && !this.plainTextKeys.has(key)) {
|
||||
throw new Error(`[Storage Engine] Security Exception: Key '${key}' is not registered and cannot be accessed.`);
|
||||
}
|
||||
}
|
||||
|
||||
async setItem<T>(key: string, value: T): Promise<void> {
|
||||
private shouldEncrypt(key: TKey): boolean {
|
||||
return this.encryptedKeys.has(key);
|
||||
}
|
||||
|
||||
async setItem<T>(key: TKey, value: T): Promise<void> {
|
||||
this.validateKey(key);
|
||||
const db = await this.getDB();
|
||||
const serialized = JSON.stringify(value);
|
||||
const payload = this.shouldEncrypt(key)
|
||||
@@ -113,18 +109,19 @@ export class IndexedDBService implements IStorageService {
|
||||
: serialized;
|
||||
|
||||
await withTransaction(db, this.storeName, 'readwrite', (store) =>
|
||||
store.put(payload, key),
|
||||
store.put(payload, key as string),
|
||||
);
|
||||
}
|
||||
|
||||
async getItem<T>(key: string): Promise<T | null> {
|
||||
async getItem<T>(key: TKey): Promise<T | null> {
|
||||
this.validateKey(key);
|
||||
const db = await this.getDB();
|
||||
|
||||
const raw = await withTransaction<string | undefined>(
|
||||
db,
|
||||
this.storeName,
|
||||
'readonly',
|
||||
(store) => store.get(key) as IDBRequest<string | undefined>,
|
||||
(store) => store.get(key as string) as IDBRequest<string | undefined>,
|
||||
);
|
||||
|
||||
if (raw === undefined || raw === null) return null;
|
||||
@@ -143,10 +140,11 @@ export class IndexedDBService implements IStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
async removeItem(key: string): Promise<void> {
|
||||
async removeItem(key: TKey): Promise<void> {
|
||||
this.validateKey(key);
|
||||
const db = await this.getDB();
|
||||
await withTransaction(db, this.storeName, 'readwrite', (store) =>
|
||||
store.delete(key),
|
||||
store.delete(key as string),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -157,18 +155,25 @@ export class IndexedDBService implements IStorageService {
|
||||
);
|
||||
}
|
||||
|
||||
async hasItem(key: string): Promise<boolean> {
|
||||
async hasItem(key: TKey): Promise<boolean> {
|
||||
const value = await this.getItem(key);
|
||||
return value !== null;
|
||||
}
|
||||
|
||||
async keys(): Promise<string[]> {
|
||||
async keys(): Promise<TKey[]> {
|
||||
const db = await this.getDB();
|
||||
return withTransaction<string[]>(
|
||||
const allKeys = await withTransaction<string[]>(
|
||||
db,
|
||||
this.storeName,
|
||||
'readonly',
|
||||
(store) => store.getAllKeys() as IDBRequest<string[]>,
|
||||
);
|
||||
return allKeys as TKey[];
|
||||
}
|
||||
}
|
||||
|
||||
export function createIndexedDB<TKey extends string>(
|
||||
config: IndexedDBConfig<TKey>
|
||||
): IStorageService<TKey> {
|
||||
return new IndexedDBService<TKey>(config);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,50 @@
|
||||
import { EncryptionUtils } from '@repo/utils';
|
||||
import type { IStorageService } from './storage.interface';
|
||||
import { ENCRYPTED_KEYS } from './storage.key';
|
||||
|
||||
export interface StorageOptions<TKey extends string> {
|
||||
encryptedKeys?: Set<TKey>;
|
||||
plainTextKeys?: Set<TKey>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
export class LocalStorageService<TKey extends string> implements IStorageService<TKey> {
|
||||
private readonly encryption: EncryptionUtils;
|
||||
private readonly encryptedKeys: Set<TKey>;
|
||||
private readonly plainTextKeys: Set<TKey>;
|
||||
|
||||
constructor(encryptionUtils?: EncryptionUtils) {
|
||||
constructor(options?: StorageOptions<TKey>, encryptionUtils?: EncryptionUtils) {
|
||||
this.encryption = encryptionUtils ?? EncryptionUtils.getInstance();
|
||||
this.encryptedKeys = options?.encryptedKeys ?? new Set();
|
||||
this.plainTextKeys = options?.plainTextKeys ?? new Set();
|
||||
}
|
||||
|
||||
/** Check if a key should be encrypted. */
|
||||
private shouldEncrypt(key: string): boolean {
|
||||
return ENCRYPTED_KEYS.has(key);
|
||||
private validateKey(key: TKey): void {
|
||||
if (!this.encryptedKeys.has(key) && !this.plainTextKeys.has(key)) {
|
||||
throw new Error(`[Storage Engine] Security Exception: Key '${key}' is not registered and cannot be accessed.`);
|
||||
}
|
||||
}
|
||||
|
||||
async setItem<T>(key: string, value: T): Promise<void> {
|
||||
private shouldEncrypt(key: TKey): boolean {
|
||||
return this.encryptedKeys.has(key);
|
||||
}
|
||||
|
||||
async setItem<T>(key: TKey, value: T): Promise<void> {
|
||||
this.validateKey(key);
|
||||
const serialized = JSON.stringify(value);
|
||||
|
||||
if (this.shouldEncrypt(key)) {
|
||||
const encrypted = this.encryption.encrypt(serialized);
|
||||
localStorage.setItem(key, encrypted);
|
||||
localStorage.setItem(key as string, encrypted);
|
||||
} else {
|
||||
localStorage.setItem(key, serialized);
|
||||
localStorage.setItem(key as string, serialized);
|
||||
}
|
||||
}
|
||||
|
||||
async getItem<T>(key: string): Promise<T | null> {
|
||||
const raw = localStorage.getItem(key);
|
||||
async getItem<T>(key: TKey): Promise<T | null> {
|
||||
this.validateKey(key);
|
||||
const raw = localStorage.getItem(key as string);
|
||||
if (raw === null) return null;
|
||||
|
||||
try {
|
||||
@@ -60,29 +56,36 @@ export class LocalStorageService implements IStorageService {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
console.warn(`[core-storage] Failed to parse key "${key}". Removing corrupt entry.`);
|
||||
localStorage.removeItem(key);
|
||||
localStorage.removeItem(key as string);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async removeItem(key: string): Promise<void> {
|
||||
localStorage.removeItem(key);
|
||||
async removeItem(key: TKey): Promise<void> {
|
||||
this.validateKey(key);
|
||||
localStorage.removeItem(key as string);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
localStorage.clear();
|
||||
}
|
||||
|
||||
async hasItem(key: string): Promise<boolean> {
|
||||
return localStorage.getItem(key) !== null;
|
||||
async hasItem(key: TKey): Promise<boolean> {
|
||||
return localStorage.getItem(key as string) !== null;
|
||||
}
|
||||
|
||||
async keys(): Promise<string[]> {
|
||||
const result: string[] = [];
|
||||
async keys(): Promise<TKey[]> {
|
||||
const result: TKey[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key !== null) result.push(key);
|
||||
if (key !== null) result.push(key as TKey);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export function createLocalStorage<TKey extends string>(
|
||||
options?: StorageOptions<TKey>
|
||||
): IStorageService<TKey> {
|
||||
return new LocalStorageService<TKey>(options);
|
||||
}
|
||||
|
||||
@@ -10,29 +10,29 @@
|
||||
* const user = await storage.getItem<UserProfile>(StorageKey.USER_PROFILE);
|
||||
* ```
|
||||
*/
|
||||
export interface IStorageService {
|
||||
export interface IStorageService<TKey extends string = string> {
|
||||
/**
|
||||
* 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>;
|
||||
setItem<T>(key: TKey, 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>;
|
||||
getItem<T>(key: TKey): Promise<T | null>;
|
||||
|
||||
/** Remove a single key from storage. */
|
||||
removeItem(key: string): Promise<void>;
|
||||
removeItem(key: TKey): 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>;
|
||||
hasItem(key: TKey): Promise<boolean>;
|
||||
|
||||
/** Get all keys currently in storage. */
|
||||
keys(): Promise<string[]>;
|
||||
keys(): Promise<TKey[]>;
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
]);
|
||||
@@ -1,6 +1,5 @@
|
||||
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 ───────────────────────────
|
||||
|
||||
@@ -37,6 +36,27 @@ Object.defineProperty(globalThis, 'localStorage', {
|
||||
|
||||
// ─── Test Data ──────────────────────────────────────────────────
|
||||
|
||||
const TestStorageKey = {
|
||||
THEME: 'theme',
|
||||
LOCALE: 'locale',
|
||||
ACCESS_TOKEN: 'access_token',
|
||||
REFRESH_TOKEN: 'refresh_token',
|
||||
USER_PROFILE: 'user_profile',
|
||||
} as const;
|
||||
|
||||
type TestStorageKeyValue = (typeof TestStorageKey)[keyof typeof TestStorageKey];
|
||||
|
||||
const ENCRYPTED_KEYS = new Set<TestStorageKeyValue>([
|
||||
TestStorageKey.ACCESS_TOKEN,
|
||||
TestStorageKey.REFRESH_TOKEN,
|
||||
TestStorageKey.USER_PROFILE,
|
||||
]);
|
||||
|
||||
const PLAIN_KEYS = new Set<TestStorageKeyValue>([
|
||||
TestStorageKey.THEME,
|
||||
TestStorageKey.LOCALE,
|
||||
]);
|
||||
|
||||
interface TestUser {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -48,95 +68,108 @@ const testUser: TestUser = { id: 1, name: 'Firman', role: 'admin' };
|
||||
// ─── Tests ──────────────────────────────────────────────────────
|
||||
|
||||
describe('LocalStorageService', () => {
|
||||
let storage: LocalStorageService;
|
||||
let storage: LocalStorageService<TestStorageKeyValue>;
|
||||
|
||||
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);
|
||||
storage = new LocalStorageService<TestStorageKeyValue>(
|
||||
{ encryptedKeys: ENCRYPTED_KEYS, plainTextKeys: PLAIN_KEYS },
|
||||
mockEncryptionUtils as never
|
||||
);
|
||||
});
|
||||
|
||||
describe('Runtime Validation', () => {
|
||||
it('throws an error if the key is not in encryptedKeys or plainTextKeys', async () => {
|
||||
// Cast a rogue key to bypass TS for the runtime check test
|
||||
const rogueKey = 'unregistered_key' as TestStorageKeyValue;
|
||||
await expect(storage.setItem(rogueKey, 'data')).rejects.toThrowError(
|
||||
"[Storage Engine] Security Exception: Key 'unregistered_key' is not registered and cannot be accessed."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── setItem / getItem ─────────────────────────────────────────
|
||||
|
||||
describe('setItem / getItem', () => {
|
||||
it('stores and retrieves a plain object (non-encrypted key)', async () => {
|
||||
await storage.setItem(StorageKey.THEME, 'dark');
|
||||
await storage.setItem(TestStorageKey.THEME, 'dark');
|
||||
|
||||
const result = await storage.getItem<string>(StorageKey.THEME);
|
||||
const result = await storage.getItem<string>(TestStorageKey.THEME);
|
||||
expect(result).toBe('dark');
|
||||
});
|
||||
|
||||
it('stores plain JSON without encryption for non-sensitive keys', async () => {
|
||||
await storage.setItem(StorageKey.LOCALE, 'en-US');
|
||||
await storage.setItem(TestStorageKey.LOCALE, 'en-US');
|
||||
|
||||
expect(mockEncrypt).not.toHaveBeenCalled();
|
||||
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
|
||||
StorageKey.LOCALE,
|
||||
TestStorageKey.LOCALE,
|
||||
'"en-US"',
|
||||
);
|
||||
});
|
||||
|
||||
it('encrypts sensitive keys (ACCESS_TOKEN)', async () => {
|
||||
const token = 'eyJhbGciOiJIUzI1NiJ9.test';
|
||||
await storage.setItem(StorageKey.ACCESS_TOKEN, token);
|
||||
await storage.setItem(TestStorageKey.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)}]`);
|
||||
expect(store[TestStorageKey.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);
|
||||
await storage.setItem(TestStorageKey.ACCESS_TOKEN, token);
|
||||
|
||||
const result = await storage.getItem<string>(StorageKey.ACCESS_TOKEN);
|
||||
const result = await storage.getItem<string>(TestStorageKey.ACCESS_TOKEN);
|
||||
expect(mockDecrypt).toHaveBeenCalled();
|
||||
expect(result).toBe(token);
|
||||
});
|
||||
|
||||
it('stores and retrieves complex objects with generics', async () => {
|
||||
await storage.setItem(StorageKey.THEME, testUser);
|
||||
await storage.setItem(TestStorageKey.THEME, testUser);
|
||||
|
||||
const result = await storage.getItem<TestUser>(StorageKey.THEME);
|
||||
const result = await storage.getItem<TestUser>(TestStorageKey.THEME);
|
||||
expect(result).toEqual(testUser);
|
||||
});
|
||||
|
||||
it('stores complex objects encrypted for sensitive keys', async () => {
|
||||
await storage.setItem(StorageKey.USER_PROFILE, testUser);
|
||||
await storage.setItem(TestStorageKey.USER_PROFILE, testUser);
|
||||
|
||||
expect(mockEncrypt).toHaveBeenCalledWith(JSON.stringify(testUser));
|
||||
|
||||
const result = await storage.getItem<TestUser>(StorageKey.USER_PROFILE);
|
||||
const result = await storage.getItem<TestUser>(TestStorageKey.USER_PROFILE);
|
||||
expect(result).toEqual(testUser);
|
||||
});
|
||||
|
||||
it('returns null for non-existent keys', async () => {
|
||||
const result = await storage.getItem<string>('nonexistent');
|
||||
const result = await storage.getItem<string>('nonexistent' as TestStorageKeyValue);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('handles corrupt/invalid JSON gracefully', async () => {
|
||||
store[StorageKey.THEME] = '{invalid json';
|
||||
store[TestStorageKey.THEME] = '{invalid json';
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const result = await storage.getItem<string>(StorageKey.THEME);
|
||||
const result = await storage.getItem<string>(TestStorageKey.THEME);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to parse key'),
|
||||
);
|
||||
// Corrupt entry should be cleaned up
|
||||
expect(store[StorageKey.THEME]).toBeUndefined();
|
||||
expect(store[TestStorageKey.THEME]).toBeUndefined();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles failed decryption gracefully', async () => {
|
||||
// Write raw garbage to an encrypted key
|
||||
store[StorageKey.ACCESS_TOKEN] = 'not-encrypted-data';
|
||||
store[TestStorageKey.ACCESS_TOKEN] = 'not-encrypted-data';
|
||||
mockDecrypt.mockReturnValueOnce('');
|
||||
|
||||
const result = await storage.getItem<string>(StorageKey.ACCESS_TOKEN);
|
||||
const result = await storage.getItem<string>(TestStorageKey.ACCESS_TOKEN);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -145,11 +178,11 @@ describe('LocalStorageService', () => {
|
||||
|
||||
describe('removeItem', () => {
|
||||
it('removes a key from storage', async () => {
|
||||
await storage.setItem(StorageKey.THEME, 'dark');
|
||||
await storage.removeItem(StorageKey.THEME);
|
||||
await storage.setItem(TestStorageKey.THEME, 'dark');
|
||||
await storage.removeItem(TestStorageKey.THEME);
|
||||
|
||||
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(StorageKey.THEME);
|
||||
const result = await storage.getItem<string>(StorageKey.THEME);
|
||||
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(TestStorageKey.THEME);
|
||||
const result = await storage.getItem<string>(TestStorageKey.THEME);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -158,8 +191,8 @@ describe('LocalStorageService', () => {
|
||||
|
||||
describe('clear', () => {
|
||||
it('clears all keys from storage', async () => {
|
||||
await storage.setItem(StorageKey.THEME, 'dark');
|
||||
await storage.setItem(StorageKey.LOCALE, 'en');
|
||||
await storage.setItem(TestStorageKey.THEME, 'dark');
|
||||
await storage.setItem(TestStorageKey.LOCALE, 'en');
|
||||
await storage.clear();
|
||||
|
||||
expect(mockLocalStorage.clear).toHaveBeenCalled();
|
||||
@@ -171,12 +204,12 @@ describe('LocalStorageService', () => {
|
||||
|
||||
describe('hasItem', () => {
|
||||
it('returns true for existing keys', async () => {
|
||||
await storage.setItem(StorageKey.THEME, 'dark');
|
||||
expect(await storage.hasItem(StorageKey.THEME)).toBe(true);
|
||||
await storage.setItem(TestStorageKey.THEME, 'dark');
|
||||
expect(await storage.hasItem(TestStorageKey.THEME)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-existent keys', async () => {
|
||||
expect(await storage.hasItem('ghost_key')).toBe(false);
|
||||
expect(await storage.hasItem('ghost_key' as TestStorageKeyValue)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,12 +217,12 @@ describe('LocalStorageService', () => {
|
||||
|
||||
describe('keys', () => {
|
||||
it('returns all stored keys', async () => {
|
||||
await storage.setItem(StorageKey.THEME, 'dark');
|
||||
await storage.setItem(StorageKey.LOCALE, 'en');
|
||||
await storage.setItem(TestStorageKey.THEME, 'dark');
|
||||
await storage.setItem(TestStorageKey.LOCALE, 'en');
|
||||
|
||||
const allKeys = await storage.keys();
|
||||
expect(allKeys).toContain(StorageKey.THEME);
|
||||
expect(allKeys).toContain(StorageKey.LOCALE);
|
||||
expect(allKeys).toContain(TestStorageKey.THEME);
|
||||
expect(allKeys).toContain(TestStorageKey.LOCALE);
|
||||
expect(allKeys).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -198,23 +231,23 @@ describe('LocalStorageService', () => {
|
||||
|
||||
describe('encryption key classification', () => {
|
||||
it('ACCESS_TOKEN is in ENCRYPTED_KEYS', () => {
|
||||
expect(ENCRYPTED_KEYS.has(StorageKey.ACCESS_TOKEN)).toBe(true);
|
||||
expect(ENCRYPTED_KEYS.has(TestStorageKey.ACCESS_TOKEN)).toBe(true);
|
||||
});
|
||||
|
||||
it('REFRESH_TOKEN is in ENCRYPTED_KEYS', () => {
|
||||
expect(ENCRYPTED_KEYS.has(StorageKey.REFRESH_TOKEN)).toBe(true);
|
||||
expect(ENCRYPTED_KEYS.has(TestStorageKey.REFRESH_TOKEN)).toBe(true);
|
||||
});
|
||||
|
||||
it('USER_PROFILE is in ENCRYPTED_KEYS', () => {
|
||||
expect(ENCRYPTED_KEYS.has(StorageKey.USER_PROFILE)).toBe(true);
|
||||
expect(ENCRYPTED_KEYS.has(TestStorageKey.USER_PROFILE)).toBe(true);
|
||||
});
|
||||
|
||||
it('THEME is NOT in ENCRYPTED_KEYS', () => {
|
||||
expect(ENCRYPTED_KEYS.has(StorageKey.THEME)).toBe(false);
|
||||
expect(ENCRYPTED_KEYS.has(TestStorageKey.THEME)).toBe(false);
|
||||
});
|
||||
|
||||
it('LOCALE is NOT in ENCRYPTED_KEYS', () => {
|
||||
expect(ENCRYPTED_KEYS.has(StorageKey.LOCALE)).toBe(false);
|
||||
expect(ENCRYPTED_KEYS.has(TestStorageKey.LOCALE)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user