Skip to content
Open
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
9 changes: 9 additions & 0 deletions workspaces/x2a/.changeset/old-lamps-beg.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@red-hat-developer-hub/backstage-plugin-x2a-mcp-extras': minor
'@red-hat-developer-hub/backstage-plugin-x2a-backend': minor
'@red-hat-developer-hub/backstage-plugin-x2a-common': minor
'@red-hat-developer-hub/backstage-plugin-x2a-node': minor
'@red-hat-developer-hub/backstage-plugin-x2a': minor
---

The user can newly update the project migration plan in an external flow and then let the X2A resync the module list changes.
3 changes: 1 addition & 2 deletions workspaces/x2a/plugins/x2a-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,7 @@ The plugin now properly handles AAP credentials from both system-wide configurat

- **Fixed authentication**: All project lookup operations now properly pass user credentials for authorization checks
- **Consistent permissions**: The following endpoints now correctly validate user permissions:
- `POST /projects/:projectId/run` (init phase)
- `POST /projects/:projectId/modules` (create module)
- `POST /projects/:projectId/run` (init phase, including migration plan resync via `refresh: true`)
- `POST /projects/:projectId/modules/:moduleId/run` (analyze/migrate/publish phases)

## API Usage Examples
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,26 @@
* limitations under the License.
*/

// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
import type { Knex } from 'knex';

/**
* Adds removed_at column to modules table for soft-delete support.
*
* @public
*/
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable('modules', table => {
table.timestamp('removed_at').nullable();
});
}

