mirror of
https://github.com/bitwarden/directory-connector
synced 2026-02-15 16:05:39 +00:00
Compare commits
2 Commits
ac/pm-2648
...
dev-clarit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06edf4cf91 | ||
|
|
623382f9e1 |
@@ -1,3 +1,7 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
# Bitwarden Directory Connector
|
||||
|
||||
## Project Overview
|
||||
@@ -20,6 +24,392 @@ Directory Connector is a TypeScript application that synchronizes users and grou
|
||||
- Node
|
||||
- Jest for testing
|
||||
|
||||
### Current Project Status
|
||||
|
||||
**Mission Critical but Deprioritized:** Directory Connector is used to sync customer directory services with their Bitwarden organization. While SCIM is the more modern cloud-hosted solution, not all directory services support SCIM, and SCIM is only available on Enterprise plans. Therefore, DC remains mission-critical infrastructure for many paying customers, but it's deprioritized in the codebase due to infrequent changes.
|
||||
|
||||
**Isolated Repository:** Unlike other Bitwarden client applications that live in a monorepo with shared core libraries, Directory Connector was kept separate when other TypeScript clients moved to the monorepo. It got its own copy of the jslib repo to avoid unnecessary regressions from apparently unrelated code changes in other clients. This severed it from the rest of the codebase, causing:
|
||||
|
||||
- Outdated dependencies that can't be updated (ES modules vs CommonJS conflicts)
|
||||
- File/folder structure that doesn't match modern Bitwarden client patterns
|
||||
- Accumulated technical debt requiring significant investment to pay down
|
||||
- jslib contains unused code from all clients, but cannot be deleted due to monolithic/tightly coupled architecture
|
||||
|
||||
**Critical Issues (Current Status):**
|
||||
|
||||
- ✅ ~~Electron, Node, and Angular are on unmaintained versions~~ **RESOLVED** - All updated (Electron 39, Node 20, Angular 21, TypeScript 5.9)
|
||||
- ❌ `keytar` is archived (Dec 2022) and incompatible with Node v22, **blocking Node upgrades beyond v20** - **PRIMARY BLOCKER**
|
||||
- ❌ No ESM support blocks dependency upgrades: googleapis, lowdb, chalk, inquirer, node-fetch, electron-store
|
||||
- ⚠️ 70 dev dependencies + 31 runtime dependencies = excessive maintenance burden (count increased with Angular 21 tooling)
|
||||
- ❌ StateService is a large pre-StateProvider monolith containing every getter/setter for all clients (PM-31159 In Progress)
|
||||
- ✅ ~~Angular CLI not used~~ **RESOLVED** - Angular CLI 21.1.2 now integrated with angular.json configuration
|
||||
|
||||
**Development Approach:** When working on this codebase, prioritize sustainability and maintainability over adding new features. Consider how changes will affect long-term maintenance burden.
|
||||
|
||||
## Tech Debt Roadmap
|
||||
|
||||
### Progress Summary
|
||||
|
||||
**Completed:**
|
||||
|
||||
- ✅ Phase 0 (Immediate Priority): All major dependencies upgraded (Node 20, Angular 21, TypeScript 5.9, Electron 39)
|
||||
- ✅ Phase 6: Angular CLI integration complete
|
||||
|
||||
**In Progress:**
|
||||
|
||||
- 🔄 Phase 1: StateService rewrite (PM-31159)
|
||||
|
||||
**Blocked/Todo:**
|
||||
|
||||
- ❌ Phase 2: Remove remaining jslib code (blocked by Phase 1)
|
||||
- ❌ Phase 3: Repository restructure (should be done before Phase 5)
|
||||
- ⚠️ Phase 4: Replace Keytar **[CRITICAL BLOCKER]** - blocking Node v22+ upgrades
|
||||
- ❌ Phase 5: ESM Support (blocked by Phase 3, needed for googleapis, lowdb, chalk, inquirer, etc.)
|
||||
|
||||
**Primary Blocker:** Keytar removal (Phase 4) is the most critical task as it blocks Node upgrades beyond v20.
|
||||
|
||||
---
|
||||
|
||||
### ✅ Immediate Priority: Unsupported Dependencies (COMPLETED)
|
||||
|
||||
**Upgrade Path (July 2025 release) - STATUS: COMPLETE**
|
||||
|
||||
All major version upgrades have been completed and exceeded targets:
|
||||
|
||||
1. ✅ Node 18.20.8 → 20.18 → **COMPLETE** (engines: `~20`, .nvmrc: `v20`)
|
||||
2. ✅ Angular 17 → 18.2.x → **EXCEEDED** (now at **21.1.1**)
|
||||
3. ✅ TypeScript 5.4.5 → 5.6.0 → **EXCEEDED** (now at **5.9.3**)
|
||||
4. ✅ Electron 34 → 36 → **EXCEEDED** (now at **39.2.1**)
|
||||
5. ✅ Angular matches clients monorepo version (21.x)
|
||||
|
||||
**Current Versions:**
|
||||
|
||||
- Node: v20 (project target), blocked from v22+ by keytar
|
||||
- TypeScript: 5.9.3
|
||||
- Angular: 21.1.1 (all packages)
|
||||
- Electron: 39.2.1 (well beyond EOL target of 36)
|
||||
- @yao-pkg/pkg: 5.16.1 (community fork replacing archived pkg)
|
||||
|
||||
**Note:** Further Node upgrades to v22+ are **blocked by keytar** (see Phase 4). Electron 36 was EOL October 2028, but we're already on 39.2.1.
|
||||
|
||||
### Phase 1: StateService Rewrite (PM-31159, In Progress)
|
||||
|
||||
**Problem:** StateService is a post-account-switching, pre-StateProvider monolith containing every getter/setter for all clients. This prevents deletion of unused data models and code. Never very stable, and more complex than DC needs (DC doesn't need account switching).
|
||||
|
||||
**Current Status:** 🔄 **Active PR** - [#990](https://github.com/bitwarden/directory-connector/pull/990) (Open, Author: @BTreston)
|
||||
|
||||
- PR created: Feb 2, 2026
|
||||
- Last updated: Feb 5, 2026
|
||||
- Files changed: 17 files (+1,512, -41 lines)
|
||||
- Commits: 4 (scaffold, add tests, fix type issues, fix integration test)
|
||||
|
||||
**Implementation Details:**
|
||||
|
||||
**New Architecture:**
|
||||
|
||||
- Created `StateServiceVNext` interface (`src/abstractions/state-vNext.service.ts`)
|
||||
- New implementation: `StateServiceVNextImplementation` (`src/services/state-service/state-vNext.service.ts`)
|
||||
- New state model with flat key-value structure (`src/models/state.model.ts`)
|
||||
- Comprehensive test suite: `state-vNext.service.spec.ts` (488 lines of tests)
|
||||
|
||||
**Storage Key Structure:**
|
||||
|
||||
```typescript
|
||||
// vNext Storage Keys (Flat key-value structure)
|
||||
StorageKeysVNext = {
|
||||
stateVersion: "stateVersion",
|
||||
directoryType: "directoryType",
|
||||
organizationId: "organizationId",
|
||||
directory_ldap: "directory_ldap",
|
||||
directory_gsuite: "directory_gsuite",
|
||||
directory_entra: "directory_entra",
|
||||
directory_okta: "directory_okta",
|
||||
directory_onelogin: "directory_onelogin",
|
||||
sync: "sync",
|
||||
syncingDir: "syncingDir",
|
||||
};
|
||||
|
||||
// Secure storage keys for sensitive data
|
||||
SecureStorageKeysVNext = {
|
||||
ldap: "secret_ldap",
|
||||
gsuite: "secret_gsuite",
|
||||
azure: "secret_azure", // Backwards compatible with old name
|
||||
entra: "secret_entra",
|
||||
okta: "secret_okta",
|
||||
oneLogin: "secret_oneLogin",
|
||||
userDelta: "userDeltaToken",
|
||||
groupDelta: "groupDeltaToken",
|
||||
lastUserSync: "lastUserSync",
|
||||
lastGroupSync: "lastGroupSync",
|
||||
lastSyncHash: "lastSyncHash",
|
||||
};
|
||||
```
|
||||
|
||||
**Migration Strategy:**
|
||||
|
||||
- State version bumped to `StateVersion.Five` (`jslib/common/src/enums/stateVersion.ts`)
|
||||
- Enhanced `StateMigrationService` to handle migration from old account-based structure to new flat structure
|
||||
- Migration keys defined for backwards compatibility (`MigrationKeys`, `SecureStorageKeysMigration`)
|
||||
- Temporary keys used during migration (`TempKeys`) to preserve data during transition
|
||||
|
||||
**File Organization:**
|
||||
|
||||
- State-related files moved to `src/services/state-service/` subdirectory:
|
||||
- `state-vNext.service.ts` (new implementation)
|
||||
- `state-vNext.service.spec.ts` (488 lines of tests)
|
||||
- `state.service.ts` (legacy, moved from `src/services/`)
|
||||
- `stateMigration.service.ts` (enhanced for v5 migration)
|
||||
- New abstraction: `src/abstractions/state-vNext.service.ts`
|
||||
- New model: `src/models/state.model.ts` (defines all storage keys)
|
||||
|
||||
**Integration:**
|
||||
|
||||
- Both old `StateService` and new `StateServiceVNext` injected in parallel during migration phase
|
||||
- `DirectoryFactoryService` updated to accept both services
|
||||
- Services module provides both implementations
|
||||
- CLI (`bwdc.ts`) and GUI (`main.ts`) both instantiate new service alongside old one
|
||||
|
||||
**Chosen Approach Benefits:**
|
||||
|
||||
- Clean break with old StateService - high degree of certainty
|
||||
- Simple and focused on DC's needs (no account switching, no rxjs)
|
||||
- Flat key-value structure easier to maintain
|
||||
- Versioning and migration capabilities included
|
||||
- Keeps existing data.json around during transition
|
||||
- All getters/setters in one place (acceptable for small application)
|
||||
|
||||
**Rejected Approaches:**
|
||||
|
||||
- Copy StateProvider from clients: Too complex (supports account switching, rxjs, syncing background/foreground contexts)
|
||||
- Rewrite simplified StateService keeping current data structure: Commits us to previous decisions, keeps monolithic account objects
|
||||
|
||||
**Next Steps:**
|
||||
|
||||
- Complete PR review and merge
|
||||
- Monitor for regressions during initial rollout
|
||||
- After several releases, can remove old StateService and migration code
|
||||
- Begin Phase 2: Remove remaining jslib code that was only needed by old StateService
|
||||
|
||||
### Phase 2: Remove Remaining jslib Code
|
||||
|
||||
After StateService is removed, review and delete old models and remaining services that referenced each other. jslib contains unused code from all clients that DC doesn't need.
|
||||
|
||||
### Phase 3: Restructure Repository (PM-31852, To Do)
|
||||
|
||||
**Current Structure:**
|
||||
|
||||
```
|
||||
src/ # Both Electron and CLI app code
|
||||
src-cli/ # package.json entry point for CLI only, no code
|
||||
jslib/
|
||||
├── common/ # Shared common code
|
||||
├── node/ # Node specific code used in CLI
|
||||
└── electron/ # Electron specific code used in GUI
|
||||
```
|
||||
|
||||
**Target Structure:**
|
||||
|
||||
```
|
||||
src-gui/ # Electron specific code only (combining src (partial) + jslib/electron)
|
||||
src-cli/ # Node and CLI specific code only (combining src (partial) + jslib/node)
|
||||
libs/ # Shared app-independent DC code, e.g. sync services (combining src (partial) + jslib/common)
|
||||
```
|
||||
|
||||
**Why:** Makes subsequent changes (code reorganizing, ESM support) much easier. This should be done early in the modernization process.
|
||||
|
||||
### Phase 4: Replace Keytar (PM-12436, To Do) ⚠️ **CRITICAL BLOCKER**
|
||||
|
||||
**Problem:** `keytar` (OS secure storage for secrets) was archived December 2022 and is incompatible with Node v22, **actively blocking Node upgrades beyond v20**.
|
||||
|
||||
**Current Status:**
|
||||
|
||||
- `keytar`: **7.9.0** (still present in dependencies)
|
||||
- **This is the #1 blocker preventing Node v22+ upgrades**
|
||||
- All "Immediate Priority" dependencies have been upgraded, but further progress requires removing keytar
|
||||
|
||||
**Solution:** Migrate to Bitwarden's Rust implementation in `desktop_native` (same as clients monorepo did)
|
||||
|
||||
1. Implement Rust <-> NAPI integration (like `desktop_native/napi`) from Electron app to Rust code
|
||||
2. Copy, rename, and expose necessary functions
|
||||
3. Point to `desktop_native` crate using git link from DC repo (no need for SDK yet):
|
||||
```rust
|
||||
desktop_core = { git = "https://github.com/bitwarden/clients", rev = "00cf24972d944638bbd1adc00a0ae3eeabb6eb9a" }
|
||||
```
|
||||
|
||||
**Important:** `keytar` uses wrong encoding on Windows (UTF-8 instead of UTF-16). Bitwarden uses UTF-16. Code should contain a migration - ensure old values are migrated correctly during testing.
|
||||
|
||||
**Priority:** This should be prioritized as it's blocking the Node upgrade path and has been archived for over 2 years.
|
||||
|
||||
### Phase 5: Add ESM Support (PM-31850, To Do)
|
||||
|
||||
**Problem:** No ESM module support prevents upgrading key dependencies.
|
||||
|
||||
**Blocked Dependencies (Current Status):**
|
||||
|
||||
- ❌ `googleapis`: **149.0.0** → current (major dependency, disabled in renovate.json5)
|
||||
- ❌ `lowdb`: **1.0.0** → v7
|
||||
- ❌ `@types/lowdb`: **1.0.15** (can be deleted once inquirer is upgraded)
|
||||
- ❌ `@electron/notarize`: **2.5.0** → v3.0.1
|
||||
- ❌ `chalk`: **4.1.2** → v5.3.0
|
||||
- ❌ `inquirer`: **8.2.6** → v12.1.0
|
||||
- ❌ `@types/inquirer`: **8.2.10** (should be deleted when inquirer upgraded)
|
||||
- ❌ `node-fetch`: **2.7.0** → v3.3.2 (should use native Node fetch API when on Node >=21)
|
||||
- ❌ `electron-store`: **8.2.0** → v10.1.0
|
||||
|
||||
**Status:** These dependencies remain blocked as expected. They will stay on old versions until:
|
||||
|
||||
1. Phase 3 (Repository Restructure) is complete
|
||||
2. ESM support is implemented
|
||||
3. Note: These ESM dependencies are primarily used in CLI build, so restructuring first (Phase 3) will limit the impact of ESM migration.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
1. Update tsconfig.json and package.json configurations
|
||||
2. Update import/export syntax to no longer use `require` statements
|
||||
3. Upgrade dependencies to move away from CommonJS (ESM can import CommonJS, but not vice versa)
|
||||
4. Trial and error
|
||||
|
||||
**Reference:** [Pure ESM package guide](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c)
|
||||
|
||||
### Phase 6: Add Angular CLI (PM-31849, In Progress / Possibly Complete?)
|
||||
|
||||
**Problem:** Angular CLI provides great DX and makes it easier to manage Angular changes (e.g. auto-migrations). DC didn't use it.
|
||||
|
||||
**Current Status:**
|
||||
|
||||
- ✅ `@angular/cli`: **21.1.2** is now present in **runtime dependencies**
|
||||
- ✅ `@angular/build`: **21.1.2** is present in dev dependencies
|
||||
- ✅ All Angular tooling has been updated to v21.x
|
||||
|
||||
**Status:** ✅ **COMPLETE** - Angular CLI has been successfully integrated:
|
||||
|
||||
- `angular.json` configuration file exists
|
||||
- `.angular/` cache directory present
|
||||
- `@angular/cli` 21.1.2 in runtime dependencies
|
||||
- `@angular/build` 21.1.2 in dev dependencies
|
||||
- All Angular packages updated to v21.x
|
||||
|
||||
This migration provides improved DX and access to Angular's auto-migration tools for future updates.
|
||||
|
||||
### Additional Considerations
|
||||
|
||||
**Reduce Dependency Count:** Current state is 70 dev dependencies + 31 runtime dependencies (101 total). The dev dependency count increased from the original 66 due to Angular 21 upgrade adding additional tooling. After removing old code, review dependency list:
|
||||
|
||||
- Can we remove some after code cleanup?
|
||||
- Could we reintegrate with monorepo to leverage Component Library and shared platform dependencies?
|
||||
- **Risk:** Becomes tightly coupled with monorepo code → regression risk, move slower due to coupling
|
||||
|
||||
**GitHub Workflows:** Need review and modernization:
|
||||
|
||||
- PM-20478: Add check-run workflow for CI on community PRs
|
||||
- PM-18290: Add linting workflow
|
||||
- PM-18289: Update build workflow
|
||||
- `pkg` and `pkg-fetch` for packaging Node runtime in CLI release are archived (fork exists but untrusted; clients vets all changes manually)
|
||||
- Options: Make our own fork, or use Node's single executable binary support (investigate)
|
||||
|
||||
## Common Development Commands
|
||||
|
||||
### Desktop App (Electron + Angular)
|
||||
|
||||
**Initial Setup:**
|
||||
|
||||
```bash
|
||||
npm install # Install dependencies (runs git submodule init automatically)
|
||||
npm run rebuild # Rebuild native modules for Electron
|
||||
```
|
||||
|
||||
**Development:**
|
||||
|
||||
```bash
|
||||
npm run electron # Build and run desktop app with hot reload and debugging
|
||||
npm run electron:ignore # Same as above but ignores certificate errors
|
||||
```
|
||||
|
||||
**Building:**
|
||||
|
||||
```bash
|
||||
npm run build # Build both main and renderer processes
|
||||
npm run build:main # Build Electron main process only
|
||||
npm run build:renderer # Build Angular renderer process only
|
||||
npm run build:renderer:watch # Build renderer with file watching
|
||||
```
|
||||
|
||||
**Distribution:**
|
||||
|
||||
```bash
|
||||
npm run dist:mac # Create macOS distributable
|
||||
npm run dist:win # Create Windows distributable
|
||||
npm run dist:lin # Create Linux distributable
|
||||
```
|
||||
|
||||
### CLI (bwdc)
|
||||
|
||||
**Development:**
|
||||
|
||||
```bash
|
||||
npm run build:cli:watch # Build CLI with file watching
|
||||
node ./build-cli/bwdc.js --help # Run the CLI from build output
|
||||
```
|
||||
|
||||
**Production Build:**
|
||||
|
||||
```bash
|
||||
npm run build:cli:prod # Build CLI for production
|
||||
npm run dist:cli # Create platform-specific CLI executables (all platforms)
|
||||
npm run dist:cli:mac # Create macOS CLI executable only
|
||||
npm run dist:cli:win # Create Windows CLI executable only
|
||||
npm run dist:cli:lin # Create Linux CLI executable only
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
**Unit Tests:**
|
||||
|
||||
```bash
|
||||
npm test # Run unit tests (excludes integration tests)
|
||||
npm run test:watch # Run unit tests in watch mode
|
||||
npm run test:watch:all # Run unit tests in watch mode (all files)
|
||||
npm run test:types # Run TypeScript type checking without emitting files
|
||||
```
|
||||
|
||||
**Integration Tests:**
|
||||
|
||||
```bash
|
||||
npm run test:integration:setup # Set up Docker containers for LDAP testing
|
||||
npm run test:integration # Run integration tests
|
||||
npm run test:integration:watch # Run integration tests in watch mode
|
||||
```
|
||||
|
||||
Integration tests require Docker and test against live directory services. The setup command creates OpenLDAP containers using docker-compose.yml.
|
||||
|
||||
### Linting & Formatting
|
||||
|
||||
```bash
|
||||
npm run lint # Run ESLint and Prettier checks
|
||||
npm run lint:fix # Auto-fix ESLint issues
|
||||
npm run prettier # Format all files with Prettier
|
||||
```
|
||||
|
||||
### Submodule Management
|
||||
|
||||
The `jslib` folder is a git submodule containing shared Bitwarden libraries:
|
||||
|
||||
```bash
|
||||
npm run sub:update # Update submodule to latest remote version
|
||||
npm run sub:pull # Pull latest changes in submodule
|
||||
npm run sub:commit # Pull and commit submodule update
|
||||
```
|
||||
|
||||
### Utility Commands
|
||||
|
||||
```bash
|
||||
npm run reset # Remove keytar modules and reinstall (use when switching between CLI/desktop)
|
||||
npm run clean:dist # Clean desktop distribution files
|
||||
npm run clean:dist:cli # Clean CLI distribution files
|
||||
```
|
||||
|
||||
**Important:** When switching between developing the desktop app and CLI, run `npm run reset` to avoid native module conflicts.
|
||||
|
||||
## Code Architecture & Structure
|
||||
|
||||
### Directory Organization
|
||||
@@ -45,6 +435,32 @@ jslib/ # Legacy folder structure (mix of deprecated/unused and c
|
||||
3. **Directory Service Pattern**: Each directory provider implements `IDirectoryService` interface
|
||||
4. **Separation of Concerns**: GUI (Angular app) and CLI (commands) share the same service layer
|
||||
|
||||
### Core Synchronization Flow
|
||||
|
||||
The sync process follows this pattern:
|
||||
|
||||
1. **DirectoryFactoryService** (`src/services/directory-factory.service.ts`) - Creates the appropriate directory service based on DirectoryType configuration
|
||||
2. **IDirectoryService** implementation (`src/services/directory-services/*.service.ts`) - Each provider (LDAP, Entra ID, Google, Okta, OneLogin) implements:
|
||||
- `getEntries(force, test)` - Returns `[GroupEntry[], UserEntry[]]`
|
||||
- Provider-specific authentication and API calls
|
||||
3. **SyncService** (`src/services/sync.service.ts`) - Orchestrates the sync:
|
||||
- Calls directory service to get entries
|
||||
- Filters and deduplicates users/groups
|
||||
- Uses BatchRequestBuilder or SingleRequestBuilder to format API requests
|
||||
- Generates hash to detect changes and avoid redundant syncs
|
||||
- Sends data to Bitwarden API via ApiService
|
||||
4. **Request Builders** (`src/services/*-request-builder.ts`) - Transform directory entries into Bitwarden API format
|
||||
|
||||
### Shared Library (jslib)
|
||||
|
||||
The `jslib` folder is a git submodule containing shared Bitwarden code:
|
||||
|
||||
- Common services (API, Crypto, Storage, Auth)
|
||||
- Platform utilities
|
||||
- Shared models and abstractions
|
||||
|
||||
**Important:** This is legacy structure - do not add new code to jslib. New code should go in `src/`.
|
||||
|
||||
## Development Conventions
|
||||
|
||||
### Code Organization
|
||||
|
||||
239
.claude/plan.md
Normal file
239
.claude/plan.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# Phase 2 PR #1: Flatten Account Model - IMPLEMENTATION COMPLETE
|
||||
|
||||
## Status: ✅ COMPLETED
|
||||
|
||||
**Implementation Date:** February 13, 2026
|
||||
**All tests passing:** 120/120 ✅
|
||||
**TypeScript compilation:** Success ✅
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Successfully implemented Phase 2 PR #1: Flatten Account Model. The Account model has been simplified from 177 lines (51 + 126 inherited) to 51 lines, removing the BaseAccount inheritance and flattening nested structures into direct properties.
|
||||
|
||||
## Changes Implemented
|
||||
|
||||
### Files Modified (7 files)
|
||||
|
||||
1. **`jslib/common/src/enums/stateVersion.ts`**
|
||||
- Added `StateVersion.Five` for the flattened Account structure
|
||||
- Updated `StateVersion.Latest = Five`
|
||||
|
||||
2. **`src/models/account.ts`**
|
||||
- Removed `extends BaseAccount` inheritance
|
||||
- Removed `ClientKeys` class (redundant)
|
||||
- Flattened 6 authentication fields to top level:
|
||||
- `userId`, `entityId`, `apiKeyClientId`
|
||||
- `accessToken`, `refreshToken`, `apiKeyClientSecret`
|
||||
- Kept `DirectoryConfigurations` and `DirectorySettings` unchanged
|
||||
- Added compatibility fields with FIXME comment for jslib infrastructure:
|
||||
- `data?`, `keys?`, `profile?`, `settings?`, `tokens?` (optional, unused)
|
||||
- Simplified constructor without Object.assign
|
||||
|
||||
3. **`src/services/stateMigration.service.ts`**
|
||||
- Added `migrateStateFrom3To4()` placeholder migration
|
||||
- Added `migrateStateFrom4To5()` to flatten nested → flat Account structure
|
||||
- Updated `migrate()` method with new case statements for v3→v4 and v4→v5
|
||||
- Updated `migrateStateFrom1To2()` to use flattened structure (removed `account.profile`, `account.clientKeys`)
|
||||
|
||||
4. **`src/services/auth.service.ts`**
|
||||
- Removed imports: `AccountKeys`, `AccountProfile`, `AccountTokens`
|
||||
- Simplified account creation from 26 lines to 10 lines (62% reduction)
|
||||
- Direct property assignment instead of nested objects with spread operators
|
||||
|
||||
5. **`src/services/state.service.ts`**
|
||||
- Changed `account.profile.userId` → `account.userId`
|
||||
- Removed `account.settings` from `scaffoldNewAccountDiskStorage`
|
||||
- Added `settings` back to `resetAccount` for base class compatibility (unused but required)
|
||||
|
||||
6. **`src/services/authService.spec.ts`**
|
||||
- Removed imports: `AccountKeys`, `AccountProfile`, `AccountTokens`
|
||||
- Updated test expectations to match new flat Account structure
|
||||
|
||||
### Files Created (1 file)
|
||||
|
||||
7. **`src/services/stateMigration.service.spec.ts`**
|
||||
- Comprehensive migration test suite (5 tests, 210 lines)
|
||||
- Tests flattening nested account structure
|
||||
- Tests handling missing nested objects gracefully
|
||||
- Tests empty account list
|
||||
- Tests preservation of directory configurations and settings
|
||||
- Tests state version update
|
||||
|
||||
## Code Reduction Achieved
|
||||
|
||||
- **Account model:** 177 lines (51 + 126 inherited) → 51 lines (71% reduction)
|
||||
- **AuthService account creation:** 26 lines → 10 lines (62% reduction)
|
||||
- **Import statements removed:** 5 jslib imports across multiple files
|
||||
|
||||
## Migration Logic
|
||||
|
||||
### State Version v4 → v5 Migration
|
||||
|
||||
The `migrateStateFrom4To5()` method handles conversion from nested to flat structure:
|
||||
|
||||
```typescript
|
||||
// OLD (nested structure):
|
||||
{
|
||||
profile: {
|
||||
userId: "CLIENT_ID",
|
||||
entityId: "CLIENT_ID",
|
||||
apiKeyClientId: "organization.CLIENT_ID"
|
||||
},
|
||||
tokens: {
|
||||
accessToken: "token",
|
||||
refreshToken: "refresh"
|
||||
},
|
||||
keys: {
|
||||
apiKeyClientSecret: "secret"
|
||||
}
|
||||
}
|
||||
|
||||
// NEW (flat structure):
|
||||
{
|
||||
userId: "CLIENT_ID",
|
||||
entityId: "CLIENT_ID",
|
||||
apiKeyClientId: "organization.CLIENT_ID",
|
||||
accessToken: "token",
|
||||
refreshToken: "refresh",
|
||||
apiKeyClientSecret: "secret"
|
||||
}
|
||||
```
|
||||
|
||||
**Migration Safety:**
|
||||
|
||||
- Null-safe property access with `??` operator
|
||||
- Preserves all directory configurations and settings
|
||||
- Falls back to userId if profile.userId doesn't exist
|
||||
- Handles empty account lists gracefully
|
||||
|
||||
## Test Results
|
||||
|
||||
### Unit Tests: ✅ PASS
|
||||
|
||||
```
|
||||
Test Suites: 14 passed, 14 total
|
||||
Tests: 120 passed, 120 total
|
||||
```
|
||||
|
||||
New tests added:
|
||||
|
||||
- `should flatten nested account structure` ✅
|
||||
- `should handle missing nested objects gracefully` ✅
|
||||
- `should handle empty account list` ✅
|
||||
- `should preserve directory configurations and settings` ✅
|
||||
- `should update state version after successful migration` ✅
|
||||
|
||||
### TypeScript Compilation: ✅ PASS
|
||||
|
||||
```
|
||||
npm run test:types
|
||||
```
|
||||
|
||||
All type checks pass with zero errors.
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### Compatibility Fields
|
||||
|
||||
Added optional compatibility fields to Account model to satisfy jslib infrastructure type constraints:
|
||||
|
||||
```typescript
|
||||
// FIXME: Remove these compatibility fields after StateServiceVNext migration (PR #990) is merged
|
||||
// These fields are unused but required for type compatibility with jslib's StateService infrastructure
|
||||
data?: any;
|
||||
keys?: any;
|
||||
profile?: any;
|
||||
settings?: any;
|
||||
tokens?: any;
|
||||
```
|
||||
|
||||
These will be removed after PR #990 (StateServiceVNext) merges and old StateService is deleted.
|
||||
|
||||
### Key Architectural Decision
|
||||
|
||||
Chose to add compatibility fields rather than refactor entire jslib infrastructure because:
|
||||
|
||||
1. PR #990 (StateServiceVNext) will eventually replace this infrastructure
|
||||
2. Minimizes changes needed in this PR
|
||||
3. Avoids conflicts with PR #990
|
||||
4. Can be cleaned up later
|
||||
|
||||
## What This Enables
|
||||
|
||||
### Immediate Benefits
|
||||
|
||||
- ✅ Simplified Account model (71% code reduction)
|
||||
- ✅ Clearer authentication field structure
|
||||
- ✅ Easier debugging (no nested property access)
|
||||
- ✅ Self-documenting code (obvious what DC needs)
|
||||
|
||||
### Enables Future Work
|
||||
|
||||
- **Phase 2 PR #2:** Remove StateFactory infrastructure
|
||||
- **Phase 2 PR #3:** Delete ~90 unused jslib files including:
|
||||
- EncString (only used by old nested Account)
|
||||
- SymmetricCryptoKey (only used by old nested Account)
|
||||
- OrganizationData (completely unused)
|
||||
- ProviderData (completely unused)
|
||||
- AccountKeys, AccountProfile, AccountTokens, AccountData, AccountSettings
|
||||
|
||||
## Merge Strategy
|
||||
|
||||
**Conflict Management:**
|
||||
|
||||
- This PR targets current codebase (with old StateService)
|
||||
- Will conflict with PR #990 (StateServiceVNext) when it merges
|
||||
- Plan: Rebase this PR after #990 merges
|
||||
- Expected conflicts: StateService files, Account model structure
|
||||
- Resolution: Keep StateServiceVNext changes, apply Account flattening to new structure
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Review & Test:** Thorough code review and manual testing
|
||||
2. **Create PR:** Open PR with comprehensive description and test results
|
||||
3. **Manual Testing Scenarios:**
|
||||
- Fresh installation → authentication flow
|
||||
- Existing installation → migration runs successfully
|
||||
- All directory types → configuration persists correctly
|
||||
- CLI authentication → flat structure works
|
||||
4. **After Merge:**
|
||||
- Begin Phase 2 PR #2: Remove StateFactory Infrastructure
|
||||
- Monitor for any migration issues in production
|
||||
|
||||
## Related Work
|
||||
|
||||
- **Depends On:** None (can merge independently)
|
||||
- **Blocks:** Phase 2 PR #2 (Remove StateFactory), Phase 2 PR #3 (Delete Unused jslib Files)
|
||||
- **Conflicts With:** PR #990 (StateServiceVNext) - plan to rebase after #990 merges
|
||||
- **Part Of:** Phase 2 tech debt cleanup (see CLAUDE.md)
|
||||
|
||||
---
|
||||
|
||||
## Original Implementation Plan
|
||||
|
||||
[The original detailed step-by-step plan from the conversation has been preserved below for reference]
|
||||
|
||||
### Context
|
||||
|
||||
Directory Connector's Account model currently extends jslib's BaseAccount, inheriting 126 lines of complex nested structures designed for multi-account password manager features that DC doesn't use. This inheritance creates unnecessary coupling and blocks cleanup of unused jslib dependencies.
|
||||
|
||||
**Current State:**
|
||||
|
||||
- Account extends BaseAccount with nested objects: `profile.userId`, `tokens.accessToken`, `keys.apiKeyClientSecret`
|
||||
- Only 6 fields from BaseAccount are actually used by DC
|
||||
- 120+ lines of inherited code (AccountData, AccountKeys, AccountProfile, AccountSettings, AccountTokens) are unused
|
||||
- Creates dependencies on EncString, SymmetricCryptoKey, OrganizationData, ProviderData that DC never uses
|
||||
|
||||
**Problem:**
|
||||
|
||||
- Unnecessary complexity for a single-account application
|
||||
- Blocks deletion of unused jslib models (Phase 2 goal)
|
||||
- Verbose account creation code (26 lines to set 6 fields)
|
||||
- Difficult to understand what DC actually needs
|
||||
|
||||
**Goal:**
|
||||
Flatten Account model to contain only the 8 fields DC uses, removing BaseAccount inheritance. This enables Phase 2 PR #2 and PR #3 to delete ~90 unused jslib files.
|
||||
|
||||
[Rest of original plan preserved in conversation transcript]
|
||||
@@ -3,5 +3,6 @@ export enum StateVersion {
|
||||
Two = 2, // Move to a typed State object
|
||||
Three = 3, // Fix migration of users' premium status
|
||||
Four = 4, // Fix 'Never Lock' option by removing stale data
|
||||
Latest = Four,
|
||||
Five = 5, // DC: Flatten Account model, remove BaseAccount inheritance
|
||||
Latest = Five,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Account as BaseAccount } from "@/jslib/common/src/models/domain/account";
|
||||
|
||||
import { DirectoryType } from "@/src/enums/directoryType";
|
||||
|
||||
import { EntraIdConfiguration } from "./entraIdConfiguration";
|
||||
@@ -9,23 +7,39 @@ import { OktaConfiguration } from "./oktaConfiguration";
|
||||
import { OneLoginConfiguration } from "./oneLoginConfiguration";
|
||||
import { SyncConfiguration } from "./syncConfiguration";
|
||||
|
||||
export class Account extends BaseAccount {
|
||||
directoryConfigurations?: DirectoryConfigurations = new DirectoryConfigurations();
|
||||
export class Account {
|
||||
// Authentication fields (flattened from nested profile/tokens/keys structure)
|
||||
userId: string;
|
||||
entityId: string;
|
||||
apiKeyClientId: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
apiKeyClientSecret: string;
|
||||
|
||||
// Directory Connector specific fields
|
||||
directoryConfigurations: DirectoryConfigurations = new DirectoryConfigurations();
|
||||
directorySettings: DirectorySettings = new DirectorySettings();
|
||||
clientKeys: ClientKeys = new ClientKeys();
|
||||
|
||||
// FIXME: Remove these compatibility fields after StateServiceVNext migration (PR #990) is merged
|
||||
// These fields are unused but required for type compatibility with jslib's StateService infrastructure
|
||||
data?: any;
|
||||
keys?: any;
|
||||
profile?: any;
|
||||
settings?: any;
|
||||
tokens?: any;
|
||||
|
||||
constructor(init: Partial<Account>) {
|
||||
super(init);
|
||||
this.userId = init?.userId;
|
||||
this.entityId = init?.entityId;
|
||||
this.apiKeyClientId = init?.apiKeyClientId;
|
||||
this.accessToken = init?.accessToken;
|
||||
this.refreshToken = init?.refreshToken;
|
||||
this.apiKeyClientSecret = init?.apiKeyClientSecret;
|
||||
this.directoryConfigurations = init?.directoryConfigurations ?? new DirectoryConfigurations();
|
||||
this.directorySettings = init?.directorySettings ?? new DirectorySettings();
|
||||
}
|
||||
}
|
||||
|
||||
export class ClientKeys {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export class DirectoryConfigurations {
|
||||
ldap: LdapConfiguration;
|
||||
gsuite: GSuiteConfiguration;
|
||||
|
||||
@@ -2,11 +2,6 @@ import { ApiService } from "@/jslib/common/src/abstractions/api.service";
|
||||
import { AppIdService } from "@/jslib/common/src/abstractions/appId.service";
|
||||
import { MessagingService } from "@/jslib/common/src/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@/jslib/common/src/abstractions/platformUtils.service";
|
||||
import {
|
||||
AccountKeys,
|
||||
AccountProfile,
|
||||
AccountTokens,
|
||||
} from "@/jslib/common/src/models/domain/account";
|
||||
import { DeviceRequest } from "@/jslib/common/src/models/request/deviceRequest";
|
||||
import { ApiTokenRequest } from "@/jslib/common/src/models/request/identityToken/apiTokenRequest";
|
||||
import { TokenRequestTwoFactor } from "@/jslib/common/src/models/request/identityToken/tokenRequestTwoFactor";
|
||||
@@ -62,27 +57,12 @@ export class AuthService {
|
||||
|
||||
await this.stateService.addAccount(
|
||||
new Account({
|
||||
profile: {
|
||||
...new AccountProfile(),
|
||||
...{
|
||||
userId: entityId,
|
||||
apiKeyClientId: clientId,
|
||||
entityId: entityId,
|
||||
},
|
||||
},
|
||||
tokens: {
|
||||
...new AccountTokens(),
|
||||
...{
|
||||
accessToken: tokenResponse.accessToken,
|
||||
refreshToken: tokenResponse.refreshToken,
|
||||
},
|
||||
},
|
||||
keys: {
|
||||
...new AccountKeys(),
|
||||
...{
|
||||
apiKeyClientSecret: clientSecret,
|
||||
},
|
||||
},
|
||||
userId: entityId,
|
||||
entityId: entityId,
|
||||
apiKeyClientId: clientId,
|
||||
accessToken: tokenResponse.accessToken,
|
||||
refreshToken: tokenResponse.refreshToken,
|
||||
apiKeyClientSecret: clientSecret,
|
||||
directorySettings: new DirectorySettings(),
|
||||
directoryConfigurations: new DirectoryConfigurations(),
|
||||
}),
|
||||
|
||||
@@ -5,11 +5,6 @@ import { AppIdService } from "@/jslib/common/src/abstractions/appId.service";
|
||||
import { MessagingService } from "@/jslib/common/src/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@/jslib/common/src/abstractions/platformUtils.service";
|
||||
import { Utils } from "@/jslib/common/src/misc/utils";
|
||||
import {
|
||||
AccountKeys,
|
||||
AccountProfile,
|
||||
AccountTokens,
|
||||
} from "@/jslib/common/src/models/domain/account";
|
||||
import { IdentityTokenResponse } from "@/jslib/common/src/models/response/identityTokenResponse";
|
||||
|
||||
import { Account, DirectoryConfigurations, DirectorySettings } from "../models/account";
|
||||
@@ -69,27 +64,12 @@ describe("AuthService", () => {
|
||||
expect(stateService.addAccount).toHaveBeenCalledTimes(1);
|
||||
expect(stateService.addAccount).toHaveBeenCalledWith(
|
||||
new Account({
|
||||
profile: {
|
||||
...new AccountProfile(),
|
||||
...{
|
||||
userId: "CLIENT_ID",
|
||||
apiKeyClientId: clientId, // with the "organization." prefix
|
||||
entityId: "CLIENT_ID",
|
||||
},
|
||||
},
|
||||
tokens: {
|
||||
...new AccountTokens(),
|
||||
...{
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
},
|
||||
},
|
||||
keys: {
|
||||
...new AccountKeys(),
|
||||
...{
|
||||
apiKeyClientSecret: clientSecret,
|
||||
},
|
||||
},
|
||||
userId: "CLIENT_ID",
|
||||
entityId: "CLIENT_ID",
|
||||
apiKeyClientId: clientId, // with the "organization." prefix
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
apiKeyClientSecret: clientSecret,
|
||||
directorySettings: new DirectorySettings(),
|
||||
directoryConfigurations: new DirectoryConfigurations(),
|
||||
}),
|
||||
|
||||
@@ -558,18 +558,16 @@ export class StateService
|
||||
|
||||
protected async scaffoldNewAccountDiskStorage(account: Account): Promise<void> {
|
||||
const storageOptions = this.reconcileOptions(
|
||||
{ userId: account.profile.userId },
|
||||
{ userId: account.userId },
|
||||
await this.defaultOnDiskLocalOptions(),
|
||||
);
|
||||
|
||||
const storedAccount = await this.getAccount(storageOptions);
|
||||
if (storedAccount != null) {
|
||||
account.settings = storedAccount.settings;
|
||||
account.directorySettings = storedAccount.directorySettings;
|
||||
account.directoryConfigurations = storedAccount.directoryConfigurations;
|
||||
} else if (await this.hasTemporaryStorage()) {
|
||||
// If migrating to state V2 with an no actively authed account we store temporary data to be copied on auth - this will only be run once.
|
||||
account.settings = await this.storageService.get<any>(keys.tempAccountSettings);
|
||||
account.directorySettings = await this.storageService.get<any>(keys.tempDirectorySettings);
|
||||
account.directoryConfigurations = await this.storageService.get<any>(
|
||||
keys.tempDirectoryConfigs,
|
||||
@@ -600,7 +598,7 @@ export class StateService
|
||||
|
||||
protected resetAccount(account: Account) {
|
||||
const persistentAccountInformation = {
|
||||
settings: account.settings,
|
||||
settings: account.settings, // Required by base class (unused by DC)
|
||||
directorySettings: account.directorySettings,
|
||||
directoryConfigurations: account.directoryConfigurations,
|
||||
};
|
||||
|
||||
209
src/services/stateMigration.service.spec.ts
Normal file
209
src/services/stateMigration.service.spec.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { StorageService } from "@/jslib/common/src/abstractions/storage.service";
|
||||
import { StateVersion } from "@/jslib/common/src/enums/stateVersion";
|
||||
import { StateFactory } from "@/jslib/common/src/factories/stateFactory";
|
||||
|
||||
import { Account, DirectoryConfigurations, DirectorySettings } from "../models/account";
|
||||
|
||||
import { StateMigrationService } from "./stateMigration.service";
|
||||
|
||||
describe("StateMigrationService - v4 to v5 migration", () => {
|
||||
let storageService: jest.Mocked<StorageService>;
|
||||
let secureStorageService: jest.Mocked<StorageService>;
|
||||
let stateFactory: jest.Mocked<StateFactory<any, Account>>;
|
||||
let migrationService: StateMigrationService;
|
||||
|
||||
beforeEach(() => {
|
||||
storageService = mock<StorageService>();
|
||||
secureStorageService = mock<StorageService>();
|
||||
stateFactory = mock<StateFactory<any, Account>>();
|
||||
|
||||
migrationService = new StateMigrationService(
|
||||
storageService,
|
||||
secureStorageService,
|
||||
stateFactory,
|
||||
);
|
||||
});
|
||||
|
||||
it("should flatten nested account structure", async () => {
|
||||
const userId = "test-user-id";
|
||||
const oldAccount = {
|
||||
profile: {
|
||||
userId: userId,
|
||||
entityId: userId,
|
||||
apiKeyClientId: "organization.CLIENT_ID",
|
||||
},
|
||||
tokens: {
|
||||
accessToken: "test-access-token",
|
||||
refreshToken: "test-refresh-token",
|
||||
},
|
||||
keys: {
|
||||
apiKeyClientSecret: "test-secret",
|
||||
},
|
||||
directoryConfigurations: new DirectoryConfigurations(),
|
||||
directorySettings: new DirectorySettings(),
|
||||
};
|
||||
|
||||
storageService.get.mockImplementation((key: string) => {
|
||||
if (key === "authenticatedAccounts") {
|
||||
return Promise.resolve([userId]);
|
||||
}
|
||||
if (key === userId) {
|
||||
return Promise.resolve(oldAccount);
|
||||
}
|
||||
if (key === "global") {
|
||||
return Promise.resolve({ stateVersion: StateVersion.Four });
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
await migrationService["migrateStateFrom4To5"]();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalledWith(
|
||||
userId,
|
||||
expect.objectContaining({
|
||||
userId: userId,
|
||||
entityId: userId,
|
||||
apiKeyClientId: "organization.CLIENT_ID",
|
||||
accessToken: "test-access-token",
|
||||
refreshToken: "test-refresh-token",
|
||||
apiKeyClientSecret: "test-secret",
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle missing nested objects gracefully", async () => {
|
||||
const userId = "test-user-id";
|
||||
const partialAccount = {
|
||||
directoryConfigurations: new DirectoryConfigurations(),
|
||||
directorySettings: new DirectorySettings(),
|
||||
};
|
||||
|
||||
storageService.get.mockImplementation((key: string) => {
|
||||
if (key === "authenticatedAccounts") {
|
||||
return Promise.resolve([userId]);
|
||||
}
|
||||
if (key === userId) {
|
||||
return Promise.resolve(partialAccount);
|
||||
}
|
||||
if (key === "global") {
|
||||
return Promise.resolve({ stateVersion: StateVersion.Four });
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
await migrationService["migrateStateFrom4To5"]();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalledWith(
|
||||
userId,
|
||||
expect.objectContaining({
|
||||
userId: userId,
|
||||
apiKeyClientId: null,
|
||||
accessToken: null,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty account list", async () => {
|
||||
storageService.get.mockImplementation((key: string) => {
|
||||
if (key === "authenticatedAccounts") {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
if (key === "global") {
|
||||
return Promise.resolve({ stateVersion: StateVersion.Four });
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
await migrationService["migrateStateFrom4To5"]();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalledWith(
|
||||
"global",
|
||||
expect.objectContaining({ stateVersion: StateVersion.Five }),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(storageService.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should preserve directory configurations and settings", async () => {
|
||||
const userId = "test-user-id";
|
||||
const directoryConfigs = new DirectoryConfigurations();
|
||||
directoryConfigs.ldap = { host: "ldap.example.com" } as any;
|
||||
|
||||
const directorySettings = new DirectorySettings();
|
||||
directorySettings.organizationId = "org-123";
|
||||
directorySettings.lastSyncHash = "hash-abc";
|
||||
|
||||
const oldAccount = {
|
||||
profile: { userId: userId },
|
||||
tokens: {},
|
||||
keys: {},
|
||||
directoryConfigurations: directoryConfigs,
|
||||
directorySettings: directorySettings,
|
||||
};
|
||||
|
||||
storageService.get.mockImplementation((key: string) => {
|
||||
if (key === "authenticatedAccounts") {
|
||||
return Promise.resolve([userId]);
|
||||
}
|
||||
if (key === userId) {
|
||||
return Promise.resolve(oldAccount);
|
||||
}
|
||||
if (key === "global") {
|
||||
return Promise.resolve({ stateVersion: StateVersion.Four });
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
await migrationService["migrateStateFrom4To5"]();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalledWith(
|
||||
userId,
|
||||
expect.objectContaining({
|
||||
directoryConfigurations: expect.objectContaining({
|
||||
ldap: { host: "ldap.example.com" },
|
||||
}),
|
||||
directorySettings: expect.objectContaining({
|
||||
organizationId: "org-123",
|
||||
lastSyncHash: "hash-abc",
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should update state version after successful migration", async () => {
|
||||
const userId = "test-user-id";
|
||||
const oldAccount = {
|
||||
profile: { userId: userId },
|
||||
tokens: {},
|
||||
keys: {},
|
||||
directoryConfigurations: new DirectoryConfigurations(),
|
||||
directorySettings: new DirectorySettings(),
|
||||
};
|
||||
|
||||
storageService.get.mockImplementation((key: string) => {
|
||||
if (key === "authenticatedAccounts") {
|
||||
return Promise.resolve([userId]);
|
||||
}
|
||||
if (key === userId) {
|
||||
return Promise.resolve(oldAccount);
|
||||
}
|
||||
if (key === "global") {
|
||||
return Promise.resolve({ stateVersion: StateVersion.Four });
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
await migrationService["migrateStateFrom4To5"]();
|
||||
|
||||
expect(storageService.save).toHaveBeenCalledWith(
|
||||
"global",
|
||||
expect.objectContaining({ stateVersion: StateVersion.Five }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,13 @@ export class StateMigrationService extends BaseStateMigrationService {
|
||||
break;
|
||||
case StateVersion.Two:
|
||||
await this.migrateStateFrom2To3();
|
||||
break;
|
||||
case StateVersion.Three:
|
||||
await this.migrateStateFrom3To4();
|
||||
break;
|
||||
case StateVersion.Four:
|
||||
await this.migrateStateFrom4To5();
|
||||
break;
|
||||
}
|
||||
currentStateVersion += 1;
|
||||
}
|
||||
@@ -143,15 +150,10 @@ export class StateMigrationService extends BaseStateMigrationService {
|
||||
const account = await this.get<Account>(userId);
|
||||
account.directoryConfigurations = directoryConfigs;
|
||||
account.directorySettings = directorySettings;
|
||||
account.profile = {
|
||||
userId: userId,
|
||||
entityId: userId,
|
||||
apiKeyClientId: clientId,
|
||||
};
|
||||
account.clientKeys = {
|
||||
clientId: clientId,
|
||||
clientSecret: clientSecret,
|
||||
};
|
||||
account.userId = userId;
|
||||
account.entityId = userId;
|
||||
account.apiKeyClientId = clientId;
|
||||
account.apiKeyClientSecret = clientSecret;
|
||||
|
||||
await this.set(userId, account);
|
||||
await clearDirectoryConnectorV1Keys();
|
||||
@@ -198,4 +200,57 @@ export class StateMigrationService extends BaseStateMigrationService {
|
||||
globals.stateVersion = StateVersion.Three;
|
||||
await this.set(StateKeys.global, globals);
|
||||
}
|
||||
|
||||
protected async migrateStateFrom3To4(): Promise<void> {
|
||||
// Placeholder migration for v3→v4 (no changes needed for DC)
|
||||
const globals = await this.getGlobals();
|
||||
globals.stateVersion = StateVersion.Four;
|
||||
await this.set(StateKeys.global, globals);
|
||||
}
|
||||
|
||||
protected async migrateStateFrom4To5(): Promise<void> {
|
||||
const authenticatedUserIds = await this.get<string[]>(StateKeys.authenticatedAccounts);
|
||||
|
||||
if (!authenticatedUserIds || authenticatedUserIds.length === 0) {
|
||||
// No accounts to migrate, just update version
|
||||
const globals = await this.getGlobals();
|
||||
globals.stateVersion = StateVersion.Five;
|
||||
await this.set(StateKeys.global, globals);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
authenticatedUserIds.map(async (userId) => {
|
||||
const oldAccount = await this.get<any>(userId);
|
||||
|
||||
if (!oldAccount) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new flattened account structure
|
||||
const flattenedAccount = new Account({
|
||||
// Extract from nested structures
|
||||
userId: oldAccount.profile?.userId ?? userId,
|
||||
entityId: oldAccount.profile?.entityId ?? userId,
|
||||
apiKeyClientId: oldAccount.profile?.apiKeyClientId ?? null,
|
||||
accessToken: oldAccount.tokens?.accessToken ?? null,
|
||||
refreshToken: oldAccount.tokens?.refreshToken ?? null,
|
||||
apiKeyClientSecret: oldAccount.keys?.apiKeyClientSecret ?? null,
|
||||
|
||||
// Preserve existing DC-specific data
|
||||
directoryConfigurations:
|
||||
oldAccount.directoryConfigurations ?? new DirectoryConfigurations(),
|
||||
directorySettings: oldAccount.directorySettings ?? new DirectorySettings(),
|
||||
});
|
||||
|
||||
// Save flattened account back to storage
|
||||
await this.set(userId, flattenedAccount);
|
||||
}),
|
||||
);
|
||||
|
||||
// Update global state version
|
||||
const globals = await this.getGlobals();
|
||||
globals.stateVersion = StateVersion.Five;
|
||||
await this.set(StateKeys.global, globals);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user