diff --git a/src/components/RegisterUpdateDialog.test.tsx b/src/components/RegisterUpdateDialog.test.tsx new file mode 100644 index 0000000..b7e8c95 --- /dev/null +++ b/src/components/RegisterUpdateDialog.test.tsx @@ -0,0 +1,108 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { RegisterUpdateDialog } from './RegisterUpdateDialog'; + +describe('RegisterUpdateDialog', () => { + it('renders the four fields when open', () => { + render( {}} onSubmit={async () => {}} />); + expect(screen.getByLabelText(/^id$/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/^name$/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/^automated$/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/additional metadata/i)).toBeInTheDocument(); + }); + + it('rejects empty id', async () => { + const onSubmit = vi.fn(); + render( {}} onSubmit={onSubmit} />); + fireEvent.click(screen.getByRole('button', { name: /^register$/i })); + expect(await screen.findByText(/id is required/i)).toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('rejects malformed JSON metadata', async () => { + const onSubmit = vi.fn(); + render( {}} onSubmit={onSubmit} />); + fireEvent.change(screen.getByLabelText(/^id$/i), { target: { value: 'x' } }); + fireEvent.change(screen.getByLabelText(/additional metadata/i), { target: { value: '{not json' } }); + fireEvent.click(screen.getByRole('button', { name: /^register$/i })); + expect(await screen.findByText(/invalid json/i)).toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('strips reserved keys from metadata so UI-validated fields win', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( {}} onSubmit={onSubmit} />); + fireEvent.change(screen.getByLabelText(/^id$/i), { target: { value: 'ui-id' } }); + fireEvent.change(screen.getByLabelText(/additional metadata/i), { + target: { value: '{"id":"evil","update_name":"evil","automated":true,"extra":1}' }, + }); + fireEvent.click(screen.getByRole('button', { name: /^register$/i })); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith({ + id: 'ui-id', + update_name: 'ui-id', + automated: false, + extra: 1, + }) + ); + }); + + it.each([ + ['string', '"just a string"'], + ['array', '[1, 2, 3]'], + ['null', 'null'], + ['number', '42'], + ])('rejects non-object JSON metadata (%s)', async (_label, raw) => { + const onSubmit = vi.fn(); + render( {}} onSubmit={onSubmit} />); + fireEvent.change(screen.getByLabelText(/^id$/i), { target: { value: 'x' } }); + fireEvent.change(screen.getByLabelText(/additional metadata/i), { target: { value: raw } }); + fireEvent.click(screen.getByRole('button', { name: /^register$/i })); + expect(await screen.findByText(/invalid json/i)).toBeInTheDocument(); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('surfaces onSubmit rejection as inline error and keeps dialog open', async () => { + const onSubmit = vi.fn().mockRejectedValue(new Error('backend exploded')); + const onClose = vi.fn(); + render(); + fireEvent.change(screen.getByLabelText(/^id$/i), { target: { value: 'x' } }); + fireEvent.click(screen.getByRole('button', { name: /^register$/i })); + expect(await screen.findByRole('alert')).toHaveTextContent(/backend exploded/i); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('submits merged body on valid input', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( {}} onSubmit={onSubmit} />); + fireEvent.change(screen.getByLabelText(/^id$/i), { target: { value: 'pkg-1' } }); + fireEvent.change(screen.getByLabelText(/^name$/i), { target: { value: 'Pkg One' } }); + fireEvent.click(screen.getByLabelText(/^automated$/i)); + fireEvent.change(screen.getByLabelText(/additional metadata/i), { + target: { value: '{"origins":["a"]}' }, + }); + fireEvent.click(screen.getByRole('button', { name: /^register$/i })); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith({ + id: 'pkg-1', + update_name: 'Pkg One', + automated: true, + origins: ['a'], + }) + ); + }); + + it('always includes automated=false when unchecked', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + render( {}} onSubmit={onSubmit} />); + fireEvent.change(screen.getByLabelText(/^id$/i), { target: { value: 'plain' } }); + fireEvent.click(screen.getByRole('button', { name: /^register$/i })); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith({ + id: 'plain', + update_name: 'plain', + automated: false, + }) + ); + }); +}); diff --git a/src/components/RegisterUpdateDialog.tsx b/src/components/RegisterUpdateDialog.tsx new file mode 100644 index 0000000..ebb8de2 --- /dev/null +++ b/src/components/RegisterUpdateDialog.tsx @@ -0,0 +1,177 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Loader2 } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; + +export interface RegisterUpdateBody { + id: string; + update_name?: string; + automated?: boolean; + [key: string]: unknown; +} + +interface Props { + open: boolean; + onClose: () => void; + onSubmit: (body: RegisterUpdateBody) => Promise; +} + +export function RegisterUpdateDialog({ open, onClose, onSubmit }: Props) { + const [id, setId] = useState(''); + const [name, setName] = useState(''); + const [automated, setAutomated] = useState(false); + const [metadata, setMetadata] = useState('{}'); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (!open) { + setId(''); + setName(''); + setAutomated(false); + setMetadata('{}'); + setError(null); + setSubmitting(false); + } + }, [open]); + + const handleSubmit = async () => { + setError(null); + if (!id.trim()) { + setError('id is required'); + return; + } + let extras: Record = {}; + if (metadata.trim()) { + try { + const parsed = JSON.parse(metadata); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('not an object'); + } + const { id: _i, update_name: _n, automated: _a, ...safe } = parsed as Record; + void _i; + void _n; + void _a; + extras = safe; + } catch { + setError('invalid JSON in additional metadata'); + return; + } + } + const body: RegisterUpdateBody = { + ...extras, + id: id.trim(), + update_name: name.trim() || id.trim(), + automated, + }; + setSubmitting(true); + try { + await onSubmit(body); + onClose(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSubmitting(false); + } + }; + + return ( + { + if (o || submitting) return; + onClose(); + }} + > + submitting && e.preventDefault()} + onPointerDownOutside={(e) => submitting && e.preventDefault()} + onInteractOutside={(e) => submitting && e.preventDefault()} + > + + Register Update + + Register a new update package with the gateway. Vendor-specific fields (origins, signatures, + etc.) go into the metadata JSON. + + +
+
+ + setId(e.target.value)} + aria-invalid={!!error} + aria-describedby={error ? 'reg-error' : undefined} + /> +
+
+ + setName(e.target.value)} /> +
+
+ setAutomated(v === true)} /> + +
+
+ +