/**
* Drops the removed_at column from modules table.
*
* @public
*/
export interface ProjectsProjectIdModulesPostRequest {
/**
* Module name
*/
name: string;
/**
* Path to the module in the source repository
*/
sourcePath: string;
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable('modules', table => {
table.dropColumn('removed_at');
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,9 @@ export interface MockRouterDeps {
createModule: jest.Mock;
getModule: jest.Mock;
deleteModule: jest.Mock;
softDeleteModule: jest.Mock;
restoreModule: jest.Mock;
updateModule: jest.Mock;
listJobs: jest.Mock;
listJobsForProject: jest.Mock;
listJobsForModule: jest.Mock;
Expand Down Expand Up @@ -289,6 +292,9 @@ export function createMockRouterDeps(): MockRouterDeps {
createModule: jest.fn().mockResolvedValue({ id: 'mock-module-id' }),
getModule: jest.fn(),
deleteModule: jest.fn().mockResolvedValue(1),
softDeleteModule: jest.fn().mockResolvedValue(1),
restoreModule: jest.fn().mockResolvedValue(1),
updateModule: jest.fn().mockResolvedValue(1),
listJobs: jest.fn(),
listJobsForProject: jest.fn(),
listJobsForModule: jest.fn(),
Expand Down
5 changes: 5 additions & 0 deletions workspaces/x2a/plugins/x2a-backend/src/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ const getX2aDatabaseServiceMock = (): typeof x2aDatabaseServiceRef.T => ({
// modules
createModule: jest.fn().mockRejectedValue(new NotAllowedError('mock error')),
deleteModule: jest.fn().mockRejectedValue(new NotAllowedError('mock error')),
softDeleteModule: jest
.fn()
.mockRejectedValue(new NotAllowedError('mock error')),
restoreModule: jest.fn().mockRejectedValue(new NotAllowedError('mock error')),
updateModule: jest.fn().mockRejectedValue(new NotAllowedError('mock error')),
listModules: jest.fn().mockRejectedValue(new NotAllowedError('mock error')),
getModule: jest.fn().mockRejectedValue(new NotAllowedError('mock error')),
// jobs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ describe('collectArtifacts routes (actions & signatures)', () => {
mockDeps.x2aDatabase.updateJob.mockResolvedValue(undefined);
mockDeps.x2aDatabase.listModules.mockResolvedValue(existingModules);
mockDeps.x2aDatabase.createModule.mockResolvedValue({ id: randomUUID() });
mockDeps.x2aDatabase.deleteModule.mockResolvedValue(1);
mockDeps.x2aDatabase.softDeleteModule.mockResolvedValue(1);

const requestBody = { status: 'success', jobId, artifacts };
const signature = signRequestBody(requestBody, callbackToken);
Expand All @@ -168,12 +168,75 @@ describe('collectArtifacts routes (actions & signatures)', () => {
projectId,
technology: undefined,
});
expect(mockDeps.x2aDatabase.deleteModule).toHaveBeenCalledTimes(1);
expect(mockDeps.x2aDatabase.deleteModule).toHaveBeenCalledWith({
expect(mockDeps.x2aDatabase.softDeleteModule).toHaveBeenCalledTimes(1);
expect(mockDeps.x2aDatabase.softDeleteModule).toHaveBeenCalledWith({
id: 'existing-2',
});
});

it('should soft-delete a module in success state when removed from metadata during resync', async () => {
const metadataModules = [
{ name: 'still-in-plan', path: '/cookbooks/still' },
];
const artifacts = [
{
id: randomUUID(),
type: 'project_metadata',
value: JSON.stringify(metadataModules),
},
];

const existingModules = [
{
id: 'module-success',
name: 'removed-from-plan',
sourcePath: '/cookbooks/removed',
projectId,
status: 'success',
analyze: { id: 'job-a', status: 'success', phase: 'analyze' },
},
{
id: 'module-kept',
name: 'still-in-plan',
sourcePath: '/cookbooks/still',
projectId,
},
];

const job: Job & { callbackToken?: string } = {
id: jobId,
projectId,
moduleId: undefined,
phase: 'init',
status: 'running',
startedAt: new Date(),
k8sJobName,
callbackToken,
};

mockDeps.x2aDatabase.getJob.mockResolvedValue(job);
mockDeps.kubeService.getJobLogs.mockResolvedValue('logs');
mockDeps.x2aDatabase.updateJob.mockResolvedValue(undefined);
mockDeps.x2aDatabase.listModules.mockResolvedValue(existingModules);
mockDeps.x2aDatabase.softDeleteModule.mockResolvedValue(1);

const requestBody = { status: 'success', jobId, artifacts };
const signature = signRequestBody(requestBody, callbackToken);

const res = await request(app)
.post(`/projects/${projectId}/collectArtifacts?phase=init`)
.set('X-Callback-Signature', signature)
.send(requestBody);

expect(res.status).toBe(200);
expect(mockDeps.x2aDatabase.softDeleteModule).toHaveBeenCalledTimes(1);
expect(mockDeps.x2aDatabase.softDeleteModule).toHaveBeenCalledWith({
id: 'module-success',
});
expect(mockDeps.x2aDatabase.createModule).not.toHaveBeenCalled();
expect(mockDeps.x2aDatabase.restoreModule).not.toHaveBeenCalled();
});

it('should not trigger phase actions when no project_metadata artifact', async () => {
const artifacts = [
{
Expand Down Expand Up @@ -291,6 +354,100 @@ describe('collectArtifacts routes (actions & signatures)', () => {
expect(mockDeps.x2aDatabase.listModules).not.toHaveBeenCalled();
expect(mockDeps.x2aDatabase.createModule).not.toHaveBeenCalled();
});

it('should restore previously soft-deleted modules when they reappear in metadata', async () => {
const metadataModules = [
{ name: 'restored-module', path: '/cookbooks/restored' },
];
const artifacts = [
{
id: randomUUID(),
type: 'project_metadata',
value: JSON.stringify(metadataModules),
},
];

const existingModules = [
{
id: 'existing-removed',
name: 'restored-module',
sourcePath: '/cookbooks/restored',
projectId,
removedAt: new Date('2026-01-01T00:00:00Z'),
},
];

const job: Job & { callbackToken?: string } = {
id: jobId,
projectId,
moduleId: undefined,
phase: 'init',
status: 'running',
startedAt: new Date(),
k8sJobName,
callbackToken,
};

mockDeps.x2aDatabase.getJob.mockResolvedValue(job);
mockDeps.kubeService.getJobLogs.mockResolvedValue('logs');
mockDeps.x2aDatabase.updateJob.mockResolvedValue(undefined);
mockDeps.x2aDatabase.listModules.mockResolvedValue(existingModules);
mockDeps.x2aDatabase.restoreModule.mockResolvedValue(1);

const requestBody = { status: 'success', jobId, artifacts };
const signature = signRequestBody(requestBody, callbackToken);

const res = await request(app)
.post(`/projects/${projectId}/collectArtifacts?phase=init`)
.set('X-Callback-Signature', signature)
.send(requestBody);

expect(res.status).toBe(200);
expect(mockDeps.x2aDatabase.restoreModule).toHaveBeenCalledTimes(1);
expect(mockDeps.x2aDatabase.restoreModule).toHaveBeenCalledWith({
id: 'existing-removed',
});
expect(mockDeps.x2aDatabase.createModule).not.toHaveBeenCalled();
expect(mockDeps.x2aDatabase.softDeleteModule).not.toHaveBeenCalled();
});

it('should handle malformed project_metadata JSON gracefully', async () => {
const artifacts = [
{
id: randomUUID(),
type: 'project_metadata',
value: 'not valid json {{{',
},
];

const job: Job & { callbackToken?: string } = {
id: jobId,
projectId,
moduleId: undefined,
phase: 'init',
status: 'running',
startedAt: new Date(),
k8sJobName,
callbackToken,
};

mockDeps.x2aDatabase.getJob.mockResolvedValue(job);
mockDeps.kubeService.getJobLogs.mockResolvedValue('logs');
mockDeps.x2aDatabase.updateJob.mockResolvedValue(undefined);

const requestBody = { status: 'success', jobId, artifacts };
const signature = signRequestBody(requestBody, callbackToken);

const res = await request(app)
.post(`/projects/${projectId}/collectArtifacts?phase=init`)
.set('X-Callback-Signature', signature)
.send(requestBody);

expect(res.status).toBe(200);
expect(mockDeps.x2aDatabase.listModules).not.toHaveBeenCalled();
expect(mockDeps.x2aDatabase.createModule).not.toHaveBeenCalled();
expect(mockDeps.x2aDatabase.softDeleteModule).not.toHaveBeenCalled();
});
});

describe('graceful failure', () => {
Expand Down
65 changes: 0 additions & 65 deletions workspaces/x2a/plugins/x2a-backend/src/router/modules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,69 +540,4 @@ describe('createRouter – modules', () => {
LONG_TEST_TIMEOUT,
);
});

describe('POST /projects/:projectId/modules', () => {
it.each(supportedDatabaseIds)(
'should create a module and return 201 - %p',
async databaseId => {
const { client } = await createDatabase(databaseId);
const app = await createApp(client);

const createProjectRes = await request(app)
.post('/projects')
.send(mockInputProject);
expect(createProjectRes.status).toBe(200);
const projectId = createProjectRes.body.id;

const response = await request(app)
.post(`/projects/${projectId}/modules`)
.send({ name: 'New Module', sourcePath: '/src/module' });

expect(response.status).toBe(201);
expect(response.body).toMatchObject({
name: 'New Module',
sourcePath: '/src/module',
projectId,
});
expect(response.body.id).toBeDefined();
},
LONG_TEST_TIMEOUT,
);

it.each(supportedDatabaseIds)(
'should return 404 when project does not exist - %p',
async databaseId => {
const { client } = await createDatabase(databaseId);
const app = await createApp(client);

const response = await request(app)
.post(`/projects/${nonExistentId}/modules`)
.send({ name: 'Module', sourcePath: '/path' });

expect(response.status).toBe(404);
expect(response.body.error.message).toContain('not found');
},
);

it.each(supportedDatabaseIds)(
'should return 400 when body is invalid - %p',
async databaseId => {
const { client } = await createDatabase(databaseId);
const app = await createApp(client);

const createProjectRes = await request(app)
.post('/projects')
.send(mockInputProject);
expect(createProjectRes.status).toBe(200);
const projectId = createProjectRes.body.id;

const response = await request(app)
.post(`/projects/${projectId}/modules`)
.send({ name: 'Only name' });

expect(response.status).toBe(400);
expect(response.body.error.name).toBe('InputError');
},
);
});
});
Loading
Loading