feat: implement core-storage package with unified promise-based interface and encrypted-at-rest support
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user