Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 75 additions & 28 deletions src/components/KeySelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function KeySelector() {
selectedZone,
selectKey,
selectZone,
selectKeyAndZone,
availableZones,
availableKeys
} = useKey();
Expand Down Expand Up @@ -76,6 +77,65 @@ function KeySelector() {
const selectedIsReverse = !!validSelectedZone && isReverseZone(validSelectedZone);
const showReverseSelect = groupReverse && (reverseOpen || selectedIsReverse);

// A zone name can be served by more than one key (e.g. split-horizon internal
// vs external). Until a key is chosen, that zone is ambiguous — the union list
// shows it once and a key would be auto-picked arbitrarily. Expand such zones
// into one option per serving key so the user picks the exact key/view.
// Composite option value is `${zone}${DELIM}${keyId}` — `|` can't appear in a
// DNS name or in our key IDs (`key_<ts>_<hex>`). Once a key IS selected the
// list is already narrowed to that key's zones, so no splitting is needed.
const ZONE_KEY_DELIM = '|';
const keysServingZone = (zone: string) =>
availableKeys.filter(k => k.zones?.includes(zone));
const hasMultiKeyZones =
!selectedKey && visibleZones.some(z => keysServingZone(z).length > 1);
// A selected zone with no key that is served by >1 key is genuinely ambiguous
// (no view chosen). In that state the dropdown only offers per-key options, so
// the plain zone name is not a selectable value — bind the Select to '' rather
// than an out-of-range value (which would blank the field and warn), and let
// the helper text prompt for a key/view. Reachable by deselecting the key or
// reloading a persisted zone-only selection.
const ambiguousZoneOnly =
!selectedKey && !!validSelectedZone && keysServingZone(validSelectedZone).length > 1;

const renderZoneMenuItems = (zones: string[]) =>
zones.flatMap(zone => {
const serving = selectedKey ? [] : keysServingZone(zone);
if (serving.length > 1) {
return serving.map(k => {
const value = `${zone}${ZONE_KEY_DELIM}${k.id}`;
return (
<MenuItem key={value} value={value}>
{zone} — {k.name} ({k.server})
</MenuItem>
);
});
}
return [
<MenuItem key={zone} value={zone}>
{zone}
</MenuItem>,
];
});

const handleZoneSelect = (value: string) => {
if (value === REVERSE_SENTINEL) {
// Reveal the sub-selection without committing a zone.
setReverseOpen(true);
return;
}
setReverseOpen(false);
const sep = value.indexOf(ZONE_KEY_DELIM);
if (sep !== -1) {
// Composite per-key entry: set the exact key and zone together.
const zone = value.slice(0, sep);
const key = availableKeys.find(k => k.id === value.slice(sep + 1)) || null;
selectKeyAndZone(key, zone);
return;
}
selectZone(value || null);
};

const renderKeyOptions = () => {
return (
<FormControl fullWidth>
Expand Down Expand Up @@ -159,40 +219,31 @@ function KeySelector() {
<InputLabel id="zone-select-label">Select Zone</InputLabel>
<Select
labelId="zone-select-label"
value={groupReverse && selectedIsReverse ? REVERSE_SENTINEL : validSelectedZone}
onChange={(e) => {
const value = e.target.value;
if (value === REVERSE_SENTINEL) {
// Reveal the sub-selection without committing a zone.
setReverseOpen(true);
return;
}
setReverseOpen(false);
selectZone(value || null);
}}
value={groupReverse && selectedIsReverse ? REVERSE_SENTINEL : ambiguousZoneOnly ? '' : validSelectedZone}
onChange={(e) => handleZoneSelect(e.target.value)}
label="Select Zone"
SelectDisplayProps={{ id: 'zone-select' } as React.HTMLAttributes<HTMLDivElement>}
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{(groupReverse ? forwardZones : visibleZones).map((zone) => (
<MenuItem key={zone} value={zone}>
{zone}
</MenuItem>
))}
{renderZoneMenuItems(groupReverse ? forwardZones : visibleZones)}
{groupReverse && (
<MenuItem value={REVERSE_SENTINEL}>
Reverse zones ({reverseZones.length})…
</MenuItem>
)}
</Select>
<FormHelperText>
{validSelectedZone
? `Managing ${validSelectedZone}`
: !selectedKey
? 'Select a zone and a matching key is chosen automatically'
: 'Select a zone to manage'}
{ambiguousZoneOnly
? `${validSelectedZone} is served by multiple keys — pick the intended key/view`
: validSelectedZone
? `Managing ${validSelectedZone}`
: hasMultiKeyZones
? 'Some zones exist on multiple keys — pick the intended key/view'
: !selectedKey
? 'Select a zone and a matching key is chosen automatically'
: 'Select a zone to manage'}
</FormHelperText>
</FormControl>

Expand All @@ -201,19 +252,15 @@ function KeySelector() {
<InputLabel id="reverse-zone-select-label">Reverse Zone</InputLabel>
<Select
labelId="reverse-zone-select-label"
value={selectedIsReverse ? validSelectedZone : ''}
onChange={(e) => selectZone(e.target.value || null)}
value={selectedIsReverse && !ambiguousZoneOnly ? validSelectedZone : ''}
onChange={(e) => handleZoneSelect(e.target.value)}
label="Reverse Zone"
SelectDisplayProps={{ id: 'reverse-zone-select' } as React.HTMLAttributes<HTMLDivElement>}
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{reverseZones.map((zone) => (
<MenuItem key={zone} value={zone}>
{zone}
</MenuItem>
))}
{renderZoneMenuItems(reverseZones)}
</Select>
</FormControl>
)}
Expand Down
64 changes: 64 additions & 0 deletions src/components/__tests__/KeySelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function setup(overrides: Record<string, unknown> = {}) {
selectedZone: null,
selectKey: jest.fn(),
selectZone,
selectKeyAndZone: jest.fn(),
availableKeys: [],
availableZones: [],
...overrides
Expand Down Expand Up @@ -85,3 +86,66 @@ describe('KeySelector reverse-zone sub-selection', () => {
expect(screen.getByRole('combobox', { name: /select zone/i })).toHaveTextContent(/reverse zones/i);
});
});

describe('KeySelector multi-key zone disambiguation', () => {
beforeEach(() => mockUseKey.mockReset());

const twoKeys = [
{ id: 'k-int', name: 'internal', server: '10.0.0.53', keyName: 'int.', algorithm: 'hmac-sha256', zones: ['example.com'] },
{ id: 'k-ext', name: 'external', server: '203.0.113.53', keyName: 'ext.', algorithm: 'hmac-sha256', zones: ['example.com'] },
];

it('expands a zone served by multiple keys into one entry per key (no key selected)', () => {
setup({ availableKeys: twoKeys, availableZones: ['example.com'] });

const list = openSelect(/select zone/i);
expect(list.getByText(/example\.com.*internal.*10\.0\.0\.53/)).toBeInTheDocument();
expect(list.getByText(/example\.com.*external.*203\.0\.113\.53/)).toBeInTheDocument();
});

it('leaves a zone served by a single key as a plain entry', () => {
setup({ availableKeys: [twoKeys[0]], availableZones: ['example.com'] });

const list = openSelect(/select zone/i);
expect(list.getByText('example.com')).toBeInTheDocument();
expect(list.queryByText(/internal/)).not.toBeInTheDocument();
});

it('selecting a per-key entry sets both the key and the zone atomically', () => {
const selectKeyAndZone = jest.fn();
setup({ availableKeys: twoKeys, availableZones: ['example.com'], selectKeyAndZone });

fireEvent.click(openSelect(/select zone/i).getByText(/external.*203\.0\.113\.53/));
expect(selectKeyAndZone).toHaveBeenCalledWith(
expect.objectContaining({ id: 'k-ext' }),
'example.com'
);
});

it('does not split zones once a key is selected (list is that key\'s zones, plain)', () => {
setup({ availableKeys: twoKeys, availableZones: ['example.com'], selectedKey: twoKeys[0] });

const list = openSelect(/select zone/i);
expect(list.getByText('example.com')).toBeInTheDocument();
// No per-key split labels inside the zone list.
expect(list.queryByText(/example\.com\s+—/)).not.toBeInTheDocument();
});

it('renders cleanly (no out-of-range value) when a multi-key zone is selected with no key', () => {
// Reachable by deselecting the key or reloading a persisted zone-only
// selection: the only options are per-key composites, so the plain zone name
// is not a selectable value. The field must not bind to it (blank + warning);
// instead it shows unselected and the helper text prompts for a key/view.
setup({ availableKeys: twoKeys, availableZones: ['example.com'], selectedKey: null, selectedZone: 'example.com' });

// The zone trigger does not display the (unselectable) plain zone name...
expect(screen.getByRole('combobox', { name: /select zone/i })).not.toHaveTextContent('example.com');
// ...and the helper text prompts for the view rather than claiming "Managing".
expect(screen.getByText(/pick the intended key\/view/i)).toBeInTheDocument();
expect(screen.queryByText(/^Managing/)).not.toBeInTheDocument();
// The per-key options are available to disambiguate.
const list = openSelect(/select zone/i);
expect(list.getByText(/internal.*10\.0\.0\.53/)).toBeInTheDocument();
expect(list.getByText(/external.*203\.0\.113\.53/)).toBeInTheDocument();
});
});
14 changes: 14 additions & 0 deletions src/context/KeyContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ interface KeyContextType {
selectedZone: string | null;
selectKey: (key: AvailableKey | null) => void;
selectZone: (zone: string | null) => void;
// Set key and zone together in one update. Needed when a single UI action
// chooses both (e.g. picking a specific key/view for a zone served by more
// than one key): calling selectKey then selectZone would read stale state and
// re-run the auto-pick, clobbering the chosen key.
selectKeyAndZone: (key: AvailableKey | null, zone: string | null) => void;
// availableZones is ALWAYS the union of zones across all keys, regardless of
// whether a key is selected. Consumers that need only the selected key's
// zones should read selectedKey.zones directly.
Expand Down Expand Up @@ -170,12 +175,21 @@ export function KeyProvider({ children }: { children: React.ReactNode }) {
saveSelections(nextKey, zone);
};

// Set both explicitly, with no auto-pick — the caller has already decided the
// exact (key, zone) pair.
const selectKeyAndZone = (key: AvailableKey | null, zone: string | null) => {
setSelectedKey(key);
setSelectedZone(zone);
saveSelections(key, zone);
};

return (
<KeyContext.Provider value={{
selectedKey,
selectedZone,
selectKey,
selectZone,
selectKeyAndZone,
availableZones,
availableKeys,
keysLoading
Expand Down
Loading