refactor(auth): remove keys auth module and align tests
- remove server auth module and client keys page/routes/api to simplify flow - update mcp and scenario tests to match uuid routes and step schema
This commit is contained in:
+1
-2
@@ -1,5 +1,4 @@
|
|||||||
# runtime / secrets
|
# runtime
|
||||||
/keys
|
|
||||||
/data
|
/data
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
+1
-4
@@ -1,6 +1,6 @@
|
|||||||
import { NavLink, Navigate, Route, Routes } from 'react-router-dom';
|
import { NavLink, Navigate, Route, Routes } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Globe, KeyRound, Monitor, ClipboardList, Activity, KeySquare, Braces } from 'lucide-react';
|
import { Globe, Monitor, ClipboardList, Activity, KeySquare, Braces } from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import styles from './App.module.css';
|
import styles from './App.module.css';
|
||||||
import { SidePanel, ThemeSwitcher } from './ui';
|
import { SidePanel, ThemeSwitcher } from './ui';
|
||||||
@@ -9,7 +9,6 @@ import { EnvironmentsPage } from './pages/environment/EnvironmentsPage';
|
|||||||
import { EnvironmentDetailPage } from './pages/environment/EnvironmentDetailPage';
|
import { EnvironmentDetailPage } from './pages/environment/EnvironmentDetailPage';
|
||||||
import { CreateEnvironmentPage } from './pages/environment/CreateEnvironmentPage';
|
import { CreateEnvironmentPage } from './pages/environment/CreateEnvironmentPage';
|
||||||
import { EditEnvironmentPage } from './pages/environment/EditEnvironmentPage';
|
import { EditEnvironmentPage } from './pages/environment/EditEnvironmentPage';
|
||||||
import { KeysPage } from './pages/KeysPage';
|
|
||||||
import { SessionsPage } from './pages/session/SessionsPage';
|
import { SessionsPage } from './pages/session/SessionsPage';
|
||||||
import { SessionDetailPage } from './pages/session/SessionDetailPage';
|
import { SessionDetailPage } from './pages/session/SessionDetailPage';
|
||||||
import { ScenariosPage } from './pages/scenario/ScenariosPage';
|
import { ScenariosPage } from './pages/scenario/ScenariosPage';
|
||||||
@@ -34,7 +33,6 @@ const NAV: { path: string; labelKey: string; Icon: LucideIcon }[] = [
|
|||||||
{ path: '/environments', labelKey: 'nav.environments', Icon: Globe },
|
{ path: '/environments', labelKey: 'nav.environments', Icon: Globe },
|
||||||
{ path: '/credentials', labelKey: 'nav.credentials', Icon: KeySquare },
|
{ path: '/credentials', labelKey: 'nav.credentials', Icon: KeySquare },
|
||||||
{ path: '/snippets', labelKey: 'nav.snippets', Icon: Braces },
|
{ path: '/snippets', labelKey: 'nav.snippets', Icon: Braces },
|
||||||
{ path: '/keys', labelKey: 'nav.keys', Icon: KeyRound },
|
|
||||||
{ path: '/sessions', labelKey: 'nav.sessions', Icon: Monitor },
|
{ path: '/sessions', labelKey: 'nav.sessions', Icon: Monitor },
|
||||||
{ path: '/scenarios', labelKey: 'nav.scenarios', Icon: ClipboardList },
|
{ path: '/scenarios', labelKey: 'nav.scenarios', Icon: ClipboardList },
|
||||||
{ path: '/runs', labelKey: 'nav.runs', Icon: Activity },
|
{ path: '/runs', labelKey: 'nav.runs', Icon: Activity },
|
||||||
@@ -77,7 +75,6 @@ export default function App() {
|
|||||||
<Route path="/environments/new" element={<CreateEnvironmentPage />} />
|
<Route path="/environments/new" element={<CreateEnvironmentPage />} />
|
||||||
<Route path="/environments/:id/edit" element={<EditEnvironmentPage />} />
|
<Route path="/environments/:id/edit" element={<EditEnvironmentPage />} />
|
||||||
<Route path="/environments/:id" element={<EnvironmentDetailPage />} />
|
<Route path="/environments/:id" element={<EnvironmentDetailPage />} />
|
||||||
<Route path="/keys" element={<KeysPage />} />
|
|
||||||
<Route path="/sessions" element={<SessionsPage />} />
|
<Route path="/sessions" element={<SessionsPage />} />
|
||||||
<Route path="/sessions/:id" element={<SessionDetailPage />} />
|
<Route path="/sessions/:id" element={<SessionDetailPage />} />
|
||||||
<Route path="/scenarios" element={<ScenariosPage />} />
|
<Route path="/scenarios" element={<ScenariosPage />} />
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type {
|
|||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
Credential,
|
Credential,
|
||||||
Environment,
|
Environment,
|
||||||
KeysResponse,
|
|
||||||
ScenarioCredential,
|
ScenarioCredential,
|
||||||
Session,
|
Session,
|
||||||
Scenario,
|
Scenario,
|
||||||
@@ -13,7 +12,7 @@ import type {
|
|||||||
Snippet,
|
Snippet,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// In dev, Vite proxies /environments /sessions /scenarios /keys to localhost:3000.
|
// In dev, Vite proxies /environments /sessions /scenarios to localhost:3000.
|
||||||
// In production (or when VITE_API_URL is set) we hit the configured origin directly.
|
// In production (or when VITE_API_URL is set) we hit the configured origin directly.
|
||||||
const BASE_URL: string = (import.meta.env.VITE_API_URL as string | undefined) ?? '';
|
const BASE_URL: string = (import.meta.env.VITE_API_URL as string | undefined) ?? '';
|
||||||
|
|
||||||
@@ -132,14 +131,6 @@ export const environments = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Keys ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const keys = {
|
|
||||||
list(): Promise<KeysResponse> {
|
|
||||||
return request('/keys');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Sessions ──────────────────────────────────────────────────────────────────
|
// ── Sessions ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const sessions = {
|
export const sessions = {
|
||||||
|
|||||||
@@ -46,12 +46,6 @@ export interface Snippet {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Keys ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface KeysResponse {
|
|
||||||
keys: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Sessions ──────────────────────────────────────────────────────────────────
|
// ── Sessions ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type SessionStatus = 'open' | 'closed';
|
export type SessionStatus = 'open' | 'closed';
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { keys } from '../api';
|
|
||||||
import { Breadcrumbs, Table, type TableColumn } from '../ui';
|
|
||||||
import styles from './Page.module.css';
|
|
||||||
|
|
||||||
interface KeyRow {
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KeysPage() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [items, setItems] = useState<KeyRow[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const columns: TableColumn<KeyRow>[] = [
|
|
||||||
{ key: 'name', header: t('keys.col_name'), render: (k) => k.name },
|
|
||||||
];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
keys
|
|
||||||
.list()
|
|
||||||
.then((res) => setItems(res.keys.map((name) => ({ name }))))
|
|
||||||
.catch((err: Error) => setError(err.message))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Breadcrumbs items={[{ label: t('keys.title') }]} className={styles.breadcrumbs} />
|
|
||||||
{error && <p className={styles.error}>{error}</p>}
|
|
||||||
<Table
|
|
||||||
columns={columns}
|
|
||||||
data={items}
|
|
||||||
rowKey={(k) => k.name}
|
|
||||||
loading={loading}
|
|
||||||
emptyMessage={t('keys.empty')}
|
|
||||||
pageSize={10}
|
|
||||||
pageSizeOptions={[10, 25, 50]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -17,8 +17,6 @@ export default defineConfig({
|
|||||||
'/snippets': 'http://localhost:13000',
|
'/snippets': 'http://localhost:13000',
|
||||||
'/sessions': 'http://localhost:13000',
|
'/sessions': 'http://localhost:13000',
|
||||||
'/scenarios': 'http://localhost:13000',
|
'/scenarios': 'http://localhost:13000',
|
||||||
'/keys': 'http://localhost:13000',
|
|
||||||
'/login': 'http://localhost:13000',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { AppConfig, validateAppConfig } from "./config/app.config";
|
|||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
import { ScheduleModule } from "@nestjs/schedule";
|
import { ScheduleModule } from "@nestjs/schedule";
|
||||||
import { HealthModule } from "./health/health.module";
|
import { HealthModule } from "./health/health.module";
|
||||||
import { AuthModule } from "./auth/auth.module";
|
|
||||||
import { BrowserModule } from "./browser/browser.module";
|
import { BrowserModule } from "./browser/browser.module";
|
||||||
import { SessionEntity } from "./session/session.entity";
|
import { SessionEntity } from "./session/session.entity";
|
||||||
import { EnvironmentEntity } from "./environment/environment.entity";
|
import { EnvironmentEntity } from "./environment/environment.entity";
|
||||||
@@ -50,7 +49,6 @@ import { SnippetModule } from "./snippet/snippet.module";
|
|||||||
synchronize: true,
|
synchronize: true,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
AuthModule,
|
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
EnvironmentModule,
|
EnvironmentModule,
|
||||||
CredentialModule,
|
CredentialModule,
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Body, Controller, Get, Post } from "@nestjs/common";
|
|
||||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
|
||||||
import { AuthService } from "./auth.service";
|
|
||||||
import { LoginDto } from "./dto/login.dto";
|
|
||||||
|
|
||||||
@ApiTags("auth")
|
|
||||||
@Controller()
|
|
||||||
export class AuthController {
|
|
||||||
constructor(private readonly authService: AuthService) {}
|
|
||||||
|
|
||||||
@Get("keys")
|
|
||||||
@ApiOperation({
|
|
||||||
summary: "List available key identifiers from the keys directory",
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: "List of key names",
|
|
||||||
schema: {
|
|
||||||
properties: { keys: { type: "array", items: { type: "string" } } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
listKeys(): { keys: string[] } {
|
|
||||||
return { keys: this.authService.listKeys() };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post("login")
|
|
||||||
@ApiOperation({
|
|
||||||
summary: "Log in using a file key and return the session token",
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 201,
|
|
||||||
description: "Login successful",
|
|
||||||
schema: {
|
|
||||||
properties: {
|
|
||||||
token: { type: "string" },
|
|
||||||
sessionName: { type: "string" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 400, description: "Key not found or invalid" })
|
|
||||||
@ApiResponse({ status: 500, description: "Automation failed" })
|
|
||||||
login(
|
|
||||||
@Body() dto: LoginDto,
|
|
||||||
): Promise<{ token: string; sessionName: string }> {
|
|
||||||
return this.authService.login(
|
|
||||||
dto.key,
|
|
||||||
dto.environmentName,
|
|
||||||
dto.sessionName,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { Module } from "@nestjs/common";
|
|
||||||
import { AuthController } from "./auth.controller";
|
|
||||||
import { AuthService } from "./auth.service";
|
|
||||||
import { SessionModule } from "../session/session.module";
|
|
||||||
import { EnvironmentModule } from "../environment/environment.module";
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [SessionModule, EnvironmentModule],
|
|
||||||
controllers: [AuthController],
|
|
||||||
providers: [AuthService],
|
|
||||||
exports: [AuthService],
|
|
||||||
})
|
|
||||||
export class AuthModule {}
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
import {
|
|
||||||
Injectable,
|
|
||||||
BadRequestException,
|
|
||||||
InternalServerErrorException,
|
|
||||||
NotFoundException,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { TraceLogger } from "../common/trace-logger";
|
|
||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
import { AppConfig } from "../config/app.config";
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import type { Page } from "playwright";
|
|
||||||
import * as fs from "fs";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as crypto from "crypto";
|
|
||||||
import { SessionService } from "../session/session.service";
|
|
||||||
import { SessionContextService } from "../session/session-context.service";
|
|
||||||
import { EnvironmentService } from "../environment/environment.service";
|
|
||||||
|
|
||||||
interface KeyDescriptor {
|
|
||||||
keyFile?: string;
|
|
||||||
login?: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AuthService {
|
|
||||||
private readonly logger = new TraceLogger(AuthService.name);
|
|
||||||
private readonly keysDir: string;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly config: ConfigService<AppConfig, true>,
|
|
||||||
private readonly sessionService: SessionService,
|
|
||||||
private readonly sessionContextService: SessionContextService,
|
|
||||||
private readonly environmentService: EnvironmentService,
|
|
||||||
) {
|
|
||||||
this.keysDir = path.resolve(this.config.get("KEYS_DIR"));
|
|
||||||
}
|
|
||||||
|
|
||||||
listKeys(): string[] {
|
|
||||||
if (!fs.existsSync(this.keysDir)) return [];
|
|
||||||
return fs
|
|
||||||
.readdirSync(this.keysDir)
|
|
||||||
.filter((f) => f.endsWith(".json"))
|
|
||||||
.map((f) => path.basename(f, ".json"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadKeyDescriptor(keyId: string): KeyDescriptor {
|
|
||||||
const keyJsonPath = path.join(this.keysDir, `${keyId}.json`);
|
|
||||||
if (!fs.existsSync(keyJsonPath)) {
|
|
||||||
throw new BadRequestException(`Key not found: ${keyId}`);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.parse(fs.readFileSync(keyJsonPath, "utf-8")) as KeyDescriptor;
|
|
||||||
} catch (err) {
|
|
||||||
throw new BadRequestException(`Cannot read key descriptor: ${keyId}`, {
|
|
||||||
cause: err,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async login(
|
|
||||||
keyId: string,
|
|
||||||
environmentName: string,
|
|
||||||
sessionName?: string,
|
|
||||||
): Promise<{ token: string; sessionName: string }> {
|
|
||||||
const resolvedSession = sessionName ?? crypto.randomUUID();
|
|
||||||
|
|
||||||
const env = await this.environmentService
|
|
||||||
.findAll()
|
|
||||||
.then(({ data }) => data.find((e) => e.name === environmentName));
|
|
||||||
if (!env)
|
|
||||||
throw new NotFoundException(`Environment "${environmentName}" not found`);
|
|
||||||
|
|
||||||
const loginUrl = env.urls.id_url;
|
|
||||||
const cabinetUrl = env.urls.cabinet_url;
|
|
||||||
if (!loginUrl)
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Environment "${environmentName}" is missing id_url`,
|
|
||||||
);
|
|
||||||
if (!cabinetUrl)
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Environment "${environmentName}" is missing cabinet_url`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const descriptor = this.loadKeyDescriptor(keyId);
|
|
||||||
const useLoginPassword = !!descriptor.login;
|
|
||||||
|
|
||||||
if (!useLoginPassword) {
|
|
||||||
if (!descriptor.keyFile) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Key descriptor for "${keyId}" must have either "login" or "keyFile"`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const keyFilePath = path.resolve(this.keysDir, descriptor.keyFile);
|
|
||||||
if (!fs.existsSync(keyFilePath)) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Key file not found: ${descriptor.keyFile}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const browser = await chromium.launch({
|
|
||||||
headless: true,
|
|
||||||
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const context = await browser.newContext();
|
|
||||||
const page = await context.newPage();
|
|
||||||
|
|
||||||
this.logger.log(`Navigating to ${loginUrl}`);
|
|
||||||
await page.goto(loginUrl, { waitUntil: "networkidle" });
|
|
||||||
|
|
||||||
if (useLoginPassword) {
|
|
||||||
// Click "Логін і пароль" auth method
|
|
||||||
await page.locator('p[aria-label="Логін і пароль"]').click();
|
|
||||||
|
|
||||||
// Fill login and password
|
|
||||||
await page.getByLabel("Електронна пошта").fill(descriptor.login!);
|
|
||||||
await page.getByLabel("Пароль").fill(descriptor.password);
|
|
||||||
|
|
||||||
// Click "Увійти"
|
|
||||||
await page.locator('button:has-text("Увійти")').click();
|
|
||||||
} else {
|
|
||||||
const keyFilePath = path.resolve(this.keysDir, descriptor.keyFile!);
|
|
||||||
|
|
||||||
// Click "Файловий ключ" button
|
|
||||||
await page.getByText("Файловий ключ").click();
|
|
||||||
|
|
||||||
// Upload key file via hidden file input
|
|
||||||
const fileInput = page.locator(
|
|
||||||
'input[accept=".dat,.pfx,.pk8,.zs2,.jks"][type="file"]',
|
|
||||||
);
|
|
||||||
await fileInput.setInputFiles(keyFilePath);
|
|
||||||
|
|
||||||
// Enter password
|
|
||||||
await page
|
|
||||||
.locator("#id-app-login-file-key-password")
|
|
||||||
.fill(descriptor.password);
|
|
||||||
|
|
||||||
// Click "Продовжити"
|
|
||||||
await page.locator("#id-app-login-file-key-sign-button").click();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait until redirected to cabinet
|
|
||||||
this.logger.log(`Waiting for redirect to ${cabinetUrl}`);
|
|
||||||
await page.waitForURL(cabinetUrl, { timeout: 30000 });
|
|
||||||
|
|
||||||
// Extract token from localStorage
|
|
||||||
const token = await page.evaluate(() => localStorage.getItem("token"));
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
throw new InternalServerErrorException(
|
|
||||||
"Login succeeded but token was not found in localStorage",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture all cookies and localStorage from the browser context
|
|
||||||
const cookies = await context.cookies();
|
|
||||||
const localStorageData = await page.evaluate(() => {
|
|
||||||
const entries: Record<string, string> = {};
|
|
||||||
for (let i = 0; i < window.localStorage.length; i++) {
|
|
||||||
const k = window.localStorage.key(i);
|
|
||||||
if (k !== null) entries[k] = window.localStorage.getItem(k) ?? "";
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.sessionService.upsert(
|
|
||||||
resolvedSession,
|
|
||||||
token,
|
|
||||||
cookies,
|
|
||||||
localStorageData,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Register the live Playwright context — browser stays open for reuse
|
|
||||||
this.sessionContextService.register(
|
|
||||||
resolvedSession,
|
|
||||||
browser,
|
|
||||||
context,
|
|
||||||
page,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Login successful for key ${keyId}, session: ${resolvedSession}`,
|
|
||||||
);
|
|
||||||
return { token, sessionName: resolvedSession };
|
|
||||||
} catch (err) {
|
|
||||||
// Close browser only on failure — on success it is kept alive in SessionContextService
|
|
||||||
await browser.close().catch(() => {});
|
|
||||||
if (
|
|
||||||
err instanceof BadRequestException ||
|
|
||||||
err instanceof InternalServerErrorException
|
|
||||||
) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
this.logger.error(`Login failed: ${(err as Error).message}`);
|
|
||||||
throw new InternalServerErrorException(
|
|
||||||
`Login automation failed: ${(err as Error).message}`,
|
|
||||||
{ cause: err },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async signWithKey(keyId: string, page: Page): Promise<void> {
|
|
||||||
const descriptor = this.loadKeyDescriptor(keyId);
|
|
||||||
|
|
||||||
if (!descriptor.keyFile) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Key descriptor for "${keyId}" must have "keyFile" to sign`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyFilePath = path.resolve(this.keysDir, descriptor.keyFile);
|
|
||||||
if (!fs.existsSync(keyFilePath)) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Key file not found: ${descriptor.keyFile}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Signing with key ${keyId} on page: ${page.url()}`);
|
|
||||||
|
|
||||||
// Open the EDS sign widget (skip if it's already open)
|
|
||||||
const isSignDialogOpen = await page
|
|
||||||
.locator('input[accept=".dat,.pfx,.pk8,.zs2,.jks"][type="file"]')
|
|
||||||
.isVisible()
|
|
||||||
.catch(() => false);
|
|
||||||
if (!isSignDialogOpen) {
|
|
||||||
await page
|
|
||||||
.locator("button")
|
|
||||||
.filter({ hasText: /підпис|sign/i })
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Select the file key tab inside the widget
|
|
||||||
await page
|
|
||||||
.locator('button, [role="tab"], li')
|
|
||||||
.filter({ hasText: /файлов|file key/i })
|
|
||||||
.first()
|
|
||||||
.click();
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
|
|
||||||
// Upload the key file
|
|
||||||
const fileInput = page.locator(
|
|
||||||
'input[accept=".dat,.pfx,.pk8,.zs2,.jks"][type="file"]',
|
|
||||||
);
|
|
||||||
await fileInput.setInputFiles(keyFilePath);
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
|
|
||||||
// Enter password
|
|
||||||
await page.locator('input[type="password"]').fill(descriptor.password);
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
|
|
||||||
// Submit
|
|
||||||
await page
|
|
||||||
.locator("button")
|
|
||||||
.filter({ hasText: /підпис|sign|підтвер/i })
|
|
||||||
.last()
|
|
||||||
.click();
|
|
||||||
await page.waitForLoadState("networkidle");
|
|
||||||
|
|
||||||
this.logger.log(`Sign completed for key ${keyId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
|
||||||
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
|
|
||||||
|
|
||||||
export class LoginDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description:
|
|
||||||
"Key identifier — filename (without extension) from the keys/ directory",
|
|
||||||
example: "3273334361",
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
key: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: "Environment name to resolve login/cabinet URLs from",
|
|
||||||
example: "liquio-diia-stg",
|
|
||||||
})
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
environmentName: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description:
|
|
||||||
"Session name to store credentials under. Auto-generated UUID if omitted.",
|
|
||||||
example: "my-test-session",
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
sessionName?: string;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { McpController } from "./mcp.controller";
|
import { McpController } from "./mcp.controller";
|
||||||
import { McpService } from "./mcp.service";
|
import { McpService } from "./mcp.service";
|
||||||
import { AuthModule } from "../auth/auth.module";
|
|
||||||
import { SessionModule } from "../session/session.module";
|
import { SessionModule } from "../session/session.module";
|
||||||
import { EnvironmentModule } from "../environment/environment.module";
|
import { EnvironmentModule } from "../environment/environment.module";
|
||||||
import { BrowserModule } from "../browser/browser.module";
|
import { BrowserModule } from "../browser/browser.module";
|
||||||
@@ -10,7 +9,6 @@ import { ScenarioModule } from "../scenario/scenario.module";
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
AuthModule,
|
|
||||||
SessionModule,
|
SessionModule,
|
||||||
EnvironmentModule,
|
EnvironmentModule,
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|||||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { AuthService } from "../auth/auth.service";
|
|
||||||
import { SessionService } from "../session/session.service";
|
import { SessionService } from "../session/session.service";
|
||||||
import { SessionContextService } from "../session/session-context.service";
|
import { SessionContextService } from "../session/session-context.service";
|
||||||
import { EnvironmentService } from "../environment/environment.service";
|
import { EnvironmentService } from "../environment/environment.service";
|
||||||
@@ -17,7 +16,6 @@ import pkg from "../../package.json";
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class McpService {
|
export class McpService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly authService: AuthService,
|
|
||||||
private readonly sessionService: SessionService,
|
private readonly sessionService: SessionService,
|
||||||
private readonly sessionContextService: SessionContextService,
|
private readonly sessionContextService: SessionContextService,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
@@ -33,53 +31,6 @@ export class McpService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private registerTools(server: McpServer): void {
|
private registerTools(server: McpServer): void {
|
||||||
// ── Auth ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"list_keys",
|
|
||||||
{ description: "List available key identifiers from the keys directory" },
|
|
||||||
async () => {
|
|
||||||
const keys = this.authService.listKeys();
|
|
||||||
return {
|
|
||||||
content: [{ type: "text" as const, text: JSON.stringify(keys) }],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"login",
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Log in using a file key against a named environment and store the session",
|
|
||||||
inputSchema: {
|
|
||||||
key: z
|
|
||||||
.string()
|
|
||||||
.describe(
|
|
||||||
"Key identifier (filename without extension from keys/ dir)",
|
|
||||||
),
|
|
||||||
environmentName: z
|
|
||||||
.string()
|
|
||||||
.describe("Environment name to resolve login/cabinet URLs"),
|
|
||||||
sessionName: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"Session name to store credentials under. Auto-UUID if omitted.",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ key, environmentName, sessionName }) => {
|
|
||||||
const result = await this.authService.login(
|
|
||||||
key,
|
|
||||||
environmentName,
|
|
||||||
sessionName,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Sessions ──────────────────────────────────────────────────────────────
|
// ── Sessions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
|||||||
import { Type } from "class-transformer";
|
import { Type } from "class-transformer";
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { CredentialEntity } from "../credential/credential.entity";
|
|||||||
import { ScenarioService } from "./scenario.service";
|
import { ScenarioService } from "./scenario.service";
|
||||||
import { ScenarioController } from "./scenario.controller";
|
import { ScenarioController } from "./scenario.controller";
|
||||||
import { ScenarioSchedulerService } from "./scenario-scheduler.service";
|
import { ScenarioSchedulerService } from "./scenario-scheduler.service";
|
||||||
import { AuthModule } from "../auth/auth.module";
|
|
||||||
import { CodeExecutorModule } from "../code-executor/code-executor.module";
|
import { CodeExecutorModule } from "../code-executor/code-executor.module";
|
||||||
import { SessionModule } from "../session/session.module";
|
import { SessionModule } from "../session/session.module";
|
||||||
import { EnvironmentModule } from "../environment/environment.module";
|
import { EnvironmentModule } from "../environment/environment.module";
|
||||||
@@ -27,7 +26,6 @@ import { SnippetModule } from "../snippet/snippet.module";
|
|||||||
ScenarioCredentialEntity,
|
ScenarioCredentialEntity,
|
||||||
CredentialEntity,
|
CredentialEntity,
|
||||||
]),
|
]),
|
||||||
AuthModule,
|
|
||||||
CodeExecutorModule,
|
CodeExecutorModule,
|
||||||
SessionModule,
|
SessionModule,
|
||||||
EnvironmentModule,
|
EnvironmentModule,
|
||||||
|
|||||||
@@ -334,7 +334,6 @@ export class ScenarioService {
|
|||||||
name: scenario.name,
|
name: scenario.name,
|
||||||
steps: scenario.steps.map((s) => ({
|
steps: scenario.steps.map((s) => ({
|
||||||
order: s.order,
|
order: s.order,
|
||||||
type: s.type,
|
|
||||||
title: s.title,
|
title: s.title,
|
||||||
execCode: s.execCode,
|
execCode: s.execCode,
|
||||||
validateCode: s.validateCode,
|
validateCode: s.validateCode,
|
||||||
@@ -367,7 +366,6 @@ export class ScenarioService {
|
|||||||
this.stepRepo.create({
|
this.stepRepo.create({
|
||||||
scenarioId: scenario.id,
|
scenarioId: scenario.id,
|
||||||
order: s.order,
|
order: s.order,
|
||||||
type: s.type,
|
|
||||||
title: s.title ?? null,
|
title: s.title ?? null,
|
||||||
execCode: s.execCode ?? null,
|
execCode: s.execCode ?? null,
|
||||||
validateCode: s.validateCode ?? null,
|
validateCode: s.validateCode ?? null,
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ jest.mock("@nestjs/common", () => {
|
|||||||
return actual;
|
return actual;
|
||||||
});
|
});
|
||||||
|
|
||||||
import { AuthModule } from "../src/auth/auth.module";
|
|
||||||
import { BrowserModule } from "../src/browser/browser.module";
|
import { BrowserModule } from "../src/browser/browser.module";
|
||||||
import { SessionModule } from "../src/session/session.module";
|
import { SessionModule } from "../src/session/session.module";
|
||||||
import { EnvironmentModule } from "../src/environment/environment.module";
|
import { EnvironmentModule } from "../src/environment/environment.module";
|
||||||
@@ -42,6 +41,9 @@ import { ScenarioStepEntity } from "../src/scenario/scenario-step.entity";
|
|||||||
import { ScenarioRunEntity } from "../src/scenario/scenario-run.entity";
|
import { ScenarioRunEntity } from "../src/scenario/scenario-run.entity";
|
||||||
import { ScenarioRunStepEntity } from "../src/scenario/scenario-run-step.entity";
|
import { ScenarioRunStepEntity } from "../src/scenario/scenario-run-step.entity";
|
||||||
import { ScenarioRunLogEntity } from "../src/scenario/scenario-run-log.entity";
|
import { ScenarioRunLogEntity } from "../src/scenario/scenario-run-log.entity";
|
||||||
|
import { ScenarioCredentialEntity } from "../src/scenario/scenario-credential.entity";
|
||||||
|
import { CredentialEntity } from "../src/credential/credential.entity";
|
||||||
|
import { SnippetEntity } from "../src/snippet/snippet.entity";
|
||||||
import { HealthController } from "../src/health/health.controller";
|
import { HealthController } from "../src/health/health.controller";
|
||||||
import { HttpExceptionFilter } from "../src/filters/http-exception.filter";
|
import { HttpExceptionFilter } from "../src/filters/http-exception.filter";
|
||||||
import { LoggingInterceptor } from "../src/interceptors/logging.interceptor";
|
import { LoggingInterceptor } from "../src/interceptors/logging.interceptor";
|
||||||
@@ -66,11 +68,13 @@ export async function buildTestApp(): Promise<INestApplication> {
|
|||||||
ScenarioRunEntity,
|
ScenarioRunEntity,
|
||||||
ScenarioRunStepEntity,
|
ScenarioRunStepEntity,
|
||||||
ScenarioRunLogEntity,
|
ScenarioRunLogEntity,
|
||||||
|
ScenarioCredentialEntity,
|
||||||
|
CredentialEntity,
|
||||||
|
SnippetEntity,
|
||||||
],
|
],
|
||||||
synchronize: true,
|
synchronize: true,
|
||||||
}),
|
}),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
AuthModule,
|
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
SessionModule,
|
SessionModule,
|
||||||
EnvironmentModule,
|
EnvironmentModule,
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import { INestApplication } from "@nestjs/common";
|
|
||||||
import request from "supertest";
|
|
||||||
import { buildTestApp } from "./app.harness";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auth controller integration tests.
|
|
||||||
*
|
|
||||||
* The login endpoint requires a live browser + real key files, so it is not
|
|
||||||
* exercised here (those belong to e2e tests with real credentials).
|
|
||||||
* We cover the parts that can be tested without external dependencies.
|
|
||||||
*/
|
|
||||||
describe("AuthController", () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
app = await buildTestApp();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── GET /keys ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("GET /keys", () => {
|
|
||||||
it("returns 200 with a keys array", async () => {
|
|
||||||
const res = await request(app.getHttpServer()).get("/keys").expect(200);
|
|
||||||
expect(res.body).toHaveProperty("keys");
|
|
||||||
expect(Array.isArray(res.body.keys)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── POST /login ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe("POST /login", () => {
|
|
||||||
it("returns 400 when body is empty", async () => {
|
|
||||||
await request(app.getHttpServer()).post("/login").send({}).expect(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 400 when key is missing", async () => {
|
|
||||||
await request(app.getHttpServer())
|
|
||||||
.post("/login")
|
|
||||||
.send({ environmentName: "test-env" })
|
|
||||||
.expect(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 400 when environmentName is missing", async () => {
|
|
||||||
await request(app.getHttpServer())
|
|
||||||
.post("/login")
|
|
||||||
.send({ key: "some-key" })
|
|
||||||
.expect(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 400 when key file does not exist", async () => {
|
|
||||||
await request(app.getHttpServer())
|
|
||||||
.post("/login")
|
|
||||||
.send({ key: "nonexistent-key", environmentName: "test-env" })
|
|
||||||
.expect(404); // NotFoundException for missing environment
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -160,7 +160,7 @@ describe("EnvironmentController", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns 404 for unknown id", async () => {
|
it("returns 404 for unknown id", async () => {
|
||||||
await request(app.getHttpServer()).get("/environments/99999").expect(404);
|
await request(app.getHttpServer()).get("/environments/00000000-0000-0000-0000-000000000001").expect(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 400 for non-numeric id", async () => {
|
it("returns 400 for non-numeric id", async () => {
|
||||||
@@ -187,7 +187,7 @@ describe("EnvironmentController", () => {
|
|||||||
|
|
||||||
it("returns 404 for unknown id", async () => {
|
it("returns 404 for unknown id", async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.patch("/environments/99999")
|
.patch("/environments/00000000-0000-0000-0000-000000000001")
|
||||||
.send({ name: "x" })
|
.send({ name: "x" })
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
@@ -213,7 +213,7 @@ describe("EnvironmentController", () => {
|
|||||||
|
|
||||||
it("returns 404 for unknown id", async () => {
|
it("returns 404 for unknown id", async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.delete("/environments/99999")
|
.delete("/environments/00000000-0000-0000-0000-000000000001")
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,14 +69,14 @@ describe("McpController", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── list_keys tool ─────────────────────────────────────────────────────────
|
// ── list_keys tool (removed) ──────────────────────────────────────────────
|
||||||
|
|
||||||
describe("list_keys", () => {
|
describe("list_keys", () => {
|
||||||
it("returns a result with text content containing a JSON array", async () => {
|
it("returns MCP error for removed tool", async () => {
|
||||||
const { status, rpc } = await mcpCall("list_keys");
|
const { status, rpc } = await mcpCall("list_keys");
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
const result = rpc.result as { content: { text: string }[] };
|
const result = rpc.result as { isError: boolean; content?: { text: string }[] };
|
||||||
expect(Array.isArray(JSON.parse(result.content[0].text))).toBe(true);
|
expect(result.isError).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,21 +42,20 @@ describe("ScenarioRunStepEntity.output + helpers.getStepOutput", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Create a scenario and return its id. */
|
/** Create a scenario and return its id. */
|
||||||
async function createScenario(name = "output-scenario"): Promise<number> {
|
async function createScenario(name = "output-scenario"): Promise<string> {
|
||||||
const sc = await scenarioService.create({ name });
|
const sc = await scenarioService.create({ name });
|
||||||
return sc.id;
|
return sc.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a step and return its id. */
|
/** Create a step and return its id. */
|
||||||
async function createStep(
|
async function createStep(
|
||||||
scenarioId: number,
|
scenarioId: string,
|
||||||
order: number,
|
order: number,
|
||||||
execCode: string,
|
execCode: string,
|
||||||
sessionName = "output-test-session",
|
sessionName = "output-test-session",
|
||||||
): Promise<number> {
|
): Promise<string> {
|
||||||
const step = await scenarioService.createStep(scenarioId, {
|
const step = await scenarioService.createStep(scenarioId, {
|
||||||
order,
|
order,
|
||||||
type: "exec",
|
|
||||||
sessionName,
|
sessionName,
|
||||||
execCode,
|
execCode,
|
||||||
});
|
});
|
||||||
@@ -64,7 +63,7 @@ describe("ScenarioRunStepEntity.output + helpers.getStepOutput", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Trigger a run and process it to completion via the scheduler. */
|
/** Trigger a run and process it to completion via the scheduler. */
|
||||||
async function runScenario(scenarioId: number): Promise<number> {
|
async function runScenario(scenarioId: string): Promise<string> {
|
||||||
const run = await scenarioService.createRun(scenarioId);
|
const run = await scenarioService.createRun(scenarioId);
|
||||||
// Drive the scheduler directly — keeps tests synchronous and fast.
|
// Drive the scheduler directly — keeps tests synchronous and fast.
|
||||||
await scheduler.pickUpPendingRuns();
|
await scheduler.pickUpPendingRuns();
|
||||||
@@ -84,7 +83,7 @@ describe("ScenarioRunStepEntity.output + helpers.getStepOutput", () => {
|
|||||||
const runId = await runScenario(scId);
|
const runId = await runScenario(scId);
|
||||||
|
|
||||||
const row = await dataSource.query(
|
const row = await dataSource.query(
|
||||||
`SELECT output, status FROM scenario_run_steps WHERE runId = ${runId}`,
|
`SELECT output, status FROM scenario_run_steps WHERE runId = '${runId}'`,
|
||||||
);
|
);
|
||||||
expect(row[0].status).toBe("pass");
|
expect(row[0].status).toBe("pass");
|
||||||
expect(JSON.parse(row[0].output as string)).toBe(42);
|
expect(JSON.parse(row[0].output as string)).toBe(42);
|
||||||
@@ -98,7 +97,7 @@ describe("ScenarioRunStepEntity.output + helpers.getStepOutput", () => {
|
|||||||
const runId = await runScenario(scId);
|
const runId = await runScenario(scId);
|
||||||
|
|
||||||
const row = await dataSource.query(
|
const row = await dataSource.query(
|
||||||
`SELECT output FROM scenario_run_steps WHERE runId = ${runId}`,
|
`SELECT output FROM scenario_run_steps WHERE runId = '${runId}'`,
|
||||||
);
|
);
|
||||||
expect(JSON.parse(row[0].output as string)).toEqual({ foo: "bar", n: 7 });
|
expect(JSON.parse(row[0].output as string)).toEqual({ foo: "bar", n: 7 });
|
||||||
});
|
});
|
||||||
@@ -111,7 +110,7 @@ describe("ScenarioRunStepEntity.output + helpers.getStepOutput", () => {
|
|||||||
const runId = await runScenario(scId);
|
const runId = await runScenario(scId);
|
||||||
|
|
||||||
const row = await dataSource.query(
|
const row = await dataSource.query(
|
||||||
`SELECT output FROM scenario_run_steps WHERE runId = ${runId}`,
|
`SELECT output FROM scenario_run_steps WHERE runId = '${runId}'`,
|
||||||
);
|
);
|
||||||
expect(row[0].output).toBeNull();
|
expect(row[0].output).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -140,7 +139,7 @@ describe("ScenarioRunStepEntity.output + helpers.getStepOutput", () => {
|
|||||||
|
|
||||||
const runId = await runScenario(scId);
|
const runId = await runScenario(scId);
|
||||||
const rows = await dataSource.query(
|
const rows = await dataSource.query(
|
||||||
`SELECT "order", output FROM scenario_run_steps WHERE runId = ${runId} ORDER BY "order"`,
|
`SELECT "order", output FROM scenario_run_steps WHERE runId = '${runId}' ORDER BY "order"`,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(JSON.parse(rows[0].output as string)).toBe(99);
|
expect(JSON.parse(rows[0].output as string)).toBe(99);
|
||||||
@@ -156,7 +155,7 @@ describe("ScenarioRunStepEntity.output + helpers.getStepOutput", () => {
|
|||||||
|
|
||||||
const runId = await runScenario(scId);
|
const runId = await runScenario(scId);
|
||||||
const rows = await dataSource.query(
|
const rows = await dataSource.query(
|
||||||
`SELECT "order", output FROM scenario_run_steps WHERE runId = ${runId} ORDER BY "order"`,
|
`SELECT "order", output FROM scenario_run_steps WHERE runId = '${runId}' ORDER BY "order"`,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(JSON.parse(rows[1].output as string)).toBe("step-zero");
|
expect(JSON.parse(rows[1].output as string)).toBe("step-zero");
|
||||||
@@ -170,7 +169,7 @@ describe("ScenarioRunStepEntity.output + helpers.getStepOutput", () => {
|
|||||||
|
|
||||||
const runId = await runScenario(scId);
|
const runId = await runScenario(scId);
|
||||||
const row = await dataSource.query(
|
const row = await dataSource.query(
|
||||||
`SELECT output FROM scenario_run_steps WHERE runId = ${runId}`,
|
`SELECT output FROM scenario_run_steps WHERE runId = '${runId}'`,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(row[0].output).toBeNull();
|
expect(row[0].output).toBeNull();
|
||||||
@@ -193,7 +192,7 @@ describe("ScenarioRunStepEntity.output + helpers.getStepOutput", () => {
|
|||||||
|
|
||||||
const runId = await runScenario(scId);
|
const runId = await runScenario(scId);
|
||||||
const rows = await dataSource.query(
|
const rows = await dataSource.query(
|
||||||
`SELECT "order", output FROM scenario_run_steps WHERE runId = ${runId} ORDER BY "order"`,
|
`SELECT "order", output FROM scenario_run_steps WHERE runId = '${runId}' ORDER BY "order"`,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(JSON.parse(rows[0].output as string)).toEqual([1, 2]);
|
expect(JSON.parse(rows[0].output as string)).toEqual([1, 2]);
|
||||||
|
|||||||
@@ -21,24 +21,23 @@ describe("ScenarioController", () => {
|
|||||||
.post("/scenarios")
|
.post("/scenarios")
|
||||||
.send({ name })
|
.send({ name })
|
||||||
.expect(201);
|
.expect(201);
|
||||||
return res.body as { id: number; name: string };
|
return res.body as { id: string; name: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createStep(
|
async function createStep(
|
||||||
scenarioId: number,
|
scenarioId: string,
|
||||||
overrides: Record<string, unknown> = {},
|
overrides: Record<string, unknown> = {},
|
||||||
) {
|
) {
|
||||||
const res = await request(app.getHttpServer())
|
const res = await request(app.getHttpServer())
|
||||||
.post(`/scenarios/${scenarioId}/steps`)
|
.post(`/scenarios/${scenarioId}/steps`)
|
||||||
.send({
|
.send({
|
||||||
order: 0,
|
order: 0,
|
||||||
type: "exec",
|
|
||||||
sessionName: "test-session",
|
sessionName: "test-session",
|
||||||
execCode: "return 1;",
|
execCode: "return 1;",
|
||||||
...overrides,
|
...overrides,
|
||||||
})
|
})
|
||||||
.expect(201);
|
.expect(201);
|
||||||
return res.body as { id: number };
|
return res.body as { id: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── POST /scenarios ────────────────────────────────────────────────────────
|
// ── POST /scenarios ────────────────────────────────────────────────────────
|
||||||
@@ -142,7 +141,7 @@ describe("ScenarioController", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns 404 for unknown id", async () => {
|
it("returns 404 for unknown id", async () => {
|
||||||
await request(app.getHttpServer()).get("/scenarios/99999").expect(404);
|
await request(app.getHttpServer()).get("/scenarios/00000000-0000-0000-0000-000000000001").expect(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,7 +159,7 @@ describe("ScenarioController", () => {
|
|||||||
|
|
||||||
it("returns 404 for unknown id", async () => {
|
it("returns 404 for unknown id", async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.patch("/scenarios/99999")
|
.patch("/scenarios/00000000-0000-0000-0000-000000000001")
|
||||||
.send({ name: "x" })
|
.send({ name: "x" })
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
@@ -178,7 +177,7 @@ describe("ScenarioController", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns 404 for unknown id", async () => {
|
it("returns 404 for unknown id", async () => {
|
||||||
await request(app.getHttpServer()).delete("/scenarios/99999").expect(404);
|
await request(app.getHttpServer()).delete("/scenarios/00000000-0000-0000-0000-000000000001").expect(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -191,7 +190,6 @@ describe("ScenarioController", () => {
|
|||||||
.post(`/scenarios/${sc.id}/steps`)
|
.post(`/scenarios/${sc.id}/steps`)
|
||||||
.send({
|
.send({
|
||||||
order: 0,
|
order: 0,
|
||||||
type: "exec",
|
|
||||||
sessionName: "my-session",
|
sessionName: "my-session",
|
||||||
execCode: "return 1;",
|
execCode: "return 1;",
|
||||||
})
|
})
|
||||||
@@ -199,48 +197,50 @@ describe("ScenarioController", () => {
|
|||||||
|
|
||||||
expect(res.body.id).toBeDefined();
|
expect(res.body.id).toBeDefined();
|
||||||
expect(res.body.order).toBe(0);
|
expect(res.body.order).toBe(0);
|
||||||
expect(res.body.type).toBe("exec");
|
|
||||||
expect(res.body.sessionName).toBe("my-session");
|
expect(res.body.sessionName).toBe("my-session");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a login step", async () => {
|
it("creates a step without execCode", async () => {
|
||||||
const sc = await createScenario();
|
const sc = await createScenario();
|
||||||
const res = await request(app.getHttpServer())
|
const res = await request(app.getHttpServer())
|
||||||
.post(`/scenarios/${sc.id}/steps`)
|
.post(`/scenarios/${sc.id}/steps`)
|
||||||
.send({ order: 0, type: "login", sessionName: "session-x" })
|
.send({ order: 0, sessionName: "session-x" })
|
||||||
.expect(201);
|
.expect(201);
|
||||||
|
|
||||||
expect(res.body.type).toBe("login");
|
expect(res.body.sessionName).toBe("session-x");
|
||||||
|
expect(res.body.execCode).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 400 when order is missing", async () => {
|
it("returns 400 when order is missing", async () => {
|
||||||
const sc = await createScenario();
|
const sc = await createScenario();
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post(`/scenarios/${sc.id}/steps`)
|
.post(`/scenarios/${sc.id}/steps`)
|
||||||
.send({ type: "exec", sessionName: "x" })
|
.send({ sessionName: "x" })
|
||||||
.expect(400);
|
.expect(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 400 when type is invalid", async () => {
|
it("ignores unknown fields in payload", async () => {
|
||||||
const sc = await createScenario();
|
const sc = await createScenario();
|
||||||
await request(app.getHttpServer())
|
const res = await request(app.getHttpServer())
|
||||||
.post(`/scenarios/${sc.id}/steps`)
|
.post(`/scenarios/${sc.id}/steps`)
|
||||||
.send({ order: 0, type: "unknown", sessionName: "x" })
|
.send({ order: 0, type: "unknown", sessionName: "x" })
|
||||||
.expect(400);
|
.expect(201);
|
||||||
|
|
||||||
|
expect(res.body).not.toHaveProperty("type");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 400 when sessionName is missing", async () => {
|
it("allows missing sessionName", async () => {
|
||||||
const sc = await createScenario();
|
const sc = await createScenario();
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post(`/scenarios/${sc.id}/steps`)
|
.post(`/scenarios/${sc.id}/steps`)
|
||||||
.send({ order: 0, type: "exec" })
|
.send({ order: 0, execCode: "return 1;" })
|
||||||
.expect(400);
|
.expect(201);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 404 for unknown scenario", async () => {
|
it("returns 404 for unknown scenario", async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post("/scenarios/99999/steps")
|
.post("/scenarios/00000000-0000-0000-0000-000000000001/steps")
|
||||||
.send({ order: 0, type: "exec", sessionName: "x" })
|
.send({ order: 0, sessionName: "x" })
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -276,7 +276,7 @@ describe("ScenarioController", () => {
|
|||||||
it("returns 404 for unknown step", async () => {
|
it("returns 404 for unknown step", async () => {
|
||||||
const sc = await createScenario();
|
const sc = await createScenario();
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get(`/scenarios/${sc.id}/steps/99999`)
|
.get(`/scenarios/${sc.id}/steps/00000000-0000-0000-0000-000000000001`)
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -300,7 +300,7 @@ describe("ScenarioController", () => {
|
|||||||
it("returns 404 for unknown step", async () => {
|
it("returns 404 for unknown step", async () => {
|
||||||
const sc = await createScenario();
|
const sc = await createScenario();
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.patch(`/scenarios/${sc.id}/steps/99999`)
|
.patch(`/scenarios/${sc.id}/steps/00000000-0000-0000-0000-000000000001`)
|
||||||
.send({ order: 1 })
|
.send({ order: 1 })
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
@@ -350,7 +350,7 @@ describe("ScenarioController", () => {
|
|||||||
|
|
||||||
it("returns 404 for unknown scenario", async () => {
|
it("returns 404 for unknown scenario", async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post("/scenarios/99999/run")
|
.post("/scenarios/00000000-0000-0000-0000-000000000001/run")
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -405,7 +405,7 @@ describe("ScenarioController", () => {
|
|||||||
|
|
||||||
it("returns 404 for unknown scenario", async () => {
|
it("returns 404 for unknown scenario", async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get("/scenarios/99999/runs")
|
.get("/scenarios/00000000-0000-0000-0000-000000000001/runs")
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -417,13 +417,11 @@ describe("ScenarioController", () => {
|
|||||||
const sc = await createScenario("export-me");
|
const sc = await createScenario("export-me");
|
||||||
await createStep(sc.id, {
|
await createStep(sc.id, {
|
||||||
order: 0,
|
order: 0,
|
||||||
type: "login",
|
|
||||||
sessionName: "s",
|
sessionName: "s",
|
||||||
execCode: '{"keyId":"k","environmentName":"e"}',
|
execCode: '{"keyId":"k","environmentName":"e"}',
|
||||||
});
|
});
|
||||||
await createStep(sc.id, {
|
await createStep(sc.id, {
|
||||||
order: 1,
|
order: 1,
|
||||||
type: "exec",
|
|
||||||
sessionName: "s",
|
sessionName: "s",
|
||||||
execCode: "return 1;",
|
execCode: "return 1;",
|
||||||
validateCode: "return true;",
|
validateCode: "return true;",
|
||||||
@@ -484,7 +482,7 @@ describe("ScenarioController", () => {
|
|||||||
|
|
||||||
it("returns 404 for unknown scenario", async () => {
|
it("returns 404 for unknown scenario", async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get("/scenarios/99999/export")
|
.get("/scenarios/00000000-0000-0000-0000-000000000001/export")
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -498,21 +496,18 @@ describe("ScenarioController", () => {
|
|||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
order: 0,
|
order: 0,
|
||||||
type: "login",
|
|
||||||
sessionName: "s",
|
sessionName: "s",
|
||||||
execCode: '{"keyId":"k","environmentName":"e"}',
|
execCode: '{"keyId":"k","environmentName":"e"}',
|
||||||
validateCode: null,
|
validateCode: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
order: 1,
|
order: 1,
|
||||||
type: "exec",
|
|
||||||
sessionName: "s",
|
sessionName: "s",
|
||||||
execCode: "return 1;",
|
execCode: "return 1;",
|
||||||
validateCode: "return true;",
|
validateCode: "return true;",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
order: 2,
|
order: 2,
|
||||||
type: "sign",
|
|
||||||
sessionName: "s",
|
sessionName: "s",
|
||||||
execCode: '{"keyId":"k"}',
|
execCode: '{"keyId":"k"}',
|
||||||
validateCode: null,
|
validateCode: null,
|
||||||
@@ -528,12 +523,9 @@ describe("ScenarioController", () => {
|
|||||||
expect(res.body.id).toBeDefined();
|
expect(res.body.id).toBeDefined();
|
||||||
expect(res.body.name).toBe("imported scenario");
|
expect(res.body.name).toBe("imported scenario");
|
||||||
expect(res.body.steps).toHaveLength(3);
|
expect(res.body.steps).toHaveLength(3);
|
||||||
expect(res.body.steps[0].type).toBe("login");
|
|
||||||
expect(res.body.steps[1].type).toBe("exec");
|
|
||||||
expect(res.body.steps[2].type).toBe("sign");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("assigns a new id (does not collide with source)", async () => {
|
it("preserves id when importing an exported scenario with id", async () => {
|
||||||
const sc = await createScenario("original");
|
const sc = await createScenario("original");
|
||||||
const exportRes = await request(app.getHttpServer())
|
const exportRes = await request(app.getHttpServer())
|
||||||
.get(`/scenarios/${sc.id}/export`)
|
.get(`/scenarios/${sc.id}/export`)
|
||||||
@@ -544,14 +536,13 @@ describe("ScenarioController", () => {
|
|||||||
.send(exportRes.body)
|
.send(exportRes.body)
|
||||||
.expect(201);
|
.expect(201);
|
||||||
|
|
||||||
expect(importRes.body.id).not.toBe(sc.id);
|
expect(importRes.body.id).toBe(sc.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round-trips a scenario faithfully", async () => {
|
it("round-trips a scenario faithfully", async () => {
|
||||||
const sc = await createScenario("roundtrip");
|
const sc = await createScenario("roundtrip");
|
||||||
await createStep(sc.id, {
|
await createStep(sc.id, {
|
||||||
order: 0,
|
order: 0,
|
||||||
type: "exec",
|
|
||||||
sessionName: "rs",
|
sessionName: "rs",
|
||||||
execCode: "return 42;",
|
execCode: "return 42;",
|
||||||
validateCode: "return true;",
|
validateCode: "return true;",
|
||||||
@@ -600,14 +591,14 @@ describe("ScenarioController", () => {
|
|||||||
.expect(400);
|
.expect(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 400 when a step has an invalid type", async () => {
|
it("ignores unknown step fields during import", async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post("/scenarios/import")
|
.post("/scenarios/import")
|
||||||
.send({
|
.send({
|
||||||
name: "bad-type",
|
name: "bad-type",
|
||||||
steps: [{ order: 0, type: "unknown", sessionName: "s" }],
|
steps: [{ order: 0, type: "unknown", sessionName: "s" }],
|
||||||
})
|
})
|
||||||
.expect(400);
|
.expect(201);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -654,7 +645,7 @@ describe("ScenarioController", () => {
|
|||||||
it("returns 404 for unknown run", async () => {
|
it("returns 404 for unknown run", async () => {
|
||||||
const sc = await createScenario();
|
const sc = await createScenario();
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get(`/scenarios/${sc.id}/run/99999`)
|
.get(`/scenarios/${sc.id}/run/00000000-0000-0000-0000-000000000001`)
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -674,7 +665,9 @@ describe("ScenarioController", () => {
|
|||||||
|
|
||||||
it("returns 404 for unknown scenario", async () => {
|
it("returns 404 for unknown scenario", async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.get("/scenarios/99999/run/1")
|
.get(
|
||||||
|
"/scenarios/00000000-0000-0000-0000-000000000001/run/00000000-0000-0000-0000-000000000001",
|
||||||
|
)
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -693,7 +686,7 @@ describe("ScenarioController", () => {
|
|||||||
// Manually mark run as pass so wait resolves immediately
|
// Manually mark run as pass so wait resolves immediately
|
||||||
const dataSource = app.get(DataSource);
|
const dataSource = app.get(DataSource);
|
||||||
await dataSource.query(
|
await dataSource.query(
|
||||||
`UPDATE scenario_runs SET status='pass' WHERE id=${runId}`,
|
`UPDATE scenario_runs SET status='pass' WHERE id='${runId}'`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const res = await request(app.getHttpServer())
|
const res = await request(app.getHttpServer())
|
||||||
@@ -716,7 +709,7 @@ describe("ScenarioController", () => {
|
|||||||
|
|
||||||
const dataSource = app.get(DataSource);
|
const dataSource = app.get(DataSource);
|
||||||
await dataSource.query(
|
await dataSource.query(
|
||||||
`UPDATE scenario_runs SET status='fail' WHERE id=${runId}`,
|
`UPDATE scenario_runs SET status='fail' WHERE id='${runId}'`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const res = await request(app.getHttpServer())
|
const res = await request(app.getHttpServer())
|
||||||
@@ -729,13 +722,15 @@ describe("ScenarioController", () => {
|
|||||||
it("returns 404 for unknown run", async () => {
|
it("returns 404 for unknown run", async () => {
|
||||||
const sc = await createScenario();
|
const sc = await createScenario();
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post(`/scenarios/${sc.id}/run/99999/wait`)
|
.post(`/scenarios/${sc.id}/run/00000000-0000-0000-0000-000000000001/wait`)
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 404 for unknown scenario", async () => {
|
it("returns 404 for unknown scenario", async () => {
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post("/scenarios/99999/run/1/wait")
|
.post(
|
||||||
|
"/scenarios/00000000-0000-0000-0000-000000000001/run/00000000-0000-0000-0000-000000000001/wait",
|
||||||
|
)
|
||||||
.expect(404);
|
.expect(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ describe("SessionController", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns 404 for unknown id", async () => {
|
it("returns 404 for unknown id", async () => {
|
||||||
await request(app.getHttpServer()).delete("/sessions/99999").expect(404);
|
await request(app.getHttpServer()).delete("/sessions/00000000-0000-0000-0000-000000000001").expect(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 400 for non-numeric id", async () => {
|
it("returns 400 for non-numeric id", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user