feat(ext/sw): org_add_config message + handler (device-local org config write)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W3b8DA2mXmLWkd4zDbHXHg
This commit is contained in:
@@ -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<string, unknown> = 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' });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -101,6 +101,10 @@ export const ERROR_COPY: Record<string, ErrorCopy> = {
|
||||
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.',
|
||||
|
||||
@@ -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<PopupMessage['type']> = 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'][]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user