feat(api,web): eigene Module — Tabelle, API, Seitenleiste, Rahmen-Seite
- Tabelle CustomModule mit Zeilenschutz (tenant_isolation_policy), Migration 20260929120000 - API /custom-modules: Lesen für jeden Angemeldeten, Schreiben nur Administrator, nur https ohne Zugangsdaten - Seitenleiste zeigt eigene Module unter ihrer Kategorie, Rahmen-Seite mit Sandbox und „In neuem Tab öffnen“ - MODULE_CATEGORIES als gemeinsame Liste, Zugriffsklassifikation nachgemessen fortgeschrieben Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
CustomModuleRequestError,
|
||||
checkCustomModuleUrl,
|
||||
createCustomModule,
|
||||
deleteCustomModule,
|
||||
getCustomModule,
|
||||
listCustomModules,
|
||||
updateCustomModule,
|
||||
} from './custom-modules-api';
|
||||
|
||||
const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() }));
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('checkCustomModuleUrl', () => {
|
||||
it('nimmt eine https-Adresse an', () => {
|
||||
expect(checkCustomModuleUrl('https://a.de')).toBe('ok');
|
||||
});
|
||||
it('lehnt http und Unparsbares als notHttps ab', () => {
|
||||
expect(checkCustomModuleUrl('http://a.de')).toBe('notHttps');
|
||||
expect(checkCustomModuleUrl('kaputt')).toBe('notHttps');
|
||||
expect(checkCustomModuleUrl('javascript:alert(1)')).toBe('notHttps');
|
||||
expect(checkCustomModuleUrl('')).toBe('notHttps');
|
||||
});
|
||||
it('erkennt Zugangsdaten in der Adresse', () => {
|
||||
expect(checkCustomModuleUrl('https://u:p@a.de')).toBe('credentials');
|
||||
expect(checkCustomModuleUrl('https://u@a.de')).toBe('credentials');
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom-modules-api', () => {
|
||||
it('listCustomModules ruft GET /custom-modules mit credentials include', async () => {
|
||||
mockFetch.mockResolvedValue(new Response(JSON.stringify([{ id: 'a' }]), { status: 200 }));
|
||||
const list = await listCustomModules();
|
||||
expect(list).toEqual([{ id: 'a' }]);
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(String(url)).toMatch(/\/custom-modules$/);
|
||||
expect(init.credentials).toBe('include');
|
||||
});
|
||||
|
||||
it('getCustomModule liefert null bei 404', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 404 }));
|
||||
await expect(getCustomModule('x')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('getCustomModule liefert die Zeile bei 200', async () => {
|
||||
mockFetch.mockResolvedValue(new Response(JSON.stringify({ id: 'x' }), { status: 200 }));
|
||||
await expect(getCustomModule('x')).resolves.toEqual({ id: 'x' });
|
||||
});
|
||||
|
||||
it('createCustomModule schickt POST mit JSON und wirft bei Fehler mit Servermeldung', async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
new Response(JSON.stringify({ message: ['Nur https'] }), { status: 400 }),
|
||||
);
|
||||
const input = { name: 'a', url: 'http://a.de', category: 'fleet' };
|
||||
const err = await createCustomModule(input).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(CustomModuleRequestError);
|
||||
expect(err.status).toBe(400);
|
||||
expect(err.message).toBe('Nur https');
|
||||
const [, init] = mockFetch.mock.calls[0];
|
||||
expect(init.method).toBe('POST');
|
||||
expect(JSON.parse(init.body)).toEqual(input);
|
||||
});
|
||||
|
||||
it('updateCustomModule schickt PATCH, deleteCustomModule DELETE', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
await updateCustomModule('x', { name: 'n' });
|
||||
expect(mockFetch.mock.calls[0][1].method).toBe('PATCH');
|
||||
expect(String(mockFetch.mock.calls[0][0])).toMatch(/\/custom-modules\/x$/);
|
||||
await deleteCustomModule('x');
|
||||
expect(mockFetch.mock.calls[1][1].method).toBe('DELETE');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Eigene Module — API-Client (quick-260929-9wc). Konsumiert `/custom-modules`.
|
||||
* Muster `favorites-api.ts`/`proxmox-api.ts`: `credentials: 'include'` fuer
|
||||
* Cookie-Auth, `NEXT_PUBLIC_API_URL` als Basis.
|
||||
*/
|
||||
|
||||
import { isHttpsUrl } from '@/components/dashboard/widgets/xframe-config';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
export interface CustomModule {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
category: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CustomModuleInput {
|
||||
name: string;
|
||||
url: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
/** Fehler mit HTTP-Status und Servermeldung (falls vorhanden). */
|
||||
export class CustomModuleRequestError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'CustomModuleRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Ergebnis der Adresspruefung im Web — dieselbe Regel wie die API. */
|
||||
export type CustomModuleUrlCheck = 'ok' | 'notHttps' | 'credentials';
|
||||
|
||||
/**
|
||||
* Gueltig ist nur eine https-Adresse ohne Zugangsdaten. Die https-Regel ist
|
||||
* EINE im ganzen Web (`isHttpsUrl` aus `xframe-config.ts`); Zugangsdaten
|
||||
* erkennt der URL-Parser an `username`/`password`.
|
||||
*/
|
||||
export function checkCustomModuleUrl(value: string): CustomModuleUrlCheck {
|
||||
if (!isHttpsUrl(value)) return 'notHttps';
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.username !== '' || parsed.password !== '') return 'credentials';
|
||||
if (parsed.hostname === '') return 'notHttps';
|
||||
} catch {
|
||||
return 'notHttps';
|
||||
}
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
async function failure(res: Response): Promise<CustomModuleRequestError> {
|
||||
let message = `Request failed (${res.status})`;
|
||||
try {
|
||||
const body = await res.json();
|
||||
const raw = body?.message;
|
||||
if (Array.isArray(raw)) message = raw.join(' ');
|
||||
else if (typeof raw === 'string') message = raw;
|
||||
} catch {
|
||||
// Antwort ohne JSON-Koerper — Standardmeldung bleibt.
|
||||
}
|
||||
return new CustomModuleRequestError(res.status, message);
|
||||
}
|
||||
|
||||
export async function listCustomModules(): Promise<CustomModule[]> {
|
||||
const res = await fetch(`${API_URL}/custom-modules`, { credentials: 'include' });
|
||||
if (!res.ok) throw await failure(res);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** `null` bei 404 (Eintrag geloescht oder fremd). */
|
||||
export async function getCustomModule(id: string): Promise<CustomModule | null> {
|
||||
const res = await fetch(`${API_URL}/custom-modules/${encodeURIComponent(id)}`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw await failure(res);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createCustomModule(input: CustomModuleInput): Promise<CustomModule> {
|
||||
const res = await fetch(`${API_URL}/custom-modules`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) throw await failure(res);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateCustomModule(
|
||||
id: string,
|
||||
input: Partial<CustomModuleInput>,
|
||||
): Promise<CustomModule> {
|
||||
const res = await fetch(`${API_URL}/custom-modules/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) throw await failure(res);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteCustomModule(id: string): Promise<void> {
|
||||
const res = await fetch(`${API_URL}/custom-modules/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) throw await failure(res);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolvePageTitle } from './nav-store';
|
||||
|
||||
describe('resolvePageTitle — eigene Module', () => {
|
||||
it('liefert den Namen des eigenen Moduls (slug = id)', () => {
|
||||
const modules = [{ id: 'abc', slug: 'abc', name: 'Wiki', category: 'infrastructure' }];
|
||||
expect(resolvePageTitle('/modules/custom/abc', modules)).toEqual({ text: 'Wiki' });
|
||||
});
|
||||
|
||||
it('findet eingebaute Module weiterhin ueber ihren slug', () => {
|
||||
const modules = [
|
||||
{ id: 'm1', slug: 'domaincheck', name: 'Domaincheck', category: 'domain-tools' },
|
||||
{ id: 'abc', slug: 'abc', name: 'Wiki', category: 'infrastructure' },
|
||||
];
|
||||
expect(resolvePageTitle('/modules/domain-tools/domaincheck', modules)).toEqual({
|
||||
text: 'Domaincheck',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user