diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9c9ab4a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + pull_request: + branches: [master, "version/**"] + push: + branches: [master] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build, lint and test + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@ecf28ddc73e819eb6fa29df6b34ef8921c743461 # v2.1.3 + with: + bun-version: 1.4.4 + - name: Install (frozen lockfile) + run: bun install --frozen-lockfile + - name: Lint + run: bun run lint + - name: Test + run: bun run test -- --run + - name: Build + run: bun run build diff --git a/README.md b/README.md index ce806aa..099c9d7 100644 --- a/README.md +++ b/README.md @@ -17,18 +17,14 @@ Modern React-based web interface for Deploy Center CI/CD platform. ## 📋 Prerequisites -- Node.js 18+ -- npm or yarn +- [Bun](https://bun.sh) 1.4+ (the project's package manager and script runner) - Deploy Center Server running ## 🛠️ Installation ```bash -# Install dependencies -npm install - -# or with yarn -yarn install +# Install dependencies (bun.lock is the only lockfile) +bun install ``` ## ⚙️ Configuration @@ -49,7 +45,7 @@ VITE_API_TIMEOUT=30000 ```bash # Start development server -npm run dev +bun run dev # Server will start at http://localhost:5173 ``` @@ -58,10 +54,10 @@ npm run dev ```bash # Build for production -npm run build +bun run build # Preview production build -npm run preview +bun run preview ``` Build output will be in `dist/` directory. diff --git a/package.json b/package.json index 1f0d8f2..780e083 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "version": "3.0.1", "description": "Deploy Center - Frontend Application | Comprehensive deployment automation platform with real-time monitoring, RBAC, and CI/CD integration", "type": "module", + "packageManager": "bun@1.4.4", "author": "Deploy Center Team", "license": "SEE LICENSE IN LICENSE", "keywords": [ diff --git a/src/components/Settings/NotificationChannelsTab.tsx b/src/components/Settings/NotificationChannelsTab.tsx index 012113e..5ef1ff0 100644 --- a/src/components/Settings/NotificationChannelsTab.tsx +++ b/src/components/Settings/NotificationChannelsTab.tsx @@ -17,7 +17,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { NotificationChannelService, type IChannelListItem, - type IDeliveryConfigInput, + type TDeliveryConfigInput, } from '@/services/notificationChannelService'; import { NotificationProviderService, type IProviderListItem } from '@/services/notificationProviderService'; @@ -66,7 +66,7 @@ export const NotificationChannelsTab: React.FC<{ t: (key: string) => string }> = const selectedProvider = dialog.ProviderId === '' ? undefined : providerById.get(dialog.ProviderId); - const buildDelivery = (): IDeliveryConfigInput | null => { + const buildDelivery = (): TDeliveryConfigInput | null => { if (!selectedProvider) return null; if (selectedProvider.Type === 'discord') { return dialog.discordOverride @@ -98,7 +98,7 @@ export const NotificationChannelsTab: React.FC<{ t: (key: string) => string }> = const updateMut = useMutation({ mutationFn: () => { if (!dialog.editingId) throw new Error('Missing id'); - const payload: { Name?: string; DeliveryConfig?: IDeliveryConfigInput; IsActive?: boolean } = { + const payload: { Name?: string; DeliveryConfig?: TDeliveryConfigInput; IsActive?: boolean } = { Name: dialog.Name, IsActive: dialog.IsActive, }; const delivery = buildDelivery(); diff --git a/src/components/Settings/NotificationProvidersTab.tsx b/src/components/Settings/NotificationProvidersTab.tsx index 56d4c62..1fe0333 100644 --- a/src/components/Settings/NotificationProvidersTab.tsx +++ b/src/components/Settings/NotificationProvidersTab.tsx @@ -19,7 +19,7 @@ import { NotificationProviderService, type IProviderListItem, type TProviderType, - type IProviderConfigInput, + type TProviderConfigInput, } from '@/services/notificationProviderService'; import SmtpPresetPicker, { SMTP_PRESETS, type TSmtpPreset } from './SmtpPresetPicker'; @@ -78,7 +78,7 @@ export const NotificationProvidersTab: React.FC<{ t: (key: string) => string }> const [error, setError] = useState(null); const refetch = () => queryClient.invalidateQueries({ queryKey }); - const buildConfig = (): IProviderConfigInput => { + const buildConfig = (): TProviderConfigInput => { if (dialog.Type === 'discord') return { webhookRoot: dialog.webhookRoot }; if (dialog.Type === 'slack') return { webhookUrl: dialog.webhookUrl || undefined, botToken: dialog.botToken || undefined }; return { @@ -100,7 +100,7 @@ export const NotificationProvidersTab: React.FC<{ t: (key: string) => string }> const updateMut = useMutation({ mutationFn: () => { if (!dialog.editingId) throw new Error('Missing id'); - const payload: { Name?: string; Config?: IProviderConfigInput; IsActive?: boolean } = { + const payload: { Name?: string; Config?: TProviderConfigInput; IsActive?: boolean } = { Name: dialog.Name, IsActive: dialog.IsActive, }; diff --git a/src/pages/Deployments/components/CopyLogButton.tsx b/src/pages/Deployments/components/CopyLogButton.tsx index f12cc54..72cc3ef 100644 --- a/src/pages/Deployments/components/CopyLogButton.tsx +++ b/src/pages/Deployments/components/CopyLogButton.tsx @@ -20,7 +20,7 @@ interface IProps { resetMs?: number; } -type Status = 'idle' | 'success' | 'error'; +type TStatus = 'idle' | 'success' | 'error'; const fallbackCopy = (value: string): boolean => { // Last-resort fallback for non-secure-context browsers. @@ -39,7 +39,7 @@ const fallbackCopy = (value: string): boolean => { }; export const CopyLogButton: React.FC = ({ text, size = 'small', resetMs = 1500 }) => { - const [status, setStatus] = useState('idle'); + const [status, setStatus] = useState('idle'); const handleClick = async () => { let ok = false; diff --git a/src/pages/Projects/ProjectDetailsPage.test.tsx b/src/pages/Projects/ProjectDetailsPage.test.tsx new file mode 100644 index 0000000..8ad4aca --- /dev/null +++ b/src/pages/Projects/ProjectDetailsPage.test.tsx @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; + +// SEC-H3 regression guard: the webhook secret is write-only, so the value the +// server returns on regeneration is the only copy. The page must hand it to +// the webhook card, and only for the project it was generated for. + +const state = vi.hoisted(() => ({ + projectId: 1, + mutate: vi.fn(), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("react-router-dom", () => ({ + useNavigate: () => vi.fn(), + useParams: () => ({ id: String(state.projectId) }), +})); + +vi.mock("@/hooks/useDateFormatter", () => ({ + useDateFormatter: () => ({ formatDateTime: (v: string) => v }), +})); + +vi.mock("@/contexts/RoleContext", () => ({ + useRole: () => ({ isViewer: false, canManageProjects: false }), +})); + +vi.mock("@/contexts/ToastContext", () => ({ + useToast: () => ({ showSuccess: vi.fn(), showError: vi.fn() }), +})); + +vi.mock("@/hooks/useDeployments", () => ({ + useDeployments: () => ({ data: [] }), +})); + +vi.mock("@/hooks/useProjects", () => ({ + useProject: (id: number) => ({ + data: { Id: id, Name: `project-${id}`, IsActive: true, Config: {} }, + isLoading: false, + error: null, + refetch: vi.fn(), + }), + useProjectStatistics: () => ({ data: null }), + useUpdateProject: () => ({ mutate: vi.fn(), isPending: false }), + useDeleteProject: () => ({ mutate: vi.fn(), isPending: false }), + useDeployProject: () => ({ mutate: vi.fn(), isPending: false }), + useRegenerateWebhook: () => ({ mutate: state.mutate, isPending: false }), +})); + +vi.mock("@/components/Projects/DeploymentModal", () => ({ DeploymentModal: () => null })); +vi.mock("@/components/Projects/ProjectFormModal", () => ({ ProjectFormModal: () => null })); +vi.mock("@/components/Projects/SshKeyManagement", () => ({ SshKeyManagement: () => null })); +vi.mock("./components/EnvironmentVariablesCard", () => ({ default: () => null })); +vi.mock("./components/ProjectNotificationsCard", () => ({ default: () => null })); + +vi.mock("./components", () => ({ + ProjectHeader: () => null, + ProjectInfoCard: () => null, + ProjectConfigCard: () => null, + ProjectPipelineCard: () => null, + ProjectStatsCard: () => null, + ProjectDeploymentsTable: () => null, + ProjectMembersCard: () => null, + ProjectWebhookCard: (props: { revealedSecret?: string | null; onRegenerateWebhook: () => void }) => ( +
+ + {props.revealedSecret ?? "none"} +
+ ), +})); + +import { ProjectDetailsPage } from "./ProjectDetailsPage"; + +const openAccessTab = () => { + fireEvent.click(screen.getByRole("tab", { name: /Access/i })); +}; + +describe("ProjectDetailsPage webhook secret (SEC-H3)", () => { + beforeEach(() => { + state.projectId = 1; + state.mutate.mockReset(); + vi.spyOn(window, "confirm").mockReturnValue(true); + }); + + it("passes the regenerated secret to the webhook card", () => { + state.mutate.mockImplementation((_id: number, opts: { onSuccess: (s: string) => void }) => + opts.onSuccess("whsec_new_value") + ); + render(); + openAccessTab(); + expect(screen.getByTestId("revealed")).toHaveTextContent("none"); + + fireEvent.click(screen.getByText("regenerate")); + + expect(state.mutate).toHaveBeenCalledWith(1, expect.anything()); + expect(screen.getByTestId("revealed")).toHaveTextContent("whsec_new_value"); + }); + + it("does not show one project's secret on another project's page", () => { + state.mutate.mockImplementation((_id: number, opts: { onSuccess: (s: string) => void }) => + opts.onSuccess("whsec_project_one") + ); + const { rerender } = render(); + openAccessTab(); + fireEvent.click(screen.getByText("regenerate")); + expect(screen.getByTestId("revealed")).toHaveTextContent("whsec_project_one"); + + state.projectId = 2; + rerender(); + + expect(screen.getByTestId("revealed")).toHaveTextContent("none"); + }); +}); diff --git a/src/pages/Projects/ProjectDetailsPage.tsx b/src/pages/Projects/ProjectDetailsPage.tsx index c52623d..62148cc 100644 --- a/src/pages/Projects/ProjectDetailsPage.tsx +++ b/src/pages/Projects/ProjectDetailsPage.tsx @@ -104,6 +104,10 @@ export const ProjectDetailsPage: React.FC = () => { const deleteProject = useDeleteProject(); const deployProject = useDeployProject(); const regenerateWebhook = useRegenerateWebhook(); + const [RevealedWebhook, setRevealedWebhook] = useState<{ ProjectId: number; Secret: string } | null>(null); + const RevealedWebhookSecret = + RevealedWebhook && project && RevealedWebhook.ProjectId === project.Id ? RevealedWebhook.Secret : null; + // Local UI state const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deployDialogOpen, setDeployDialogOpen] = useState(false); @@ -179,7 +183,10 @@ export const ProjectDetailsPage: React.FC = () => { const projectId = project.Id; regenerateWebhook.mutate(projectId, { - onSuccess: () => { + onSuccess: (secret) => { + // Write-only: this is the only time the new secret is available. + // Keyed by project so another project's page never shows it. + setRevealedWebhook(secret ? { ProjectId: projectId, Secret: secret } : null); showSuccess(t("projects.webhookRegenerated")); }, onError: (error: Error) => { @@ -313,6 +320,7 @@ export const ProjectDetailsPage: React.FC = () => { onRegenerateWebhook={handleRegenerateWebhook} onCopyToClipboard={copyToClipboard} regeneratingWebhook={regenerateWebhook.isPending} + revealedSecret={RevealedWebhookSecret} /> diff --git a/src/pages/Projects/components/ProjectNotificationsCard.tsx b/src/pages/Projects/components/ProjectNotificationsCard.tsx index e83e5f4..abb419c 100644 --- a/src/pages/Projects/components/ProjectNotificationsCard.tsx +++ b/src/pages/Projects/components/ProjectNotificationsCard.tsx @@ -58,6 +58,7 @@ export const ProjectNotificationsCard: React.FC = ({ projectId }) => { if (seededRef.current) { // Merge: for non-dirty rows, take server values; for dirty rows, keep // the user's in-progress edits intact. + // eslint-disable-next-line react-hooks/set-state-in-effect -- syncs server data into local edit state without dropping dirty rows; see the note above setRows((prev) => { const next = new Map(prev); for (const c of channels) { diff --git a/src/pages/Projects/components/ProjectTemplateWizard.tsx b/src/pages/Projects/components/ProjectTemplateWizard.tsx index 23eefc5..3d2e354 100644 --- a/src/pages/Projects/components/ProjectTemplateWizard.tsx +++ b/src/pages/Projects/components/ProjectTemplateWizard.tsx @@ -33,7 +33,7 @@ import { useQuery } from '@tanstack/react-query'; import { ProjectTemplateService, type IProjectTemplate, - type EProjectTemplateCategory, + type TProjectTemplateCategory, } from '@/services/projectTemplateService'; interface IProps { @@ -41,7 +41,7 @@ interface IProps { onClose: (chosen: IProjectTemplate | null | 'cancel') => void; } -const CATEGORY_LABELS: Record = { +const CATEGORY_LABELS: Record = { backend: 'Backend', frontend: 'Frontend', static: 'Static', @@ -59,7 +59,7 @@ export const ProjectTemplateWizard: React.FC = ({ open, onClose }) => { }); const grouped = useMemo(() => { - const map = new Map(); + const map = new Map(); for (const cat of ['backend', 'frontend', 'static', 'other'] as const) { map.set(cat, []); } diff --git a/src/services/notificationChannelService.ts b/src/services/notificationChannelService.ts index 110af53..180db1c 100644 --- a/src/services/notificationChannelService.ts +++ b/src/services/notificationChannelService.ts @@ -19,17 +19,17 @@ export interface IChannelListItem { export interface IDiscordDelivery { webhookSuffix?: string; overrideWebhook?: string; } export interface ISlackDelivery { channel: string; } export interface IEmailDelivery { recipients: string[]; } -export type IDeliveryConfigInput = IDiscordDelivery | ISlackDelivery | IEmailDelivery; +export type TDeliveryConfigInput = IDiscordDelivery | ISlackDelivery | IEmailDelivery; export interface IChannelCreate { ProviderId: number; Name: string; - DeliveryConfig: IDeliveryConfigInput; + DeliveryConfig: TDeliveryConfigInput; } export interface IChannelUpdate { Name?: string; - DeliveryConfig?: IDeliveryConfigInput; + DeliveryConfig?: TDeliveryConfigInput; IsActive?: boolean; } diff --git a/src/services/notificationProviderService.ts b/src/services/notificationProviderService.ts index 53ffb70..ff72a9b 100644 --- a/src/services/notificationProviderService.ts +++ b/src/services/notificationProviderService.ts @@ -29,17 +29,17 @@ export interface IEmailConfigInput { presetName?: 'gmail' | 'sendgrid' | 'mailgun' | 'custom'; } -export type IProviderConfigInput = IDiscordConfigInput | ISlackConfigInput | IEmailConfigInput; +export type TProviderConfigInput = IDiscordConfigInput | ISlackConfigInput | IEmailConfigInput; export interface IProviderCreate { Name: string; Type: TProviderType; - Config: IProviderConfigInput; + Config: TProviderConfigInput; } export interface IProviderUpdate { Name?: string; - Config?: IProviderConfigInput; + Config?: TProviderConfigInput; IsActive?: boolean; } diff --git a/src/services/projectTemplateService.ts b/src/services/projectTemplateService.ts index 5327d9f..750eb67 100644 --- a/src/services/projectTemplateService.ts +++ b/src/services/projectTemplateService.ts @@ -7,14 +7,14 @@ import ApiInstance from './api'; -export type EProjectTemplateCategory = 'backend' | 'frontend' | 'static' | 'other'; +export type TProjectTemplateCategory = 'backend' | 'frontend' | 'static' | 'other'; export interface IProjectTemplate { Id: number; Name: string; Description: string | null; Icon: string | null; - Category: EProjectTemplateCategory; + Category: TProjectTemplateCategory; DefaultConfig: Record; IsBuiltIn: boolean; CreatedBy: number | null; @@ -26,7 +26,7 @@ export interface IProjectTemplateCreate { Name: string; Description?: string | null; Icon?: string | null; - Category: EProjectTemplateCategory; + Category: TProjectTemplateCategory; DefaultConfig: Record; } @@ -34,12 +34,12 @@ export interface IProjectTemplateUpdate { Name?: string; Description?: string | null; Icon?: string | null; - Category?: EProjectTemplateCategory; + Category?: TProjectTemplateCategory; DefaultConfig?: Record; } export const ProjectTemplateService = { - list: async (category?: EProjectTemplateCategory): Promise => { + list: async (category?: TProjectTemplateCategory): Promise => { const params = category ? { category } : undefined; const res = await ApiInstance.get('/project-templates', { params }); const data = res.data?.Data;