feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,499 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { mountPiecesApi } from './pieces-api.js';
|
||||
|
||||
function makeGeneralPieceYaml(): string {
|
||||
return [
|
||||
'name: general',
|
||||
'description: 汎用タスク',
|
||||
'max_movements: 25',
|
||||
'initial_movement: understand',
|
||||
'movements:',
|
||||
' - name: understand',
|
||||
' edit: false',
|
||||
' persona: analyst',
|
||||
' instruction: |',
|
||||
' タスクを確認する。',
|
||||
' allowed_tools: [Read, Glob]',
|
||||
' default_next: execute',
|
||||
' rules:',
|
||||
' - condition: 方針が立った',
|
||||
' next: execute',
|
||||
' - name: execute',
|
||||
' edit: true',
|
||||
' persona: worker',
|
||||
' instruction: |',
|
||||
' 作業を実行する。',
|
||||
' allowed_tools: [Read, Write]',
|
||||
' default_next: COMPLETE',
|
||||
' rules:',
|
||||
' - condition: 完了',
|
||||
' next: COMPLETE',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function makeMinimalPieceYaml(name: string, description = 'x'): string {
|
||||
return [
|
||||
`name: ${name}`,
|
||||
`description: ${description}`,
|
||||
'max_movements: 1',
|
||||
'initial_movement: only',
|
||||
'movements:',
|
||||
' - name: only',
|
||||
' edit: false',
|
||||
' persona: p',
|
||||
' instruction: i',
|
||||
' allowed_tools: [Read]',
|
||||
' default_next: COMPLETE',
|
||||
' rules: []',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
describe('Pieces API (no auth — legacy behavior)', () => {
|
||||
let app: express.Application;
|
||||
let piecesDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-'));
|
||||
piecesDir = join(tempDir, 'pieces');
|
||||
mkdirSync(piecesDir);
|
||||
writeFileSync(join(piecesDir, 'general.yaml'), makeGeneralPieceYaml());
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
mountPiecesApi(app, { piecesDir });
|
||||
});
|
||||
|
||||
it('GET /api/pieces returns piece list', async () => {
|
||||
const res = await request(app).get('/api/pieces');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.pieces).toHaveLength(1);
|
||||
expect(res.body.pieces[0].name).toBe('general');
|
||||
expect(res.body.pieces[0].source).toBe('builtin');
|
||||
expect(res.body.pieces[0].custom).toBe(false);
|
||||
});
|
||||
|
||||
it('GET /api/pieces/:name returns full piece', async () => {
|
||||
const res = await request(app).get('/api/pieces/general');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.piece.name).toBe('general');
|
||||
expect(res.body.piece.movements).toHaveLength(2);
|
||||
expect(res.body.source).toBe('builtin');
|
||||
});
|
||||
|
||||
it('GET /api/pieces/:name returns 404 for unknown', async () => {
|
||||
const res = await request(app).get('/api/pieces/nonexistent');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('PUT /api/pieces/:name updates piece', async () => {
|
||||
const res = await request(app)
|
||||
.put('/api/pieces/general')
|
||||
.send({
|
||||
name: 'general',
|
||||
description: '更新済み',
|
||||
max_movements: 30,
|
||||
initial_movement: 'understand',
|
||||
movements: [
|
||||
{ name: 'understand', edit: false, persona: 'analyst', instruction: 'テスト', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] },
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/pieces creates new piece', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/pieces')
|
||||
.send({
|
||||
name: 'custom',
|
||||
description: 'カスタム',
|
||||
max_movements: 10,
|
||||
initial_movement: 'work',
|
||||
movements: [
|
||||
{ name: 'work', edit: true, persona: 'worker', instruction: '作業する', allowed_tools: ['Read', 'Write'], default_next: 'COMPLETE', rules: [] },
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/pieces rejects duplicate name', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/pieces')
|
||||
.send({ name: 'general', description: 'x', max_movements: 1, initial_movement: 'a', movements: [{ name: 'a', edit: false, persona: 'x', instruction: 'x', allowed_tools: [], rules: [] }] });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it('POST /api/pieces rejects rules[].next: COMPLETE (Phase 6b)', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/pieces')
|
||||
.send({
|
||||
name: 'phase6b-reject',
|
||||
description: 'should be rejected',
|
||||
max_movements: 1,
|
||||
initial_movement: 'only',
|
||||
movements: [
|
||||
{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'],
|
||||
rules: [{ condition: 'ok', next: 'COMPLETE' }] },
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(String(res.body.error ?? res.body)).toMatch(/rules\[\]\.next cannot be "COMPLETE"/);
|
||||
});
|
||||
|
||||
it('POST /api/pieces accepts default_next: COMPLETE (engine-internal sentinel)', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/pieces')
|
||||
.send({
|
||||
name: 'phase6b-default-ok',
|
||||
description: 'default_next is fine',
|
||||
max_movements: 1,
|
||||
initial_movement: 'only',
|
||||
movements: [
|
||||
{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'],
|
||||
default_next: 'COMPLETE', rules: [] },
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
// Phase 4: per-movement SSH connection allowlist validation.
|
||||
it('POST /api/pieces rejects SshExec without allowed_ssh_connections', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/pieces')
|
||||
.send({
|
||||
name: 'ssh-missing-allowlist',
|
||||
description: 'x',
|
||||
max_movements: 1,
|
||||
initial_movement: 'only',
|
||||
movements: [
|
||||
{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
|
||||
default_next: 'COMPLETE', rules: [] },
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(String(res.body.error ?? res.body)).toMatch(/allowed_ssh_connections is required/);
|
||||
});
|
||||
|
||||
it('POST /api/pieces accepts SshExec with UUID allowlist', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/pieces')
|
||||
.send({
|
||||
name: 'ssh-uuid-ok',
|
||||
description: 'x',
|
||||
max_movements: 1,
|
||||
initial_movement: 'only',
|
||||
movements: [
|
||||
{
|
||||
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
|
||||
allowed_ssh_connections: ['6f9619ff-8b86-d011-b42d-00c04fc964ff'],
|
||||
default_next: 'COMPLETE', rules: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it('POST /api/pieces accepts SshExec with ["*"] wildcard', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/pieces')
|
||||
.send({
|
||||
name: 'ssh-wildcard-ok',
|
||||
description: 'x',
|
||||
max_movements: 1,
|
||||
initial_movement: 'only',
|
||||
movements: [
|
||||
{
|
||||
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
|
||||
allowed_ssh_connections: ['*'],
|
||||
default_next: 'COMPLETE', rules: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it('POST /api/pieces accepts SshExec with empty allowlist (explicit deny)', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/pieces')
|
||||
.send({
|
||||
name: 'ssh-empty-ok',
|
||||
description: 'x',
|
||||
max_movements: 1,
|
||||
initial_movement: 'only',
|
||||
movements: [
|
||||
{
|
||||
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
|
||||
allowed_ssh_connections: [],
|
||||
default_next: 'COMPLETE', rules: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it('POST /api/pieces rejects allowed_ssh_connections with bad format', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/pieces')
|
||||
.send({
|
||||
name: 'ssh-bad-format',
|
||||
description: 'x',
|
||||
max_movements: 1,
|
||||
initial_movement: 'only',
|
||||
movements: [
|
||||
{
|
||||
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
|
||||
allowed_ssh_connections: ['BAD-NOT-LOWERCASE'],
|
||||
default_next: 'COMPLETE', rules: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(String(res.body.error ?? res.body)).toMatch(/must be '\*' or a lowercase hex/);
|
||||
});
|
||||
|
||||
it('POST /api/pieces rejects non-array allowed_ssh_connections', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/pieces')
|
||||
.send({
|
||||
name: 'ssh-non-array',
|
||||
description: 'x',
|
||||
max_movements: 1,
|
||||
initial_movement: 'only',
|
||||
movements: [
|
||||
{
|
||||
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['SshExec'],
|
||||
allowed_ssh_connections: 'not-an-array',
|
||||
default_next: 'COMPLETE', rules: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(String(res.body.error ?? res.body)).toMatch(/must be an array/);
|
||||
});
|
||||
|
||||
it('POST /api/pieces accepts allowed_ssh_connections without SSH tools (no-op)', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/pieces')
|
||||
.send({
|
||||
name: 'ssh-noop',
|
||||
description: 'x',
|
||||
max_movements: 1,
|
||||
initial_movement: 'only',
|
||||
movements: [
|
||||
{
|
||||
name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'],
|
||||
allowed_ssh_connections: ['6f9619ff-8b86-d011-b42d-00c04fc964ff'],
|
||||
default_next: 'COMPLETE', rules: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it('DELETE /api/pieces/:name deletes piece', async () => {
|
||||
await request(app).post('/api/pieces').send({
|
||||
name: 'deleteme', description: 'x', max_movements: 1, initial_movement: 'a',
|
||||
movements: [{ name: 'a', edit: false, persona: 'x', instruction: 'x', allowed_tools: [], rules: [] }],
|
||||
});
|
||||
const res = await request(app).delete('/api/pieces/deleteme');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('DELETE /api/pieces/general is forbidden', async () => {
|
||||
const res = await request(app).delete('/api/pieces/general');
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth-aware tests (per-user custom pieces + non-admin write authz)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type UserShape = { id: string; role: 'admin' | 'user' };
|
||||
|
||||
function makeAuthApp(piecesDir: string, userPiecesRootDir: string, user: UserShape | null): express.Application {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
if (user) (req as any).user = user;
|
||||
next();
|
||||
});
|
||||
mountPiecesApi(app, { piecesDir, userPiecesRootDir });
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('Pieces API (auth-aware: per-user custom + write authz)', () => {
|
||||
let piecesDir: string;
|
||||
let userPiecesRootDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'pieces-api-auth-'));
|
||||
piecesDir = join(tempDir, 'pieces');
|
||||
userPiecesRootDir = join(tempDir, 'users');
|
||||
mkdirSync(piecesDir);
|
||||
mkdirSync(userPiecesRootDir);
|
||||
writeFileSync(join(piecesDir, 'general.yaml'), makeGeneralPieceYaml());
|
||||
writeFileSync(join(piecesDir, 'chat.yaml'), makeMinimalPieceYaml('chat', 'built-in chat'));
|
||||
});
|
||||
|
||||
it('GET /api/pieces returns built-ins for any authenticated non-admin', async () => {
|
||||
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||
.get('/api/pieces');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.pieces.map((p: any) => p.name).sort()).toEqual(['chat', 'general']);
|
||||
for (const p of res.body.pieces) {
|
||||
expect(p.source).toBe('builtin');
|
||||
expect(p.custom).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('GET /api/pieces merges caller\'s user-custom pieces (own only)', async () => {
|
||||
// Alice has my-tool, Bob has bob-tool
|
||||
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
||||
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'my-tool.yaml'), makeMinimalPieceYaml('my-tool', "alice's piece"));
|
||||
mkdirSync(join(userPiecesRootDir, 'bob', 'pieces'), { recursive: true });
|
||||
writeFileSync(join(userPiecesRootDir, 'bob', 'pieces', 'bob-tool.yaml'), makeMinimalPieceYaml('bob-tool', "bob's piece"));
|
||||
|
||||
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||
.get('/api/pieces');
|
||||
expect(res.status).toBe(200);
|
||||
const byName = Object.fromEntries(res.body.pieces.map((p: any) => [p.name, p]));
|
||||
expect(Object.keys(byName).sort()).toEqual(['chat', 'general', 'my-tool']);
|
||||
expect(byName['my-tool'].source).toBe('user-custom');
|
||||
expect(byName['my-tool'].ownerId).toBe('alice');
|
||||
expect(byName['my-tool'].custom).toBe(true);
|
||||
// Bob's piece must not appear for Alice
|
||||
expect(byName['bob-tool']).toBeUndefined();
|
||||
});
|
||||
|
||||
it("GET /api/pieces — user-custom shadows built-in with the same name", async () => {
|
||||
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
||||
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'general.yaml'), makeMinimalPieceYaml('general', 'alice override'));
|
||||
|
||||
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||
.get('/api/pieces');
|
||||
expect(res.status).toBe(200);
|
||||
const general = res.body.pieces.find((p: any) => p.name === 'general');
|
||||
expect(general.source).toBe('user-custom');
|
||||
expect(general.description).toBe('alice override');
|
||||
});
|
||||
|
||||
it('POST /api/pieces creates a user-custom piece for non-admin caller', async () => {
|
||||
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||
.post('/api/pieces')
|
||||
.send({
|
||||
name: 'alice-custom',
|
||||
description: 'alice piece',
|
||||
max_movements: 1,
|
||||
initial_movement: 'only',
|
||||
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'alice-custom.yaml'))).toBe(true);
|
||||
// Built-in dir untouched
|
||||
expect(existsSync(join(piecesDir, 'alice-custom.yaml'))).toBe(false);
|
||||
});
|
||||
|
||||
it('POST /api/pieces by admin writes to piecesDir (legacy behavior)', async () => {
|
||||
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' }))
|
||||
.post('/api/pieces')
|
||||
.send({
|
||||
name: 'admin-piece',
|
||||
description: 'admin piece',
|
||||
max_movements: 1,
|
||||
initial_movement: 'only',
|
||||
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(existsSync(join(piecesDir, 'admin-piece.yaml'))).toBe(true);
|
||||
});
|
||||
|
||||
it('PUT /api/pieces/:name on built-in by non-admin returns 403', async () => {
|
||||
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||
.put('/api/pieces/general')
|
||||
.send({
|
||||
name: 'general',
|
||||
description: 'should not write',
|
||||
max_movements: 1,
|
||||
initial_movement: 'only',
|
||||
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('PUT /api/pieces/:name on own user-custom by owner returns 200', async () => {
|
||||
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
||||
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'my-piece.yaml'), makeMinimalPieceYaml('my-piece', 'v1'));
|
||||
|
||||
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||
.put('/api/pieces/my-piece')
|
||||
.send({
|
||||
name: 'my-piece',
|
||||
description: 'v2',
|
||||
max_movements: 1,
|
||||
initial_movement: 'only',
|
||||
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("PUT /api/pieces/:name — non-admin cannot edit another user's piece", async () => {
|
||||
// Bob has a piece. Alice tries to edit it via the same name.
|
||||
mkdirSync(join(userPiecesRootDir, 'bob', 'pieces'), { recursive: true });
|
||||
writeFileSync(join(userPiecesRootDir, 'bob', 'pieces', 'bob-only.yaml'), makeMinimalPieceYaml('bob-only', 'bob'));
|
||||
|
||||
// Alice doesn't have bob-only — the request 404s (she can't see it), proving isolation.
|
||||
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||
.put('/api/pieces/bob-only')
|
||||
.send({
|
||||
name: 'bob-only',
|
||||
description: 'hijacked',
|
||||
max_movements: 1,
|
||||
initial_movement: 'only',
|
||||
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('DELETE /api/pieces/:name on built-in by non-admin returns 403', async () => {
|
||||
// Use a deletable built-in (not general/chat which are protected separately)
|
||||
writeFileSync(join(piecesDir, 'extra.yaml'), makeMinimalPieceYaml('extra', 'extra'));
|
||||
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||
.delete('/api/pieces/extra');
|
||||
expect(res.status).toBe(403);
|
||||
expect(existsSync(join(piecesDir, 'extra.yaml'))).toBe(true);
|
||||
});
|
||||
|
||||
it('DELETE /api/pieces/:name on own user-custom by owner returns 200', async () => {
|
||||
mkdirSync(join(userPiecesRootDir, 'alice', 'pieces'), { recursive: true });
|
||||
writeFileSync(join(userPiecesRootDir, 'alice', 'pieces', 'goner.yaml'), makeMinimalPieceYaml('goner', 'gone'));
|
||||
|
||||
const res = await request(makeAuthApp(piecesDir, userPiecesRootDir, { id: 'alice', role: 'user' }))
|
||||
.delete('/api/pieces/goner');
|
||||
expect(res.status).toBe(200);
|
||||
expect(existsSync(join(userPiecesRootDir, 'alice', 'pieces', 'goner.yaml'))).toBe(false);
|
||||
});
|
||||
|
||||
it('admin can edit and delete built-in pieces', async () => {
|
||||
writeFileSync(join(piecesDir, 'admin-target.yaml'), makeMinimalPieceYaml('admin-target', 'before'));
|
||||
const adminApp = makeAuthApp(piecesDir, userPiecesRootDir, { id: 'admin1', role: 'admin' });
|
||||
|
||||
const putRes = await request(adminApp).put('/api/pieces/admin-target').send({
|
||||
name: 'admin-target',
|
||||
description: 'after',
|
||||
max_movements: 1,
|
||||
initial_movement: 'only',
|
||||
movements: [{ name: 'only', edit: false, persona: 'p', instruction: 'i', allowed_tools: ['Read'], default_next: 'COMPLETE', rules: [] }],
|
||||
});
|
||||
expect(putRes.status).toBe(200);
|
||||
|
||||
const delRes = await request(adminApp).delete('/api/pieces/admin-target');
|
||||
expect(delRes.status).toBe(200);
|
||||
expect(existsSync(join(piecesDir, 'admin-target.yaml'))).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user