From 11e3aa41d18560cfde8a85a6d33cf1fb299e2991 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 27 Jun 2026 12:23:57 -0400 Subject: [PATCH] feat(ext/sw): org_add_config message + handler (device-local org config write) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W3b8DA2mXmLWkd4zDbHXHg --- .../__tests__/org-add-config.test.ts | 79 +++++++++++++++++++ .../src/service-worker/router/org-handlers.ts | 16 +++- .../src/service-worker/router/popup-only.ts | 4 + extension/src/shared/error-copy.ts | 4 + extension/src/shared/messages.ts | 5 +- 5 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 extension/src/service-worker/__tests__/org-add-config.test.ts diff --git a/extension/src/service-worker/__tests__/org-add-config.test.ts b/extension/src/service-worker/__tests__/org-add-config.test.ts new file mode 100644 index 0000000..d4d243e --- /dev/null +++ b/extension/src/service-worker/__tests__/org-add-config.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { handleOrgAddConfig } from '../router/org-handlers'; +import type { OrgConfig } from '../../shared/types'; + +const BASE_CONFIG: OrgConfig = { + orgId: 'org-1', + displayName: 'Acme Corp', + hostType: 'gitea', + hostUrl: 'https://git.acme.example', + repoPath: 'acme/vault', + apiToken: 'token-abc', + memberId: 'member-1', +}; + +function mockStorage(initial: OrgConfig[] = []) { + const store: Record = initial.length + ? { orgConfigs: initial } + : {}; + + const setMock = vi.fn().mockResolvedValue(undefined); + const getMock = vi.fn().mockResolvedValue({ ...store }); + + (global as { chrome: unknown }).chrome = { + storage: { + local: { + get: getMock, + set: setMock, + }, + }, + } as never; + + return { getMock, setMock }; +} + +describe('handleOrgAddConfig', () => { + beforeEach(() => { + mockStorage([]); + }); + + it('appends the new config and returns { ok: true }', async () => { + const { setMock } = mockStorage([]); + const resp = await handleOrgAddConfig({ config: BASE_CONFIG }); + expect(resp).toEqual({ ok: true }); + expect(setMock).toHaveBeenCalledOnce(); + const saved = (setMock.mock.calls[0][0] as { orgConfigs: OrgConfig[] }).orgConfigs; + expect(saved).toHaveLength(1); + expect(saved[0].orgId).toBe('org-1'); + }); + + it('preserves existing configs when appending', async () => { + const existing: OrgConfig = { ...BASE_CONFIG, orgId: 'org-0', displayName: 'Pre-existing' }; + const newCfg: OrgConfig = { ...BASE_CONFIG, orgId: 'org-2', displayName: 'New Org' }; + const { setMock } = mockStorage([existing]); + const resp = await handleOrgAddConfig({ config: newCfg }); + expect(resp).toEqual({ ok: true }); + const saved = (setMock.mock.calls[0][0] as { orgConfigs: OrgConfig[] }).orgConfigs; + expect(saved).toHaveLength(2); + expect(saved.map((c: OrgConfig) => c.orgId)).toEqual(['org-0', 'org-2']); + }); + + it('returns { ok: false, error: "org_already_configured" } for duplicate orgId', async () => { + mockStorage([BASE_CONFIG]); + const resp = await handleOrgAddConfig({ config: BASE_CONFIG }); + expect(resp).toEqual({ ok: false, error: 'org_already_configured' }); + }); + + it('does NOT call saveOrgConfigs when rejecting a duplicate', async () => { + const { setMock } = mockStorage([BASE_CONFIG]); + await handleOrgAddConfig({ config: BASE_CONFIG }); + expect(setMock).not.toHaveBeenCalled(); + }); + + it('rejects duplicate even when display name differs', async () => { + mockStorage([BASE_CONFIG]); + const renamed = { ...BASE_CONFIG, displayName: 'Acme Corp Renamed' }; + const resp = await handleOrgAddConfig({ config: renamed }); + expect(resp).toEqual({ ok: false, error: 'org_already_configured' }); + }); +}); diff --git a/extension/src/service-worker/router/org-handlers.ts b/extension/src/service-worker/router/org-handlers.ts index 2a0f5e4..fa14f83 100644 --- a/extension/src/service-worker/router/org-handlers.ts +++ b/extension/src/service-worker/router/org-handlers.ts @@ -11,9 +11,9 @@ import { openOrg, listOrgItems, getOrgItem, listOrgCollections, isNetworkError } from '../org-vault'; import type { OrgHandleState } from '../org-vault'; -import { loadOrgConfigs } from '../org-config'; +import { loadOrgConfigs, saveOrgConfigs } from '../org-config'; import * as session from '../session'; -import type { OrgConfigSummary } from '../../shared/types'; +import type { OrgConfig, OrgConfigSummary } from '../../shared/types'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type WasmModule = any; @@ -88,3 +88,15 @@ export async function handleOrgListConfigs(): Promise<{ ok: true; data: OrgConfi data: cfgs.map(c => ({ orgId: c.orgId, displayName: c.displayName })), }; } + +/** Handle org_add_config: append a new device-local org config (reject duplicate orgId). */ +export async function handleOrgAddConfig( + { config }: { config: OrgConfig }, +): Promise<{ ok: true } | { ok: false; error: string }> { + const cfgs = await loadOrgConfigs(); + if (cfgs.some(c => c.orgId === config.orgId)) { + return { ok: false, error: 'org_already_configured' }; + } + await saveOrgConfigs([...cfgs, config]); + return { ok: true }; +} diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index c7e3917..c569782 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -16,6 +16,7 @@ import * as sessionTimer from '../session-timer'; import { loadDeviceSettings, saveDeviceSettings, loadBlacklist, saveBlacklist } from '../storage'; import { handleOrgListConfigs, + handleOrgAddConfig, handleOrgSwitch, handleOrgListItems, handleOrgGetItem, @@ -671,6 +672,9 @@ export async function handle( case 'org_list_configs': return handleOrgListConfigs(); + case 'org_add_config': + return handleOrgAddConfig(msg); + case 'org_switch': return handleOrgSwitch(msg, state.wasm); diff --git a/extension/src/shared/error-copy.ts b/extension/src/shared/error-copy.ts index bd5377b..6792dff 100644 --- a/extension/src/shared/error-copy.ts +++ b/extension/src/shared/error-copy.ts @@ -101,6 +101,10 @@ export const ERROR_COPY: Record = { cta: { label: 'Open setup', action: 'open_setup' }, }, // --- Org (enterprise) context errors (v0.9.0 Plan B) --- + org_already_configured: { + title: 'Already added', + body: 'That organization is already configured on this device.', + }, org_not_configured: { title: 'Org not set up here', body: 'This organization is not configured on this device yet. Add it from the vault switcher before browsing its items.', diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index 23a51ed..b97d184 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -1,7 +1,7 @@ import type { Item, ItemId, Manifest, ManifestEntry, VaultConfig, SetupState, DeviceSettings, GeneratorRequest, VaultSettings, AttachmentRef, Device, - FieldHistoryView, OrgConfigSummary, OrgManifestEntry, Collection, + FieldHistoryView, OrgConfig, OrgConfigSummary, OrgManifestEntry, Collection, } from './types'; // --- Session timeout config --- @@ -70,6 +70,7 @@ export type PopupMessage = referenceImageBytes: ArrayBuffer; deviceName: string } | { type: 'get_vault_status' } | { type: 'org_list_configs' } + | { type: 'org_add_config'; config: OrgConfig } | { type: 'org_switch'; context: string } | { type: 'org_list_items' } | { type: 'org_get_item'; id: string } @@ -187,7 +188,7 @@ export const POPUP_ONLY_TYPES: ReadonlySet = new Set([ 'preview_totp_from_secret', 'generate_recovery_qr', 'unwrap_recovery_qr', 'create_vault', 'attach_vault', 'get_vault_status', - 'org_list_configs', + 'org_list_configs', 'org_add_config', 'org_switch', 'org_list_items', 'org_get_item', 'org_list_collections', ] as PopupMessage['type'][]);