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
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*

Check failure on line 1 in public/dashboard-assistant/components/__tests__/model-register.test.tsx

View workflow job for this annotation

GitHub Actions / Run lint

File must start with a license header
* Copyright Wazuh Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React from 'react';
import userEvent from '@testing-library/user-event';
import { render, screen, waitFor } from '../../../../test/test_utils';

jest.mock('../model-form', () => {
const _React = jest.requireActual<typeof import('react')>('react');

const MockModelForm = ({ onChange, onValidationChange }: any) => {
_React.useEffect(() => {
onChange?.({
modelProvider: 'OpenAI',
model: 'gpt-4o',
apiUrl: 'https://api.openai.com/v1/chat/completions',
apiKey: 'mock-key',
});
onValidationChange?.(true);
}, [onChange, onValidationChange]);

return _React.createElement('div', { 'data-testid': 'mock-model-form' });
};

return {
__esModule: true,
ModelForm: MockModelForm,
};
});

jest.mock('../../modules/installation-manager/hooks/use-assistant-installation', () => {
const React = jest.requireActual<typeof import('react')>('react');

Check failure on line 34 in public/dashboard-assistant/components/__tests__/model-register.test.tsx

View workflow job for this annotation

GitHub Actions / Run lint

'React' is already declared in the upper scope
const { ExecutionState } = jest.requireActual<
typeof import('../../modules/installation-manager/domain')
>('../../modules/installation-manager/domain');

const failedStep = {
stepName: 'Create Model',
state: ExecutionState.FAILED,
error: new Error('Model step failed'),
};

const progress = {
getSteps: () => [
{
stepName: 'Persist ML Commons settings',
state: ExecutionState.FINISHED_SUCCESSFULLY,
},
failedStep,
],
getFailedSteps: () => [failedStep],
isFinished: () => false,
isFinishedSuccessfully: () => false,
isFinishedWithWarnings: () => false,
hasFailed: () => true,
};

return {
__esModule: true,
useAssistantInstallation: () => {
const [error, setError] = React.useState<string | undefined>(undefined);

const install = React.useCallback(async () => {
setError('Steps: "Create Model" has failed');
}, []);

return {
install,
setModel: jest.fn(),
reset: jest.fn(),
isLoading: false,
error,
result: { success: false, rollbacks: ['Create Agent', 'Create Connector'] },
modelData: undefined,
progress,
isSuccess: false,
};
},
};
});

import { ModelRegister } from '../model-register';

