diff --git a/src/client/OpenCodeClient.ts b/src/client/OpenCodeClient.ts index e277277..b542228 100644 --- a/src/client/OpenCodeClient.ts +++ b/src/client/OpenCodeClient.ts @@ -33,26 +33,31 @@ export class OpenCodeClient { private apiBaseUrl: string; private uiBaseUrl: string; private projectDirectory: string; + private password: string | null = null; private trackedSessionId: string | null = null; private lastPart: OpenCodePart | null = null; - constructor(apiBaseUrl: string, uiBaseUrl: string, projectDirectory: string) { + constructor(apiBaseUrl: string, uiBaseUrl: string, projectDirectory: string, password?: string) { this.apiBaseUrl = this.normalizeBaseUrl(apiBaseUrl); this.uiBaseUrl = this.normalizeBaseUrl(uiBaseUrl); this.projectDirectory = projectDirectory; + this.password = password ?? null; } - updateBaseUrl(apiBaseUrl: string, uiBaseUrl: string, projectDirectory: string): void { + updateBaseUrl(apiBaseUrl: string, uiBaseUrl: string, projectDirectory: string, password?: string): void { const nextApiUrl = this.normalizeBaseUrl(apiBaseUrl); const nextUiUrl = this.normalizeBaseUrl(uiBaseUrl); + const nextPassword = password ?? null; if ( nextApiUrl !== this.apiBaseUrl || nextUiUrl !== this.uiBaseUrl || - projectDirectory !== this.projectDirectory + projectDirectory !== this.projectDirectory || + nextPassword !== this.password ) { this.apiBaseUrl = nextApiUrl; this.uiBaseUrl = nextUiUrl; this.projectDirectory = projectDirectory; + this.password = nextPassword; this.resetTracking(); } } @@ -163,12 +168,17 @@ export class OpenCodeClient { private async request(method: string, path: string, body?: unknown): Promise> { try { const url = `${this.apiBaseUrl}${path}`; + const headers: Record = { + "Content-Type": "application/json", + "x-opencode-directory": this.projectDirectory, + }; + if (this.password) { + headers["Authorization"] = `Basic ${btoa(`opencode:${this.password}`)}`; + } + const response = await fetch(url, { method, - headers: { - "Content-Type": "application/json", - "x-opencode-directory": this.projectDirectory, - }, + headers, body: body ? JSON.stringify(body) : undefined, }); diff --git a/src/main.ts b/src/main.ts index 6475111..d580257 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,6 +8,7 @@ import { registerOpenCodeIcons, OPENCODE_ICON_NAME } from "./icons"; import { OpenCodeClient } from "./client/OpenCodeClient"; import { ContextManager } from "./context/ContextManager"; import { ExecutableResolver } from "./server/ExecutableResolver"; +import { PasswordManager } from "./security/PasswordManager"; export default class OpenCodePlugin extends Plugin { settings: OpenCodeSettings = DEFAULT_SETTINGS; @@ -18,6 +19,7 @@ export default class OpenCodePlugin extends Plugin { private viewManager: ViewManager; private cachedIframeUrl: string | null = null; private lastBaseUrl: string | null = null; + private password: string; async onload(): Promise { console.log("Loading OpenCode plugin"); @@ -30,8 +32,9 @@ export default class OpenCodePlugin extends Plugin { await this.attemptAutodetect(); const projectDirectory = this.getProjectDirectory(); + this.password = PasswordManager.getOrCreatePassword(this.app); - this.processManager = new ServerManager(this.settings, projectDirectory); + this.processManager = new ServerManager(this.settings, projectDirectory, this.password); this.processManager.on("stateChange", (state: ServerState) => { this.notifyStateChange(state); }); @@ -50,7 +53,8 @@ export default class OpenCodePlugin extends Plugin { this.openCodeClient = new OpenCodeClient( this.getApiBaseUrl(), this.getServerUrl(), - projectDirectory + projectDirectory, + this.password ); this.lastBaseUrl = this.getServerUrl(); @@ -92,7 +96,12 @@ export default class OpenCodePlugin extends Plugin { this, this.settings, this.processManager, - () => this.saveSettings() + () => this.saveSettings(), + (newPassword: string) => { + this.password = newPassword; + this.processManager.setPassword(newPassword); + this.refreshClientState(); + } )); this.addRibbonIcon(OPENCODE_ICON_NAME, "OpenCode", () => { @@ -255,7 +264,7 @@ export default class OpenCodePlugin extends Plugin { const nextUiBaseUrl = this.getServerUrl(); const nextApiBaseUrl = this.getApiBaseUrl(); const projectDirectory = this.getProjectDirectory(); - this.openCodeClient.updateBaseUrl(nextApiBaseUrl, nextUiBaseUrl, projectDirectory); + this.openCodeClient.updateBaseUrl(nextApiBaseUrl, nextUiBaseUrl, projectDirectory, this.password); if (this.lastBaseUrl && this.lastBaseUrl !== nextUiBaseUrl) { this.cachedIframeUrl = null; diff --git a/src/security/PasswordManager.ts b/src/security/PasswordManager.ts new file mode 100644 index 0000000..6c57473 --- /dev/null +++ b/src/security/PasswordManager.ts @@ -0,0 +1,83 @@ +import { randomBytes } from "crypto"; +import { App } from "obsidian"; + +const SECRET_KEY = "opencode-server-password"; +const PASSWORD_BYTES = 24; // 24 bytes = 32 chars in base64url + +/** + * SecretStorage interface for type safety. + * Obsidian's SecretStorage provides secure credential storage. + */ +interface SecretStorage { + getSecret(id: string): string | null; + setSecret(id: string, secret: string): void; + listSecrets(): string[]; +} + +/** + * Get SecretStorage from App instance. + * Uses type assertion as SecretStorage may not be in older type definitions. + */ +function getSecretStorage(app: App): SecretStorage { + return (app as unknown as { secretStorage: SecretStorage }).secretStorage; +} + +/** + * Utility class for managing the server authentication password. + * Uses Obsidian's SecretStorage for secure persistence and + * Node.js crypto for cryptographically secure random generation. + */ +export class PasswordManager { + /** + * Generates a cryptographically secure random password. + * @returns 32-character base64url encoded string + */ + static generatePassword(): string { + return randomBytes(PASSWORD_BYTES).toString("base64url"); + } + + /** + * Loads the stored password from SecretStorage. + * @param app - Obsidian App instance + * @returns The stored password or null if not set + */ + static loadPassword(app: App): string | null { + return getSecretStorage(app).getSecret(SECRET_KEY); + } + + /** + * Stores a password in SecretStorage. + * @param app - Obsidian App instance + * @param password - The password to store + */ + static storePassword(app: App, password: string): void { + getSecretStorage(app).setSecret(SECRET_KEY, password); + } + + /** + * Gets the existing password or creates and stores a new one. + * @param app - Obsidian App instance + * @returns The password (existing or newly generated) + */ + static getOrCreatePassword(app: App): string { + const existing = this.loadPassword(app); + if (existing) { + return existing; + } + + const password = this.generatePassword(); + this.storePassword(app, password); + return password; + } + + /** + * Regenerates and stores a new password, replacing any existing one. + * @param app - Obsidian App instance + * @returns The newly generated password + */ + static regeneratePassword(app: App): string { + const password = this.generatePassword(); + this.storePassword(app, password); + return password; + } +} diff --git a/src/server/ServerManager.ts b/src/server/ServerManager.ts index f4aa368..98f5714 100644 --- a/src/server/ServerManager.ts +++ b/src/server/ServerManager.ts @@ -17,15 +17,21 @@ export class ServerManager extends EventEmitter { private settings: OpenCodeSettings; private projectDirectory: string; private processImpl: OpenCodeProcess; + private password: string | null = null; - constructor(settings: OpenCodeSettings, projectDirectory: string) { + constructor(settings: OpenCodeSettings, projectDirectory: string, password?: string) { super(); this.settings = settings; this.projectDirectory = projectDirectory; + this.password = password ?? null; this.processImpl = process.platform === "win32" ? new WindowsProcess() : new PosixProcess(); } + setPassword(password: string): void { + this.password = password; + } + updateSettings(settings: OpenCodeSettings): void { this.settings = settings; } @@ -66,11 +72,17 @@ export class ServerManager extends EventEmitter { let spawnOptions: SpawnOptions; if (this.settings.useCustomCommand) { - // Custom command mode: use custom command directly with shell - executablePath = this.settings.customCommand; + executablePath = this.settings.customCommand.replace( + /\$OPENCODE_PASSWORD/g, + this.password ?? "" + ); spawnOptions = { cwd: this.projectDirectory, - env: { ...process.env, NODE_USE_SYSTEM_CA: "1" }, + env: { + ...process.env, + NODE_USE_SYSTEM_CA: "1", + OPENCODE_SERVER_PASSWORD: this.password ?? "", + }, stdio: ["ignore", "pipe", "pipe"], shell: true, }; @@ -86,7 +98,11 @@ export class ServerManager extends EventEmitter { spawnOptions = { cwd: this.projectDirectory, - env: { ...process.env, NODE_USE_SYSTEM_CA: "1" }, + env: { + ...process.env, + NODE_USE_SYSTEM_CA: "1", + OPENCODE_SERVER_PASSWORD: this.password ?? "", + }, stdio: ["ignore", "pipe", "pipe"], }; } @@ -224,8 +240,14 @@ export class ServerManager extends EventEmitter { private async checkServerHealth(): Promise { try { + const headers: Record = {}; + if (this.password) { + headers["Authorization"] = `Basic ${btoa(`opencode:${this.password}`)}`; + } + const response = await fetch(`${this.getUrl()}/global/health`, { method: "GET", + headers, signal: AbortSignal.timeout(2000), }); return response.ok; diff --git a/src/settings/SettingsTab.ts b/src/settings/SettingsTab.ts index a50ed5c..a5fc1e7 100644 --- a/src/settings/SettingsTab.ts +++ b/src/settings/SettingsTab.ts @@ -4,6 +4,7 @@ import { homedir } from "os"; import { OpenCodeSettings, ViewLocation } from "../types"; import { ServerManager } from "../server/ServerManager"; import { ExecutableResolver } from "../server/ExecutableResolver"; +import { PasswordManager } from "../security/PasswordManager"; function expandTilde(path: string): string { if (path === "~") { @@ -23,7 +24,8 @@ export class OpenCodeSettingTab extends PluginSettingTab { plugin: Plugin, private settings: OpenCodeSettings, private serverManager: ServerManager, - private onSettingsChange: () => Promise + private onSettingsChange: () => Promise, + private onPasswordRegenerate: (newPassword: string) => void ) { super(app, plugin); } @@ -94,7 +96,7 @@ export class OpenCodeSettingTab extends PluginSettingTab { .setDesc("Custom shell command to start OpenCode.") .addTextArea((text) => { text - .setPlaceholder("opencode serve --port 14096 --hostname 127.0.0.1 --cors app://obsidian.md") + .setPlaceholder("opencode serve --port 14096 --hostname 127.0.0.1 --cors app://obsidian.md\nUse $OPENCODE_PASSWORD to inject the server password") .setValue(this.settings.customCommand) .onChange(async (value) => { this.settings.customCommand = value; @@ -231,6 +233,11 @@ export class OpenCodeSettingTab extends PluginSettingTab { }) ); + containerEl.createEl("h3", { text: "Security" }); + + const securitySection = containerEl.createDiv({ cls: "opencode-security-section" }); + this.renderSecuritySection(securitySection); + containerEl.createEl("h3", { text: "Server Status" }); const statusContainer = containerEl.createDiv({ cls: "opencode-settings-status" }); @@ -274,6 +281,44 @@ export class OpenCodeSettingTab extends PluginSettingTab { await this.onSettingsChange(); } + private renderSecuritySection(container: HTMLElement): void { + container.empty(); + + const statusEl = container.createDiv({ cls: "opencode-security-status" }); + statusEl.createSpan({ text: "Password: " }); + statusEl.createSpan({ + text: "[secured]", + cls: "opencode-security-badge", + }); + + const buttonContainer = container.createDiv({ cls: "opencode-security-buttons" }); + const regenerateButton = buttonContainer.createEl("button", { + text: "Regenerate Password", + cls: "mod-warning", + }); + regenerateButton.addEventListener("click", async () => { + const wasRunning = this.serverManager.getState() === "running"; + if (wasRunning) { + await this.serverManager.stop(); + } + + const newPassword = PasswordManager.regeneratePassword(this.app); + + this.onPasswordRegenerate(newPassword); + + if (wasRunning) { + await this.serverManager.start(); + } + + this.renderSecuritySection(container); + }); + + container.createEl("p", { + text: "Regenerating password will restart the server if it's running.", + cls: "opencode-security-hint", + }); + } + private renderServerStatus(container: HTMLElement): void { container.empty(); diff --git a/tests/ServerManager.test.ts b/tests/ServerManager.test.ts index 98d143f..41f91d1 100644 --- a/tests/ServerManager.test.ts +++ b/tests/ServerManager.test.ts @@ -285,4 +285,70 @@ describe("ServerManager", () => { expect(currentManager.getState()).toBe("stopped"); }); }); + + describe("password authentication", () => { + test("server with password rejects unauthenticated requests", async () => { + const port = getNextPort(); + const settings = createTestSettings(port); + const testPassword = "test-password-abc123"; + + currentManager = new ServerManager(settings, PROJECT_DIR, testPassword); + + await currentManager.start(); + expect(currentManager.getState()).toBe("running"); + + const url = currentManager.getUrl(); + + // Unauthenticated request should fail with 401 + const response = await fetch(`${url}/global/health`, { + signal: AbortSignal.timeout(2000), + }); + + expect(response.status).toBe(401); + }, 30000); + + test("server with password accepts authenticated requests", async () => { + const port = getNextPort(); + const settings = createTestSettings(port); + const testPassword = "test-password-xyz789"; + + currentManager = new ServerManager(settings, PROJECT_DIR, testPassword); + + await currentManager.start(); + expect(currentManager.getState()).toBe("running"); + + const url = currentManager.getUrl(); + const authHeader = `Basic ${btoa(`opencode:${testPassword}`)}`; + + // Authenticated request should succeed + const response = await fetch(`${url}/global/health`, { + headers: { Authorization: authHeader }, + signal: AbortSignal.timeout(2000), + }); + + expect(response.ok).toBe(true); + }, 30000); + + test("setPassword updates password for subsequent requests", async () => { + const port = getNextPort(); + const settings = createTestSettings(port); + const initialPassword = "initial-password"; + + currentManager = new ServerManager(settings, PROJECT_DIR, initialPassword); + + await currentManager.start(); + expect(currentManager.getState()).toBe("running"); + + const url = currentManager.getUrl(); + + // Verify initial password works + const authHeader = `Basic ${btoa(`opencode:${initialPassword}`)}`; + const response = await fetch(`${url}/global/health`, { + headers: { Authorization: authHeader }, + signal: AbortSignal.timeout(2000), + }); + + expect(response.ok).toBe(true); + }, 30000); + }); }); diff --git a/tests/auth-integration.test.ts b/tests/auth-integration.test.ts new file mode 100644 index 0000000..2be8123 --- /dev/null +++ b/tests/auth-integration.test.ts @@ -0,0 +1,181 @@ +import { describe, test, expect, beforeAll, afterEach } from "bun:test"; +import { ServerManager } from "../src/server/ServerManager"; +import { OpenCodeSettings } from "../src/types"; + +const TEST_PORT_BASE = 16000; +const TEST_TIMEOUT_MS = 10000; +const PROJECT_DIR = process.cwd(); + +let currentPort = TEST_PORT_BASE; + +function getNextPort(): number { + return currentPort++; +} + +function createTestSettings(port: number): OpenCodeSettings { + return { + port, + hostname: "127.0.0.1", + autoStart: false, + opencodePath: "opencode", + projectDirectory: "", + startupTimeout: process.platform === "win32" ? 15000 : TEST_TIMEOUT_MS, + defaultViewLocation: "sidebar", + injectWorkspaceContext: true, + maxNotesInContext: 20, + maxSelectionLength: 2000, + customCommand: "", + useCustomCommand: false, + }; +} + +let currentManager: ServerManager | null = null; + +beforeAll(async () => { + const proc = Bun.spawn(["opencode", "--version"], { + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + throw new Error( + "opencode binary not found or not executable. " + + "Please ensure 'opencode' is installed and available in PATH." + ); + } +}); + +afterEach(async () => { + if (currentManager) { + await currentManager.stop(); + await new Promise((resolve) => setTimeout(resolve, 500)); + currentManager = null; + } +}); + +describe("Authentication Integration", () => { + describe("server with password", () => { + test("rejects unauthenticated health check with 401", async () => { + const port = getNextPort(); + const settings = createTestSettings(port); + const password = "test-secret-password-xyz"; + + currentManager = new ServerManager(settings, PROJECT_DIR, password); + await currentManager.start(); + + const url = currentManager.getUrl(); + const healthUrl = `${url}/global/health`; + + const response = await fetch(healthUrl, { + signal: AbortSignal.timeout(2000), + }); + + expect(response.status).toBe(401); + }); + + test("accepts authenticated health check with 200", async () => { + const port = getNextPort(); + const settings = createTestSettings(port); + const password = "test-secret-password-abc"; + + currentManager = new ServerManager(settings, PROJECT_DIR, password); + await currentManager.start(); + + const url = currentManager.getUrl(); + const healthUrl = `${url}/global/health`; + const authHeader = `Basic ${btoa(`opencode:${password}`)}`; + + const response = await fetch(healthUrl, { + headers: { Authorization: authHeader }, + signal: AbortSignal.timeout(2000), + }); + + expect(response.ok).toBe(true); + }); + + test("rejects request with wrong password", async () => { + const port = getNextPort(); + const settings = createTestSettings(port); + const correctPassword = "correct-password-123"; + const wrongPassword = "wrong-password-456"; + + currentManager = new ServerManager(settings, PROJECT_DIR, correctPassword); + await currentManager.start(); + + const url = currentManager.getUrl(); + const healthUrl = `${url}/global/health`; + const authHeader = `Basic ${btoa(`opencode:${wrongPassword}`)}`; + + const response = await fetch(healthUrl, { + headers: { Authorization: authHeader }, + signal: AbortSignal.timeout(2000), + }); + + expect(response.status).toBe(401); + }); + + test("server without password accepts unauthenticated requests", async () => { + const port = getNextPort(); + const settings = createTestSettings(port); + + currentManager = new ServerManager(settings, PROJECT_DIR); + await currentManager.start(); + + const url = currentManager.getUrl(); + const healthUrl = `${url}/global/health`; + + const response = await fetch(healthUrl, { + signal: AbortSignal.timeout(2000), + }); + + expect(response.ok).toBe(true); + }); + }); + + describe("password regeneration", () => { + test("setPassword changes the auth credentials", async () => { + const port1 = getNextPort(); + const settings1 = createTestSettings(port1); + const initialPassword = "initial-password-aaa"; + const newPassword = "new-password-bbb"; + + currentManager = new ServerManager(settings1, PROJECT_DIR, initialPassword); + await currentManager.start(); + + const healthUrl1 = `${currentManager.getUrl()}/global/health`; + + const initialAuthHeader = `Basic ${btoa(`opencode:${initialPassword}`)}`; + const response1 = await fetch(healthUrl1, { + headers: { Authorization: initialAuthHeader }, + signal: AbortSignal.timeout(2000), + }); + expect(response1.ok).toBe(true); + + await currentManager.stop(); + await new Promise((resolve) => setTimeout(resolve, 500)); + currentManager = null; + + const port2 = getNextPort(); + const settings2 = createTestSettings(port2); + currentManager = new ServerManager(settings2, PROJECT_DIR, newPassword); + await currentManager.start(); + + const healthUrl2 = `${currentManager.getUrl()}/global/health`; + + const oldAuthHeader = `Basic ${btoa(`opencode:${initialPassword}`)}`; + const response2 = await fetch(healthUrl2, { + headers: { Authorization: oldAuthHeader }, + signal: AbortSignal.timeout(2000), + }); + expect(response2.status).toBe(401); + + const newAuthHeader = `Basic ${btoa(`opencode:${newPassword}`)}`; + const response3 = await fetch(healthUrl2, { + headers: { Authorization: newAuthHeader }, + signal: AbortSignal.timeout(2000), + }); + expect(response3.ok).toBe(true); + }); + }); +}); diff --git a/tests/security/PasswordManager.test.ts b/tests/security/PasswordManager.test.ts new file mode 100644 index 0000000..749e5bd --- /dev/null +++ b/tests/security/PasswordManager.test.ts @@ -0,0 +1,152 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { PasswordManager } from "../../src/security/PasswordManager"; +import { App } from "obsidian"; + +interface MockSecretStorage { + secrets: Map; + getSecret(id: string): string | null; + setSecret(id: string, secret: string): void; + listSecrets(): string[]; +} + +function createMockSecretStorage(): MockSecretStorage { + const secrets = new Map(); + return { + secrets, + getSecret(id: string): string | null { + return secrets.get(id) ?? null; + }, + setSecret(id: string, secret: string): void { + secrets.set(id, secret); + }, + listSecrets(): string[] { + return Array.from(secrets.keys()); + }, + }; +} + +function createMockApp(secretStorage: MockSecretStorage): App { + return { + secretStorage, + } as unknown as App; +} + +describe("PasswordManager", () => { + let mockStorage: MockSecretStorage; + let mockApp: App; + + beforeEach(() => { + mockStorage = createMockSecretStorage(); + mockApp = createMockApp(mockStorage); + }); + + describe("generatePassword", () => { + test("returns 32-character string", () => { + const password = PasswordManager.generatePassword(); + expect(password.length).toBe(32); + }); + + test("returns base64url encoded string (alphanumeric, -, _)", () => { + const password = PasswordManager.generatePassword(); + expect(password).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + test("produces different values on each call", () => { + const passwords = new Set(); + for (let i = 0; i < 10; i++) { + passwords.add(PasswordManager.generatePassword()); + } + expect(passwords.size).toBe(10); + }); + }); + + describe("loadPassword", () => { + test("returns null when password not set", () => { + const result = PasswordManager.loadPassword(mockApp); + expect(result).toBeNull(); + }); + + test("returns stored password when set", () => { + const testPassword = "test-password-123"; + mockStorage.setSecret("opencode-server-password", testPassword); + + const result = PasswordManager.loadPassword(mockApp); + expect(result).toBe(testPassword); + }); + }); + + describe("storePassword", () => { + test("stores password in SecretStorage", () => { + const testPassword = "stored-password-xyz"; + + PasswordManager.storePassword(mockApp, testPassword); + + expect(mockStorage.secrets.get("opencode-server-password")).toBe(testPassword); + }); + + test("overwrites existing password", () => { + const firstPassword = "first-password"; + const secondPassword = "second-password"; + + PasswordManager.storePassword(mockApp, firstPassword); + PasswordManager.storePassword(mockApp, secondPassword); + + expect(mockStorage.secrets.get("opencode-server-password")).toBe(secondPassword); + }); + }); + + describe("getOrCreatePassword", () => { + test("returns existing password if already set", () => { + const existingPassword = "existing-password-abc"; + mockStorage.setSecret("opencode-server-password", existingPassword); + + const result = PasswordManager.getOrCreatePassword(mockApp); + expect(result).toBe(existingPassword); + }); + + test("generates and stores new password if not set", () => { + const result = PasswordManager.getOrCreatePassword(mockApp); + + expect(result.length).toBe(32); + expect(result).toMatch(/^[A-Za-z0-9_-]+$/); + expect(mockStorage.secrets.get("opencode-server-password")).toBe(result); + }); + + test("is deterministic - returns same password on repeated calls", () => { + const first = PasswordManager.getOrCreatePassword(mockApp); + const second = PasswordManager.getOrCreatePassword(mockApp); + + expect(first).toBe(second); + }); + }); + + describe("regeneratePassword", () => { + test("always generates new password", () => { + const first = PasswordManager.regeneratePassword(mockApp); + const second = PasswordManager.regeneratePassword(mockApp); + + expect(first).not.toBe(second); + }); + + test("stores the new password", () => { + const newPassword = PasswordManager.regeneratePassword(mockApp); + + expect(mockStorage.secrets.get("opencode-server-password")).toBe(newPassword); + }); + + test("overwrites existing password with new one", () => { + mockStorage.setSecret("opencode-server-password", "old-password"); + const newPassword = PasswordManager.regeneratePassword(mockApp); + + expect(newPassword).not.toBe("old-password"); + expect(mockStorage.secrets.get("opencode-server-password")).toBe(newPassword); + }); + + test("returns valid 32-char base64url string", () => { + const password = PasswordManager.regeneratePassword(mockApp); + + expect(password.length).toBe(32); + expect(password).toMatch(/^[A-Za-z0-9_-]+$/); + }); + }); +});