describe('ModelRegister', () => {
it('does not show success toast when installation fails', async () => {
const user = userEvent.setup();

render(<ModelRegister />);

const deployButton = await screen.findByRole('button', { name: /Deploy/i });
expect(deployButton).toBeEnabled();

await user.click(deployButton);

await screen.findByText(/Error deploying model/i);

await waitFor(() => {
expect(screen.queryByText('Model deployed successfully.')).not.toBeInTheDocument();
});

expect(await screen.findByText(/Rollback summary/i)).toBeInTheDocument();
expect(
await screen.findByText(/Reverted steps: Create Agent, Create Connector/i)
).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export const DeploymentStatus = ({
<div style={{ display: 'flex', alignItems: 'center' }}>
{uiStatus === StepStatus.ERROR && step?.error ? (
<EuiPopover
panelStyle={{ wordBreak: 'break-word' }}
isOpen={openErrorPopoverKey === key}
closePopover={() => setOpenErrorPopoverKey(null)}
anchorPosition="rightCenter"
Expand Down
20 changes: 17 additions & 3 deletions public/dashboard-assistant/components/model-register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import React, { useState, useCallback, useEffect } from 'react';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import {
EuiButton,
EuiButtonEmpty,
Expand Down Expand Up @@ -50,15 +50,17 @@ const ModelRegisterComponent = ({
onDeployed,
}: ModelRegisterProps) => {
const [isDeployed, setIsDeployed] = useState(false);
const { addSuccessToast, addErrorToast } = useToast();
const { addSuccessToast, addErrorToast, addInfoToast } = useToast();
const {
install: startInstallationProcess,
setModel,
isLoading: isInstalling,
error: installationError,
progress: installationProgress,
isSuccess: isInstallationSuccessful,
result: installationResult,
} = useAssistantInstallation();
const lastRollbackKeyRef = useRef<string | null>(null);

useEffect(() => {
if (installationError) {
Expand All @@ -67,7 +69,19 @@ const ModelRegisterComponent = ({
`${installationError}. Rolling back current installation. Please, verify data provided and try again.`
);
}
}, [addErrorToast, addSuccessToast, installationError]);
}, [addErrorToast, installationError]);

useEffect(() => {
if (installationError && installationResult?.rollbacks?.length) {
const rollbackSummary = installationResult.rollbacks.join(', ');
if (lastRollbackKeyRef.current !== rollbackSummary) {
addInfoToast('Rollback summary', `Reverted steps: ${rollbackSummary}`);
lastRollbackKeyRef.current = rollbackSummary;
}
} else if (!installationError) {
lastRollbackKeyRef.current = null;
}
}, [addInfoToast, installationError, installationResult]);

useEffect(() => {
if (isInstallationSuccessful) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { AgentRepository } from '../agent-repository';
export function createAgentRepositoryMock(): jest.Mocked<AgentRepository> {
return {
create: jest.fn(),
delete: jest.fn(),
execute: jest.fn(),
getActive: jest.fn(),
register: jest.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { CreateRepository } from '../../../common/domain/entities/repository';
import { CreateRepository, DeleteRepository } from '../../../common/domain/entities/repository';
import { Agent } from '../../domain/entities/agent';
import { CreateAgentDto } from '../dtos/create-agent-dto';

export interface AgentRepository extends CreateRepository<Agent, CreateAgentDto> {
export interface AgentRepository extends CreateRepository<Agent, CreateAgentDto>, DeleteRepository {
execute(id: string, parameters: any): Promise<any>;
getActive(): Promise<string | undefined>;
register(agentId: string): Promise<void>;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*
* Copyright Wazuh Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import type { AgentRepository } from '../ports/agent-repository';

export const deleteAgentUseCase = (agentRepository: AgentRepository) => async (
agentId: string
): Promise<void> => {
await agentRepository.delete(agentId);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
* Copyright Wazuh Contributors
* SPDX-License-Identifier: Apache-2.0
*/

const SENSITIVE_KEY_PATTERN = /(key|token|secret|password|authorization)$/i;
const MAX_BODY_LENGTH = 1000;

export class HttpError extends Error {
public response?: Response;
public status?: number;
public statusText?: string;
public request?: { method: string; url: string; body?: string };

private constructor(message: string) {
super(message);
this.name = 'HttpError';
}

public static async create(
response: Response,
options: RequestInit & { method: string },
url: string
): Promise<HttpError> {
const method = options.method.toUpperCase();
const sanitizedUrl = this.sanitizeUrl(url);

const statusText = response.statusText?.trim();
const statusSummary = statusText ? `${response.status} ${statusText}` : `${response.status}`;

const message = this.buildFailureMessage({
method,
url: sanitizedUrl,
statusSummary,
});

const error = new HttpError(message);
error.response = response;
error.status = response.status;
error.statusText = response.statusText;
error.request = {
method,
url: sanitizedUrl,
body: this.sanitizeRequestBody(options.body ?? undefined),
};

return error;
}

private static buildFailureMessage(params: {
method: string;
url: string;
statusSummary: string;
}): string {
const { method, url, statusSummary } = params;

const messageParts = [`HTTP ${method} ${url} failed with status ${statusSummary}`].filter(
Boolean
);

return messageParts.join('. ');
}

private static sanitizeUrl(url: string): string {
try {
const base =
typeof window !== 'undefined' && window.location?.origin
? window.location.origin
: 'http://localhost';
const parsed = new URL(url, base);
parsed.searchParams.forEach((value, key) => {
if (SENSITIVE_KEY_PATTERN.test(key)) {
parsed.searchParams.delete(key);
}
});

if (!url.startsWith('http')) {
return `${parsed.pathname}${parsed.search}${parsed.hash}` || parsed.pathname;
}

return parsed.toString();
} catch (error) {
return url;
}
}

private static sanitizeStructuredValue(value: unknown): unknown {
if (value == null) {
return value;
}

if (Array.isArray(value)) {
return value.map((item) => this.sanitizeStructuredValue(item));
}

if (typeof value === 'object') {
return Object.entries(value as Record<string, unknown>).reduce<Record<string, unknown>>(
(acc, [key, entryValue]) => {
if (typeof entryValue !== 'string' || !SENSITIVE_KEY_PATTERN.test(key)) {
acc[key] = this.sanitizeStructuredValue(entryValue);
}
return acc;
},
{}
);
}

return value;
}

private static sanitizeRequestBody(body?: BodyInit | null): string | undefined {
if (!body) {
return undefined;
}

if (typeof body === 'string') {
try {
const parsed = JSON.parse(body);
const sanitized = this.sanitizeStructuredValue(parsed);
return JSON.stringify(sanitized);
} catch (error) {
return body.length > MAX_BODY_LENGTH ? `${body.slice(0, MAX_BODY_LENGTH)}...` : body;
}
}

return '';
}
}
Loading
Loading