From 379e03ab1521c39ef24c2e397ae46d7fe007941b Mon Sep 17 00:00:00 2001 From: Marek Libra Date: Mon, 26 Jan 2026 11:55:22 +0100 Subject: [PATCH 1/2] feat(x2a): paginating of GET /projects result Signed-off-by: Marek Libra --- .../x2a/.changeset/proud-months-smash.md | 7 + .../x2a/plugins/x2a-backend/package.json | 2 +- .../x2a/plugins/x2a-backend/src/constants.ts | 18 + .../x2a/plugins/x2a-backend/src/plugin.ts | 3 + .../plugins/x2a-backend/src/router.test.ts | 260 +++++- .../x2a/plugins/x2a-backend/src/router.ts | 127 ++- .../x2a-backend/src/schema/openapi.yaml | 9 + .../openapi/generated/apis/Api.server.ts | 1 + .../src/schema/openapi/generated/router.ts | 13 + .../src/services/X2ADatabaseService.test.ts | 849 +++++++++++++++++- .../src/services/X2ADatabaseService.ts | 131 ++- .../plugins/x2a-backend/src/utils/delay.ts | 25 + .../plugins/x2a-backend/src/utils/index.ts | 1 + .../openapi/generated/apis/Api.client.ts | 4 +- .../x2a/plugins/x2a-common/package.json | 5 +- .../x2a/plugins/x2a-common/report.api.md | 15 + .../x2a/plugins/x2a-common/src/index.ts | 1 + .../x2a/plugins/x2a-common/src/permissions.ts | 62 ++ workspaces/x2a/yarn.lock | 1 + 19 files changed, 1467 insertions(+), 67 deletions(-) create mode 100644 workspaces/x2a/.changeset/proud-months-smash.md create mode 100644 workspaces/x2a/plugins/x2a-backend/src/constants.ts create mode 100644 workspaces/x2a/plugins/x2a-backend/src/utils/delay.ts create mode 100644 workspaces/x2a/plugins/x2a-common/src/permissions.ts diff --git a/workspaces/x2a/.changeset/proud-months-smash.md b/workspaces/x2a/.changeset/proud-months-smash.md new file mode 100644 index 0000000000..a48787fa13 --- /dev/null +++ b/workspaces/x2a/.changeset/proud-months-smash.md @@ -0,0 +1,7 @@ +--- +'@red-hat-developer-hub/backstage-plugin-x2a-backend': patch +'@red-hat-developer-hub/backstage-plugin-x2a-common': patch +'@red-hat-developer-hub/backstage-plugin-x2a': patch +--- + +Adding pagination and filtering by permissions to the GET|POST /projects and GET /project/[id] endpoints. diff --git a/workspaces/x2a/plugins/x2a-backend/package.json b/workspaces/x2a/plugins/x2a-backend/package.json index af7725bfa5..c956ef5b01 100644 --- a/workspaces/x2a/plugins/x2a-backend/package.json +++ b/workspaces/x2a/plugins/x2a-backend/package.json @@ -40,6 +40,7 @@ "@backstage/catalog-client": "^1.12.1", "@backstage/errors": "^1.2.7", "@backstage/plugin-catalog-node": "^1.20.0", + "@backstage/plugin-permission-common": "^0.9.4", "@backstage/types": "^1.2.2", "@kubernetes/client-node": "^1.4.0", "@red-hat-developer-hub/backstage-plugin-x2a-common": "workspace:*", @@ -51,7 +52,6 @@ "devDependencies": { "@backstage/backend-test-utils": "^1.10.1", "@backstage/cli": "^0.34.5", - "@backstage/plugin-permission-common": "^0.9.4", "@backstage/repo-tools": "^0.16.2", "@types/express": "^4.17.6", "@types/knex": "^0.16.0", diff --git a/workspaces/x2a/plugins/x2a-backend/src/constants.ts b/workspaces/x2a/plugins/x2a-backend/src/constants.ts new file mode 100644 index 0000000000..73581263cf --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/src/constants.ts @@ -0,0 +1,18 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export const DEFAULT_PAGE_SIZE = 10; +export const DEFAULT_PAGE_SORT = 'created_at'; +export const DEFAULT_PAGE_ORDER = 'desc'; diff --git a/workspaces/x2a/plugins/x2a-backend/src/plugin.ts b/workspaces/x2a/plugins/x2a-backend/src/plugin.ts index 5968f331c1..e525955a32 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/plugin.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/plugin.ts @@ -36,11 +36,13 @@ export const x2APlugin = createBackendPlugin({ httpRouter: coreServices.httpRouter, database: coreServices.database, logger: coreServices.logger, + permissionsSvc: coreServices.permissions, x2aDatabase: x2aDatabaseServiceRef, kubeService: kubeServiceRef, }, async init({ httpRouter, + permissionsSvc, x2aDatabase, logger, httpAuth, @@ -53,6 +55,7 @@ export const x2APlugin = createBackendPlugin({ await createRouter({ httpAuth, logger, + permissionsSvc, x2aDatabase, kubeService, }), diff --git a/workspaces/x2a/plugins/x2a-backend/src/router.test.ts b/workspaces/x2a/plugins/x2a-backend/src/router.test.ts index 61fa57460f..e62f3c8974 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router.test.ts @@ -22,6 +22,7 @@ import { } from '@backstage/backend-test-utils'; import express from 'express'; import request from 'supertest'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; import { createRouter } from './router'; import { X2ADatabaseService } from './services/X2ADatabaseService'; @@ -40,7 +41,11 @@ const mockInputProject: ProjectsPostRequest = { abbreviation: 'MP', }; -async function createApp(client: Knex): Promise { +async function createApp( + client: Knex, + authorizeResult?: AuthorizeResult, + adminWriteResult?: AuthorizeResult, +): Promise { const x2aDatabase = X2ADatabaseService.create({ logger: mockServices.logger.mock(), dbClient: client, @@ -48,6 +53,26 @@ async function createApp(client: Knex): Promise { const router = await createRouter({ httpAuth: mockServices.httpAuth(), logger: mockServices.logger.mock(), + permissionsSvc: mockServices.permissions.mock({ + authorize: async (requests: any[]) => { + // Check which permission is being requested + const permission = requests[0]?.permission; + if ( + permission?.name === 'x2a.admin' && + permission?.attributes?.action === 'update' + ) { + // This is x2aAdminWritePermission + return [ + { + result: + adminWriteResult ?? authorizeResult ?? AuthorizeResult.ALLOW, + }, + ] as any; + } + // Default to the provided authorizeResult or ALLOW + return [{ result: authorizeResult ?? AuthorizeResult.ALLOW }] as any; + }, + }), x2aDatabase, kubeService: { getPods: jest.fn().mockResolvedValue({ items: [] }), @@ -109,7 +134,7 @@ describe('createRouter', () => { ); it.each(databases.eachSupportedId())( - 'should not allow unauthenticated requests to create a migration - %p', + 'should not allow unauthenticated requests to create a project - %p', async databaseId => { const { client } = await createDatabase(databaseId); const app = await createApp(client); @@ -127,6 +152,62 @@ describe('createRouter', () => { }, ); + it.each(databases.eachSupportedId())( + 'should allow users with x2aUserPermission to create projects - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const app = await createApp(client, AuthorizeResult.ALLOW); + + const response = await request(app) + .post('/projects') + .send(mockInputProject); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + ...mockInputProject, + createdBy: 'user:default/mock', + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'should allow users with x2aAdminWritePermission to create projects - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const app = await createApp(client, AuthorizeResult.ALLOW); + + const response = await request(app) + .post('/projects') + .send(mockInputProject); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + ...mockInputProject, + createdBy: 'user:default/mock', + }); + }, + ); + + it.each(databases.eachSupportedId())( + 'should deny users without permissions from creating projects - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const app = await createApp(client, AuthorizeResult.DENY); + + const response = await request(app) + .post('/projects') + .send(mockInputProject); + + expect(response.status).toBe(403); + expect(response.body).toMatchObject({ + error: { + name: 'NotAllowedError', + message: 'You are not allowed to create a project', + }, + }); + }, + ); + it.each(databases.eachSupportedId())( 'should get a project by id - %p', async databaseId => { @@ -225,4 +306,179 @@ describe('createRouter', () => { }); }, ); + + it.each(databases.eachSupportedId())( + 'should allow users with admin write permission to delete any project - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + + // Create a project as user1 + // const user1Credentials = mockCredentials.user('user:default/user1'); + const user1CredentialsHeader = + mockCredentials.user.header('user:default/user1'); + const appWithCreate = await createApp(client, AuthorizeResult.ALLOW); + const createResponse = await request(appWithCreate) + .post('/projects') + .set('Authorization', user1CredentialsHeader) + .send(mockInputProject); + + expect(createResponse.status).toBe(200); + const projectId = createResponse.body.id; + expect(createResponse.body.createdBy).toBe('user:default/user1'); + + // Verify project exists + const getResponse = await request(appWithCreate) + .get(`/projects/${projectId}`) + .set('Authorization', user1CredentialsHeader) + .send(); + expect(getResponse.status).toBe(200); + + // Try to delete as user2 (non-admin) - should fail because they didn't create it + // const user2Credentials = mockCredentials.user('user:default/user2'); + const user2CredentialsHeader = + mockCredentials.user.header('user:default/user2'); + const appNonAdmin = await createApp( + client, + AuthorizeResult.ALLOW, // Can create projects + AuthorizeResult.DENY, // Cannot delete others' projects (no admin write) + ); + const deleteResponseNonAdmin = await request(appNonAdmin) + .delete(`/projects/${projectId}`) + .set('Authorization', user2CredentialsHeader) + .send(); + + // Non-admin user2 cannot delete project created by user1 + // The deleteProject filters by created_by, so deletedCount will be 0 + expect(deleteResponseNonAdmin.status).toBe(404); + expect(deleteResponseNonAdmin.body).toMatchObject({ + error: { name: 'NotFoundError', message: 'Project not found' }, + }); + + // Verify project still exists + const getAfterFailedDelete = await request(appWithCreate) + .get(`/projects/${projectId}`) + .set('Authorization', user1CredentialsHeader) + .send(); + expect(getAfterFailedDelete.status).toBe(200); + expect(getAfterFailedDelete.body.id).toBe(projectId); + + // Now delete as admin user (should succeed even though created by user1) + // const adminCredentials = mockCredentials.user('user:default/admin'); + const adminCredentialsHeader = + mockCredentials.user.header('user:default/admin'); + const appAdmin = await createApp( + client, + AuthorizeResult.ALLOW, + AuthorizeResult.ALLOW, + ); + const deleteResponseAdmin = await request(appAdmin) + .delete(`/projects/${projectId}`) + .set('Authorization', adminCredentialsHeader) + .send(); + + expect(deleteResponseAdmin.status).toBe(200); + expect(deleteResponseAdmin.body.deletedCount).toBe(1); + + // Verify project is deleted + const getAfterDelete = await request(appWithCreate) + .get(`/projects/${projectId}`) + .set('Authorization', user1CredentialsHeader) + .send(); + expect(getAfterDelete.status).toBe(404); + }, + ); + + it.each(databases.eachSupportedId())( + 'should allow users without admin write permission to delete their own project - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + // User does not have admin write permission, but can create projects + const app = await createApp( + client, + AuthorizeResult.ALLOW, // Can create (has x2aUserPermission) + AuthorizeResult.DENY, // Cannot delete others' projects (no admin write) + ); + + // Create a project (as the default mock user) + const createResponse = await request(app) + .post('/projects') + .send(mockInputProject); + + expect(createResponse.status).toBe(200); + const projectId = createResponse.body.id; + expect(createResponse.body.createdBy).toBe('user:default/mock'); + + // Delete own project (should succeed) + const deleteResponse = await request(app) + .delete(`/projects/${projectId}`) + .send(); + + expect(deleteResponse.status).toBe(200); + expect(deleteResponse.body.deletedCount).toBe(1); + + // Verify project is deleted + const getAfterDeleteResponse = await request(app) + .get(`/projects/${projectId}`) + .send(); + expect(getAfterDeleteResponse.status).toBe(404); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return 404 when deletion fails due to permission filtering - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + // User does not have admin write permission + const app = await createApp( + client, + AuthorizeResult.ALLOW, // Can create projects + AuthorizeResult.DENY, // Cannot delete others' projects (no admin write) + ); + + // Create a project + const createResponse = await request(app) + .post('/projects') + .send(mockInputProject); + + expect(createResponse.status).toBe(200); + const projectId = createResponse.body.id; + + // Note: The permission filtering happens at the database service level. + // When canWriteAll is false, deleteProject filters by created_by. + // Since the same user created and is deleting, it should succeed. + // Cross-user deletion prevention is tested in X2ADatabaseService tests. + // This test verifies the endpoint integration works correctly. + + const deleteResponse = await request(app) + .delete(`/projects/${projectId}`) + .send(); + + // Same user can delete their own project + expect(deleteResponse.status).toBe(200); + expect(deleteResponse.body.deletedCount).toBe(1); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return 404 when deleting non-existent project even with admin write permission - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + // User has admin write permission + const app = await createApp( + client, + AuthorizeResult.ALLOW, + AuthorizeResult.ALLOW, + ); + + const nonExistentId = '00000000-0000-0000-0000-000000000000'; + const response = await request(app) + .delete(`/projects/${nonExistentId}`) + .send(); + + expect(response.status).toBe(404); + expect(response.body).toMatchObject({ + error: { name: 'NotFoundError', message: 'Project not found' }, + }); + }, + ); }); diff --git a/workspaces/x2a/plugins/x2a-backend/src/router.ts b/workspaces/x2a/plugins/x2a-backend/src/router.ts index 303323b3a3..05548dc894 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/router.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/router.ts @@ -14,10 +14,24 @@ * limitations under the License. */ -import { HttpAuthService, LoggerService } from '@backstage/backend-plugin-api'; -import { InputError, NotFoundError } from '@backstage/errors'; import { z } from 'zod'; -import express from 'express'; +import express, { Request } from 'express'; +import { + HttpAuthService, + LoggerService, + PermissionsService, +} from '@backstage/backend-plugin-api'; +import { InputError, NotAllowedError, NotFoundError } from '@backstage/errors'; +import { + AuthorizePermissionResponse, + AuthorizeResult, + BasicPermission, +} from '@backstage/plugin-permission-common'; +import { + x2aAdminViewPermission, + x2aAdminWritePermission, + x2aUserPermission, +} from '@red-hat-developer-hub/backstage-plugin-x2a-common'; import { x2aDatabaseServiceRef } from './services/X2ADatabaseService'; import { @@ -27,23 +41,81 @@ import { } from './schema/openapi'; import { kubeServiceRef } from './services/KubeService'; +const isUserOfAdminViewPermission = async ( + request: Request, + permissionsSvc: PermissionsService, + httpAuth: HttpAuthService, +): Promise => { + const credentials = await httpAuth.credentials(request); + const result = await permissionsSvc.authorize( + [{ permission: x2aAdminViewPermission }], + { credentials }, + ); + return result?.[0]?.result === AuthorizeResult.ALLOW; +}; + +const isUserOfAdminWritePermission = async ( + request: Request, + permissionsSvc: PermissionsService, + httpAuth: HttpAuthService, +): Promise => { + const credentials = await httpAuth.credentials(request); + const result = await permissionsSvc.authorize( + [{ permission: x2aAdminWritePermission }], + { credentials }, + ); + return result?.[0]?.result === AuthorizeResult.ALLOW; +}; + +const authorize = async ( + request: Request, + anyOfPermissions: BasicPermission[], + permissionsSvc: PermissionsService, + httpAuth: HttpAuthService, +): Promise => { + const credentials = await httpAuth.credentials(request); + const decisionResponses: AuthorizePermissionResponse[][] = await Promise.all( + anyOfPermissions.map(permission => + permissionsSvc.authorize([{ permission }], { + credentials, + }), + ), + ); + + const decisions: AuthorizePermissionResponse[] = decisionResponses.map( + d => d?.[0] ?? { result: AuthorizeResult.DENY }, + ); + const allow = decisions.find(d => d.result === AuthorizeResult.ALLOW); + return ( + allow || { + result: AuthorizeResult.DENY, + } + ); +}; + export async function createRouter({ httpAuth, x2aDatabase, logger, + permissionsSvc, }: { httpAuth: HttpAuthService; x2aDatabase: typeof x2aDatabaseServiceRef.T; kubeService: typeof kubeServiceRef.T; logger: LoggerService; + permissionsSvc: PermissionsService; }): Promise { const router = await createOpenApiRouter(); router.get('/projects', async (req, res) => { const endpoint = 'GET /projects'; + logger.info(`${endpoint} request received`); + + // parse request query const projectsGetRequestSchema = z.object({ page: z.number().optional(), pageSize: z.number().optional(), + order: z.enum(['asc', 'desc']).optional(), sort: z .enum(['createdAt', 'name', 'description', 'createdBy']) .optional(), @@ -61,7 +133,15 @@ export async function createRouter({ logger.info(`${endpoint} request received: query=${JSON.stringify(query)}`); - const { projects, totalCount } = await x2aDatabase.listProjects(); + // list projects + const { projects, totalCount } = await x2aDatabase.listProjects(query, { + credentials: await httpAuth.credentials(req, { allow: ['user'] }), + canViewAll: await isUserOfAdminViewPermission( + req as unknown as Request, + permissionsSvc, + httpAuth, + ), + }); const response: ProjectsGet['response'] = { totalCount, @@ -72,6 +152,20 @@ export async function createRouter({ router.post('/projects', async (req, res) => { const endpoint = 'POST /projects'; + logger.info(`${endpoint} request received`); + + // authorize request + const decision = await authorize( + req, + [x2aAdminWritePermission, x2aUserPermission], + permissionsSvc, + httpAuth, + ); + if (decision.result === AuthorizeResult.DENY) { + throw new NotAllowedError('You are not allowed to create a project'); + } + + // parse request body const projectCreateRequestSchema = z.object({ name: z.string(), description: z.string(), @@ -86,6 +180,7 @@ export async function createRouter({ } const requestBody: ProjectsPost['body'] = parsedBody.data; + // create project const newProject = await x2aDatabase.createProject(requestBody, { credentials: await httpAuth.credentials(req, { allow: ['user'] }), }); @@ -99,7 +194,17 @@ export async function createRouter({ const projectId = req.params.projectId; logger.info(`${endpoint} request received: projectId=${projectId}`); - const project = await x2aDatabase.getProject({ projectId }); + const project = await x2aDatabase.getProject( + { projectId }, + { + credentials: await httpAuth.credentials(req, { allow: ['user'] }), + canViewAll: await isUserOfAdminViewPermission( + req as unknown as Request, + permissionsSvc, + httpAuth, + ), + }, + ); if (!project) { throw new NotFoundError(`Project not found`); } @@ -110,7 +215,17 @@ export async function createRouter({ const endpoint = 'DELETE /projects/:projectId'; const projectId = req.params.projectId; logger.info(`${endpoint} request received: projectId=${projectId}`); - const deletedCount = await x2aDatabase.deleteProject({ projectId }); + const deletedCount = await x2aDatabase.deleteProject( + { projectId }, + { + credentials: await httpAuth.credentials(req, { allow: ['user'] }), + canWriteAll: await isUserOfAdminWritePermission( + req as unknown as Request, + permissionsSvc, + httpAuth, + ), + }, + ); if (deletedCount === 0) { throw new NotFoundError(`Project not found`); } diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml index 343070a601..c2390eb4ee 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi.yaml @@ -24,6 +24,15 @@ paths: type: integer required: false description: Page size + - in: query + name: order + schema: + type: string + enum: + - asc + - desc + required: false + description: Sort order, either ascending ("asc") or descending ("desc") - in: query name: sort schema: diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts index 3a04fc68e8..21904b05c2 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/apis/Api.server.ts @@ -31,6 +31,7 @@ export type ProjectsGet = { query: { page?: number; pageSize?: number; + order?: 'asc' | 'desc'; sort?: 'createdAt' | 'name' | 'description' | 'createdBy'; }; response: ProjectsGet200Response; diff --git a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts index 2aa3634ebf..4d70768769 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/schema/openapi/generated/router.ts @@ -56,6 +56,19 @@ export const spec = { "required": false, "description": "Page size" }, + { + "in": "query", + "name": "order", + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + }, + "required": false, + "description": "Sort order, either ascending (\"asc\") or descending (\"desc\")" + }, { "in": "query", "name": "sort", diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.test.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.test.ts index e0ebcbe4dd..1bd52282b9 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.test.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.test.ts @@ -23,7 +23,7 @@ import { import { Knex } from 'knex'; import { X2ADatabaseService } from './X2ADatabaseService'; import { migrate } from './dbMigrate'; -import { toSorted } from '../utils'; +import { delay, toSorted } from '../utils'; const databases = TestDatabases.create({ // TODO: Reenable for 'POSTGRES_18' @@ -150,7 +150,8 @@ describe('X2ADatabaseService', () => { const { client } = await createDatabase(databaseId); const service = createService(client); - const result = await service.listProjects(); + const credentials = mockCredentials.user(); + const result = await service.listProjects({}, { credentials }); expect(result.projects).toEqual([]); expect(result.totalCount).toBe(0); @@ -172,6 +173,7 @@ describe('X2ADatabaseService', () => { }, { credentials }, ); + await delay(10); // To make sure the projects are created in the correct order await service.createProject( { name: 'Project 2', @@ -180,6 +182,7 @@ describe('X2ADatabaseService', () => { }, { credentials }, ); + await delay(10); await service.createProject( { name: 'Project 3', @@ -189,7 +192,10 @@ describe('X2ADatabaseService', () => { { credentials }, ); - const result = await service.listProjects(); + const result = await service.listProjects( + { order: 'desc', sort: 'createdAt' }, + { credentials }, + ); expect(result.totalCount).toBe(3); expect(result.projects).toHaveLength(3); @@ -198,6 +204,428 @@ describe('X2ADatabaseService', () => { expect(result.projects[2].name).toBe('Project 1'); }, ); + + it.each(databases.eachSupportedId())( + 'should paginate results with page and pageSize - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials = mockCredentials.user(); + // Create 5 projects + for (let i = 1; i <= 5; i++) { + await service.createProject( + { + name: `Project ${i}`, + abbreviation: `P${i}`, + description: `Project ${i} description`, + }, + { credentials }, + ); + await delay(10); + } + + // First page with pageSize 2 + const page1 = await service.listProjects( + { page: 0, pageSize: 2, sort: 'createdAt', order: 'desc' }, + { credentials }, + ); + expect(page1.totalCount).toBe(5); + expect(page1.projects).toHaveLength(2); + expect(page1.projects[0].name).toBe('Project 5'); + expect(page1.projects[1].name).toBe('Project 4'); + + // Second page + const page2 = await service.listProjects( + { page: 1, pageSize: 2, sort: 'createdAt', order: 'desc' }, + { credentials }, + ); + expect(page2.totalCount).toBe(5); + expect(page2.projects).toHaveLength(2); + expect(page2.projects[0].name).toBe('Project 3'); + expect(page2.projects[1].name).toBe('Project 2'); + + // Third page (last page with 1 item) + const page3 = await service.listProjects( + { page: 2, pageSize: 2, sort: 'createdAt', order: 'desc' }, + { credentials }, + ); + expect(page3.totalCount).toBe(5); + expect(page3.projects).toHaveLength(1); + expect(page3.projects[0].name).toBe('Project 1'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should use default pageSize when not specified - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials = mockCredentials.user(); + // Create more than DEFAULT_PAGE_SIZE (10) projects + for (let i = 1; i <= 15; i++) { + await service.createProject( + { + name: `Project ${i}`, + abbreviation: `P${i}`, + description: `Project ${i} description`, + }, + { credentials }, + ); + if (i < 15) await delay(10); + } + + const result = await service.listProjects( + { sort: 'createdAt', order: 'desc' }, + { credentials }, + ); + + expect(result.totalCount).toBe(15); + expect(result.projects).toHaveLength(10); // Default page size + }, + ); + + it.each(databases.eachSupportedId())( + 'should sort by name ascending - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials = mockCredentials.user(); + await service.createProject( + { name: 'Zebra Project', abbreviation: 'ZP', description: 'Z' }, + { credentials }, + ); + await delay(10); + await service.createProject( + { name: 'Alpha Project', abbreviation: 'AP', description: 'A' }, + { credentials }, + ); + await delay(10); + await service.createProject( + { name: 'Beta Project', abbreviation: 'BP', description: 'B' }, + { credentials }, + ); + + const result = await service.listProjects( + { sort: 'name', order: 'asc' }, + { credentials }, + ); + + expect(result.projects).toHaveLength(3); + expect(result.projects[0].name).toBe('Alpha Project'); + expect(result.projects[1].name).toBe('Beta Project'); + expect(result.projects[2].name).toBe('Zebra Project'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should sort by name descending - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials = mockCredentials.user(); + await service.createProject( + { name: 'Alpha Project', abbreviation: 'AP', description: 'A' }, + { credentials }, + ); + await delay(10); + await service.createProject( + { name: 'Zebra Project', abbreviation: 'ZP', description: 'Z' }, + { credentials }, + ); + await delay(10); + await service.createProject( + { name: 'Beta Project', abbreviation: 'BP', description: 'B' }, + { credentials }, + ); + + const result = await service.listProjects( + { sort: 'name', order: 'desc' }, + { credentials }, + ); + + expect(result.projects).toHaveLength(3); + expect(result.projects[0].name).toBe('Zebra Project'); + expect(result.projects[1].name).toBe('Beta Project'); + expect(result.projects[2].name).toBe('Alpha Project'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should sort by createdBy - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + const credentials3 = mockCredentials.user('user:default/user3'); + + await service.createProject( + { name: 'Project 1', abbreviation: 'P1', description: 'D1' }, + { credentials: credentials3 }, + ); + await delay(10); + await service.createProject( + { name: 'Project 2', abbreviation: 'P2', description: 'D2' }, + { credentials: credentials1 }, + ); + await delay(10); + await service.createProject( + { name: 'Project 3', abbreviation: 'P3', description: 'D3' }, + { credentials: credentials2 }, + ); + + const result = await service.listProjects( + { sort: 'createdBy', order: 'asc' }, + { credentials: credentials1, canViewAll: true }, + ); + + expect(result.projects).toHaveLength(3); + // Should be sorted by created_by (userEntityRef) ascending + expect(result.projects[0].createdBy).toBe('user:default/user1'); + expect(result.projects[1].createdBy).toBe('user:default/user2'); + expect(result.projects[2].createdBy).toBe('user:default/user3'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should filter by user when canViewAll is false - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + // User1 creates 2 projects + await service.createProject( + { name: 'User1 Project 1', abbreviation: 'U1P1', description: 'D1' }, + { credentials: credentials1 }, + ); + await delay(10); + await service.createProject( + { name: 'User1 Project 2', abbreviation: 'U1P2', description: 'D2' }, + { credentials: credentials1 }, + ); + await delay(10); + + // User2 creates 2 projects + await service.createProject( + { name: 'User2 Project 1', abbreviation: 'U2P1', description: 'D3' }, + { credentials: credentials2 }, + ); + await delay(10); + await service.createProject( + { name: 'User2 Project 2', abbreviation: 'U2P2', description: 'D4' }, + { credentials: credentials2 }, + ); + + // User1 should only see their own projects + const user1Result = await service.listProjects( + { sort: 'createdAt', order: 'desc' }, + { credentials: credentials1, canViewAll: false }, + ); + expect(user1Result.totalCount).toBe(2); + expect(user1Result.projects).toHaveLength(2); + expect( + user1Result.projects.every(p => p.createdBy === 'user:default/user1'), + ).toBe(true); + + // User2 should only see their own projects + const user2Result = await service.listProjects( + { sort: 'createdAt', order: 'desc' }, + { credentials: credentials2, canViewAll: false }, + ); + expect(user2Result.totalCount).toBe(2); + expect(user2Result.projects).toHaveLength(2); + expect( + user2Result.projects.every(p => p.createdBy === 'user:default/user2'), + ).toBe(true); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return all projects when canViewAll is true - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + const credentials3 = mockCredentials.user('user:default/user3'); + + await service.createProject( + { name: 'Project 1', abbreviation: 'P1', description: 'D1' }, + { credentials: credentials1 }, + ); + await delay(10); + await service.createProject( + { name: 'Project 2', abbreviation: 'P2', description: 'D2' }, + { credentials: credentials2 }, + ); + await delay(10); + await service.createProject( + { name: 'Project 3', abbreviation: 'P3', description: 'D3' }, + { credentials: credentials3 }, + ); + + // User1 with canViewAll should see all projects + const result = await service.listProjects( + { sort: 'createdAt', order: 'desc' }, + { credentials: credentials1, canViewAll: true }, + ); + + expect(result.totalCount).toBe(3); + expect(result.projects).toHaveLength(3); + expect( + result.projects.some(p => p.createdBy === 'user:default/user1'), + ).toBe(true); + expect( + result.projects.some(p => p.createdBy === 'user:default/user2'), + ).toBe(true); + expect( + result.projects.some(p => p.createdBy === 'user:default/user3'), + ).toBe(true); + }, + ); + + it.each(databases.eachSupportedId())( + 'should filter by user when canViewAll is undefined - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + await service.createProject( + { name: 'User1 Project', abbreviation: 'U1P', description: 'D1' }, + { credentials: credentials1 }, + ); + await delay(10); + await service.createProject( + { name: 'User2 Project', abbreviation: 'U2P', description: 'D2' }, + { credentials: credentials2 }, + ); + + // When canViewAll is undefined, should default to filtering + const result = await service.listProjects( + { sort: 'createdAt', order: 'desc' }, + { credentials: credentials1 }, + ); + + expect(result.totalCount).toBe(1); + expect(result.projects).toHaveLength(1); + expect(result.projects[0].createdBy).toBe('user:default/user1'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should combine pagination and user filtering - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + // User1 creates 5 projects + for (let i = 1; i <= 5; i++) { + await service.createProject( + { + name: `User1 Project ${i}`, + abbreviation: `U1P${i}`, + description: `Description ${i}`, + }, + { credentials: credentials1 }, + ); + await delay(10); + } + + // User2 creates 3 projects + for (let i = 1; i <= 3; i++) { + await service.createProject( + { + name: `User2 Project ${i}`, + abbreviation: `U2P${i}`, + description: `Description ${i}`, + }, + { credentials: credentials2 }, + ); + if (i < 3) await delay(10); + } + + // User1 should only see their 5 projects, paginated + const page1 = await service.listProjects( + { page: 1, pageSize: 2, sort: 'createdAt', order: 'desc' }, + { credentials: credentials1, canViewAll: false }, + ); + expect(page1.totalCount).toBe(5); // Total count should be 5, not 8 + expect(page1.projects).toHaveLength(2); + expect( + page1.projects.every(p => p.createdBy === 'user:default/user1'), + ).toBe(true); + }, + ); + + it.each(databases.eachSupportedId())( + 'should use default sort and order when not specified - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials = mockCredentials.user(); + await service.createProject( + { name: 'Project 1', abbreviation: 'P1', description: 'D1' }, + { credentials }, + ); + await delay(10); + await service.createProject( + { name: 'Project 2', abbreviation: 'P2', description: 'D2' }, + { credentials }, + ); + await delay(10); + await service.createProject( + { name: 'Project 3', abbreviation: 'P3', description: 'D3' }, + { credentials }, + ); + + const result = await service.listProjects({}, { credentials }); + + expect(result.projects).toHaveLength(3); + // Should default to created_at desc (newest first) + expect(result.projects[0].name).toBe('Project 3'); + expect(result.projects[1].name).toBe('Project 2'); + expect(result.projects[2].name).toBe('Project 1'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should handle empty page gracefully - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials = mockCredentials.user(); + await service.createProject( + { name: 'Project 1', abbreviation: 'P1', description: 'D1' }, + { credentials }, + ); + + // Request page 2 which doesn't exist + const result = await service.listProjects( + { page: 2, pageSize: 10 }, + { credentials }, + ); + + expect(result.totalCount).toBe(1); + expect(result.projects).toHaveLength(0); + }, + ); }); describe('getProject', () => { @@ -207,9 +635,13 @@ describe('X2ADatabaseService', () => { const { client } = await createDatabase(databaseId); const service = createService(client); - const project = await service.getProject({ - projectId: 'non-existent-id', - }); + const credentials = mockCredentials.user(); + const project = await service.getProject( + { + projectId: 'non-existent-id', + }, + { credentials }, + ); expect(project).toBeUndefined(); }, @@ -231,9 +663,12 @@ describe('X2ADatabaseService', () => { { credentials }, ); - const retrievedProject = await service.getProject({ - projectId: createdProject.id, - }); + const retrievedProject = await service.getProject( + { + projectId: createdProject.id, + }, + { credentials }, + ); expect(retrievedProject).toBeDefined(); expect(retrievedProject?.id).toBe(createdProject.id); @@ -271,8 +706,14 @@ describe('X2ADatabaseService', () => { { credentials }, ); - const retrieved1 = await service.getProject({ projectId: project1.id }); - const retrieved2 = await service.getProject({ projectId: project2.id }); + const retrieved1 = await service.getProject( + { projectId: project1.id }, + { credentials }, + ); + const retrieved2 = await service.getProject( + { projectId: project2.id }, + { credentials }, + ); expect(retrieved1?.id).toBe(project1.id); expect(retrieved1?.name).toBe('Project 1'); @@ -280,6 +721,132 @@ describe('X2ADatabaseService', () => { expect(retrieved2?.name).toBe('Project 2'); }, ); + + it.each(databases.eachSupportedId())( + 'should return undefined when user tries to access project created by another user - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + // User1 creates a project + const project = await service.createProject( + { + name: 'User1 Project', + abbreviation: 'U1P', + description: 'Project created by user1', + }, + { credentials: credentials1 }, + ); + + // User2 tries to access user1's project (should be denied) + const retrievedProject = await service.getProject( + { + projectId: project.id, + }, + { credentials: credentials2, canViewAll: false }, + ); + + expect(retrievedProject).toBeUndefined(); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return project when user accesses their own project - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials = mockCredentials.user('user:default/user1'); + + const project = await service.createProject( + { + name: 'My Project', + abbreviation: 'MP', + description: 'My own project', + }, + { credentials }, + ); + + // User should be able to access their own project + const retrievedProject = await service.getProject( + { + projectId: project.id, + }, + { credentials, canViewAll: false }, + ); + + expect(retrievedProject).toBeDefined(); + expect(retrievedProject?.id).toBe(project.id); + expect(retrievedProject?.createdBy).toBe('user:default/user1'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should return project when canViewAll is true even if created by different user - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + // User1 creates a project + const project = await service.createProject( + { + name: 'User1 Project', + abbreviation: 'U1P', + description: 'Project created by user1', + }, + { credentials: credentials1 }, + ); + + // User2 with canViewAll should be able to access user1's project + const retrievedProject = await service.getProject( + { + projectId: project.id, + }, + { credentials: credentials2, canViewAll: true }, + ); + + expect(retrievedProject).toBeDefined(); + expect(retrievedProject?.id).toBe(project.id); + expect(retrievedProject?.createdBy).toBe('user:default/user1'); + }, + ); + + it.each(databases.eachSupportedId())( + 'should filter by user when canViewAll is undefined - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + // User1 creates a project + const project = await service.createProject( + { + name: 'User1 Project', + abbreviation: 'U1P', + description: 'Project created by user1', + }, + { credentials: credentials1 }, + ); + + // When canViewAll is undefined, should default to filtering + const retrievedProject = await service.getProject( + { + projectId: project.id, + }, + { credentials: credentials2 }, + ); + + expect(retrievedProject).toBeUndefined(); + }, + ); }); describe('deleteProject', () => { @@ -289,9 +856,13 @@ describe('X2ADatabaseService', () => { const { client } = await createDatabase(databaseId); const service = createService(client); - const deletedCount = await service.deleteProject({ - projectId: 'non-existent-id', - }); + const credentials = mockCredentials.user(); + const deletedCount = await service.deleteProject( + { + projectId: 'non-existent-id', + }, + { credentials }, + ); expect(deletedCount).toBe(0); }, @@ -314,20 +885,29 @@ describe('X2ADatabaseService', () => { ); // Verify it exists - const beforeDelete = await service.getProject({ - projectId: project.id, - }); + const beforeDelete = await service.getProject( + { + projectId: project.id, + }, + { credentials }, + ); expect(beforeDelete).toBeDefined(); // Delete it - const deletedCount = await service.deleteProject({ - projectId: project.id, - }); + const deletedCount = await service.deleteProject( + { + projectId: project.id, + }, + { credentials }, + ); expect(deletedCount).toBe(1); // Verify it's gone - const afterDelete = await service.getProject({ projectId: project.id }); + const afterDelete = await service.getProject( + { projectId: project.id }, + { credentials }, + ); expect(afterDelete).toBeUndefined(); }, ); @@ -356,27 +936,196 @@ describe('X2ADatabaseService', () => { { credentials }, ); - const deletedCount = await service.deleteProject({ - projectId: project1.id, - }); + const deletedCount = await service.deleteProject( + { + projectId: project1.id, + }, + { credentials }, + ); expect(deletedCount).toBe(1); // Verify project1 is deleted - const deleted = await service.getProject({ projectId: project1.id }); + const deleted = await service.getProject( + { projectId: project1.id }, + { credentials }, + ); expect(deleted).toBeUndefined(); // Verify project2 still exists - const remaining = await service.getProject({ projectId: project2.id }); + const remaining = await service.getProject( + { projectId: project2.id }, + { credentials }, + ); expect(remaining).toBeDefined(); expect(remaining?.name).toBe('Project 2'); // Verify total count - const listResult = await service.listProjects(); + const listResult = await service.listProjects({}, { credentials }); expect(listResult.totalCount).toBe(1); expect(listResult.projects[0].id).toBe(project2.id); }, ); + + it.each(databases.eachSupportedId())( + 'should return 0 when user tries to delete project created by another user - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + // User1 creates a project + const project = await service.createProject( + { + name: 'User1 Project', + abbreviation: 'U1P', + description: 'Project created by user1', + }, + { credentials: credentials1 }, + ); + + // User2 tries to delete user1's project (should fail - returns 0) + const deletedCount = await service.deleteProject( + { + projectId: project.id, + }, + { credentials: credentials2, canWriteAll: false }, + ); + + expect(deletedCount).toBe(0); + + // Verify project still exists + const projectAfter = await service.getProject( + { + projectId: project.id, + }, + { credentials: credentials1 }, + ); + expect(projectAfter).toBeDefined(); + expect(projectAfter?.id).toBe(project.id); + }, + ); + + it.each(databases.eachSupportedId())( + 'should delete project when user deletes their own project - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials = mockCredentials.user('user:default/user1'); + + const project = await service.createProject( + { + name: 'My Project', + abbreviation: 'MP', + description: 'My own project', + }, + { credentials }, + ); + + // User should be able to delete their own project + const deletedCount = await service.deleteProject( + { + projectId: project.id, + }, + { credentials, canWriteAll: false }, + ); + + expect(deletedCount).toBe(1); + + // Verify project is deleted + const projectAfter = await service.getProject( + { + projectId: project.id, + }, + { credentials }, + ); + expect(projectAfter).toBeUndefined(); + }, + ); + + it.each(databases.eachSupportedId())( + 'should delete project when canWriteAll is true even if created by different user - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + // User1 creates a project + const project = await service.createProject( + { + name: 'User1 Project', + abbreviation: 'U1P', + description: 'Project created by user1', + }, + { credentials: credentials1 }, + ); + + // User2 with canWriteAll should be able to delete user1's project + const deletedCount = await service.deleteProject( + { + projectId: project.id, + }, + { credentials: credentials2, canWriteAll: true }, + ); + + expect(deletedCount).toBe(1); + + // Verify project is deleted + const projectAfter = await service.getProject( + { + projectId: project.id, + }, + { credentials: credentials1 }, + ); + expect(projectAfter).toBeUndefined(); + }, + ); + + it.each(databases.eachSupportedId())( + 'should filter by user when canWriteAll is undefined - %p', + async databaseId => { + const { client } = await createDatabase(databaseId); + const service = createService(client); + + const credentials1 = mockCredentials.user('user:default/user1'); + const credentials2 = mockCredentials.user('user:default/user2'); + + // User1 creates a project + const project = await service.createProject( + { + name: 'User1 Project', + abbreviation: 'U1P', + description: 'Project created by user1', + }, + { credentials: credentials1 }, + ); + + // When canWriteAll is undefined, should default to filtering + const deletedCount = await service.deleteProject( + { + projectId: project.id, + }, + { credentials: credentials2 }, + ); + + expect(deletedCount).toBe(0); + + // Verify project still exists + const projectAfter = await service.getProject( + { + projectId: project.id, + }, + { credentials: credentials1 }, + ); + expect(projectAfter).toBeDefined(); + expect(projectAfter?.id).toBe(project.id); + }, + ); }); describe('integration tests', () => { @@ -399,26 +1148,35 @@ describe('X2ADatabaseService', () => { ); // Read - const retrieved = await service.getProject({ projectId: project.id }); + const retrieved = await service.getProject( + { projectId: project.id }, + { credentials }, + ); expect(retrieved).toBeDefined(); expect(retrieved?.name).toBe('Lifecycle Test'); // List - const listResult = await service.listProjects(); + const listResult = await service.listProjects({}, { credentials }); expect(listResult.totalCount).toBe(1); expect(listResult.projects[0].id).toBe(project.id); // Delete - const deletedCount = await service.deleteProject({ - projectId: project.id, - }); + const deletedCount = await service.deleteProject( + { + projectId: project.id, + }, + { credentials }, + ); expect(deletedCount).toBe(1); // Verify deletion - const afterDelete = await service.getProject({ projectId: project.id }); + const afterDelete = await service.getProject( + { projectId: project.id }, + { credentials }, + ); expect(afterDelete).toBeUndefined(); - const finalList = await service.listProjects(); + const finalList = await service.listProjects({}, { credentials }); expect(finalList.totalCount).toBe(0); }, ); @@ -894,15 +1652,21 @@ describe('X2ADatabaseService', () => { expect(modulesBefore).toHaveLength(3); // Delete the project - const deletedCount = await service.deleteProject({ - projectId: project.id, - }); + const deletedCount = await service.deleteProject( + { + projectId: project.id, + }, + { credentials }, + ); expect(deletedCount).toBe(1); // Verify project is deleted - const projectAfter = await service.getProject({ - projectId: project.id, - }); + const projectAfter = await service.getProject( + { + projectId: project.id, + }, + { credentials }, + ); expect(projectAfter).toBeUndefined(); // Verify all modules are cascade deleted @@ -965,7 +1729,10 @@ describe('X2ADatabaseService', () => { }); // Delete project1 - await service.deleteProject({ projectId: project1.id }); + await service.deleteProject( + { projectId: project1.id }, + { credentials }, + ); // Verify project1 modules are cascade deleted expect( diff --git a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.ts b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.ts index 27597c67e5..094c13e7f2 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/services/X2ADatabaseService.ts @@ -26,6 +26,12 @@ import { import { Expand } from '@backstage/types'; import { Project } from '@red-hat-developer-hub/backstage-plugin-x2a-common'; import { Knex } from 'knex'; +import { ProjectsGet } from '../schema/openapi'; +import { + DEFAULT_PAGE_ORDER, + DEFAULT_PAGE_SIZE, + DEFAULT_PAGE_SORT, +} from '../constants'; // TODO: model via openapi schema export interface Module { @@ -74,6 +80,18 @@ export class X2ADatabaseService { }; } + // Map REST sort param to database column name + private mapSortToDatabaseColumn(sort?: string): string | undefined { + const mapping = { + createdAt: 'created_at', + createdBy: 'created_by', + finishedAt: 'finished_at', + startedAt: 'started_at', + }; + + return sort ? mapping[sort as keyof typeof mapping] || sort : undefined; + } + // Map a database row to a Module object private mapRowToModule(row: any): Module { return { @@ -96,6 +114,21 @@ export class X2ADatabaseService { }; } + // Filter query by user permissions + private filterPermissions( + queryBuilder: Knex.QueryBuilder, + canDoAll: boolean | undefined, + userEntityRef: string, + ): void { + if (!canDoAll) { + // Filter by the user who created the project. + // For admins, the WHERE clause is not applied so matching all records. + // If multiple non-admin users are allowed to do the action, we need to + // either pass their full list here or do the permission check outside of the DB + queryBuilder.where('created_by', userEntityRef); + } + } + async createProject( input: { name: string; @@ -135,38 +168,106 @@ export class X2ADatabaseService { return newProject; } - async listProjects(): Promise<{ projects: Project[]; totalCount: number }> { - this.#logger.info('listProjects called'); + async listProjects( + query: ProjectsGet['query'], + options: { + credentials: BackstageCredentials; + canViewAll?: boolean; + }, + ): Promise<{ projects: Project[]; totalCount: number }> { + const calledByUserRef = options.credentials.principal.userEntityRef; + this.#logger.info(`listProjects called by ${calledByUserRef}`); + const pageSize = query.pageSize || DEFAULT_PAGE_SIZE; // Fetch all records from the database const rows = await this.#dbClient('projects') + .limit(pageSize) + .offset((query.page || 0) * pageSize) .select('*') - .orderBy('created_at', 'desc'); + .modify(queryBuilder => + this.filterPermissions( + queryBuilder, + options.canViewAll, + calledByUserRef, + ), + ) + .orderBy( + this.mapSortToDatabaseColumn(query.sort) || DEFAULT_PAGE_SORT, + query.order || DEFAULT_PAGE_ORDER, + ); + + const totalCount = (await this.#dbClient('projects') + .count('*', { as: 'count' }) + .modify(queryBuilder => + this.filterPermissions( + queryBuilder, + options.canViewAll, + calledByUserRef, + ), + ) + .first()) as { count: number }; const projects: Project[] = rows.map(this.mapRowToProject); - const totalCount = projects.length; - this.#logger.debug(`Fetched ${totalCount} projects from database`); + this.#logger.debug( + `Fetched ${projects.length} out of ${totalCount.count} projects from database (permissions applied)`, + ); - return { projects, totalCount }; + return { projects, totalCount: totalCount.count }; } - async getProject({ - projectId, - }: { - projectId: string; - }): Promise { - this.#logger.info(`getProject called for projectId: ${projectId}`); - const row = await this.#dbClient('projects').where('id', projectId).first(); + async getProject( + { + projectId, + }: { + projectId: string; + }, + options: { + credentials: BackstageCredentials; + canViewAll?: boolean; + }, + ): Promise { + const calledByUserRef = options.credentials.principal.userEntityRef; + this.#logger.info( + `getProject called for projectId: ${projectId} by ${calledByUserRef}`, + ); + + const row = await this.#dbClient('projects') + .where('id', projectId) + .modify(queryBuilder => + this.filterPermissions( + queryBuilder, + options.canViewAll, + calledByUserRef, + ), + ) + .first(); + return row ? this.mapRowToProject(row) : undefined; } - async deleteProject({ projectId }: { projectId: string }): Promise { - this.#logger.info(`deleteProject called for projectId: ${projectId}`); + async deleteProject( + { projectId }: { projectId: string }, + options: { + credentials: BackstageCredentials; + canWriteAll?: boolean; + }, + ): Promise { + const calledByUserRef = options.credentials.principal.userEntityRef; + this.#logger.info( + `deleteProject called for projectId: ${projectId} by ${calledByUserRef}`, + ); // Delete from the database const deletedCount = await this.#dbClient('projects') .where('id', projectId) + .modify(queryBuilder => + this.filterPermissions( + queryBuilder, + options.canWriteAll, + calledByUserRef, + ), + ) .delete(); if (deletedCount === 0) { diff --git a/workspaces/x2a/plugins/x2a-backend/src/utils/delay.ts b/workspaces/x2a/plugins/x2a-backend/src/utils/delay.ts new file mode 100644 index 0000000000..d0b905867b --- /dev/null +++ b/workspaces/x2a/plugins/x2a-backend/src/utils/delay.ts @@ -0,0 +1,25 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Creates a promise that resolves after the specified number of milliseconds. + * + * @param ms - The number of milliseconds to wait before resolving + * @returns A promise that resolves after the specified delay + */ +export function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/workspaces/x2a/plugins/x2a-backend/src/utils/index.ts b/workspaces/x2a/plugins/x2a-backend/src/utils/index.ts index 4a17edc5cb..5b4465bcac 100644 --- a/workspaces/x2a/plugins/x2a-backend/src/utils/index.ts +++ b/workspaces/x2a/plugins/x2a-backend/src/utils/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export * from './toSorted'; +export * from './delay'; diff --git a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/apis/Api.client.ts b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/apis/Api.client.ts index 206d2bb9ef..85a1e36b1c 100644 --- a/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/apis/Api.client.ts +++ b/workspaces/x2a/plugins/x2a-common/client/src/schema/openapi/generated/apis/Api.client.ts @@ -51,6 +51,7 @@ export type ProjectsGet = { query: { page?: number; pageSize?: number; + order?: 'asc' | 'desc'; sort?: 'createdAt' | 'name' | 'description' | 'createdBy'; }; }; @@ -96,6 +97,7 @@ export class DefaultApiClient { * Returns a list of projects. * @param page - Page number * @param pageSize - Page size + * @param order - Sort order, either ascending (\"asc\") or descending (\"desc\") * @param sort - Sort by field */ public async projectsGet( @@ -105,7 +107,7 @@ export class DefaultApiClient { ): Promise> { const baseUrl = await this.discoveryApi.getBaseUrl(pluginId); - const uriTemplate = `/projects{?page,pageSize,sort}`; + const uriTemplate = `/projects{?page,pageSize,order,sort}`; const uri = parser.parse(uriTemplate).expand({ ...request.query, diff --git a/workspaces/x2a/plugins/x2a-common/package.json b/workspaces/x2a/plugins/x2a-common/package.json index 3ae68373e9..0ca3e0a8fb 100644 --- a/workspaces/x2a/plugins/x2a-common/package.json +++ b/workspaces/x2a/plugins/x2a-common/package.json @@ -46,5 +46,8 @@ "@mareklibra", "@elai-shalev", "@eloycoto" - ] + ], + "dependencies": { + "@backstage/plugin-permission-common": "^0.9.4" + } } diff --git a/workspaces/x2a/plugins/x2a-common/report.api.md b/workspaces/x2a/plugins/x2a-common/report.api.md index 36c0d7c16b..6ad86177c3 100644 --- a/workspaces/x2a/plugins/x2a-common/report.api.md +++ b/workspaces/x2a/plugins/x2a-common/report.api.md @@ -4,6 +4,8 @@ ```ts +import { BasicPermission } from '@backstage/plugin-permission-common'; + // @public (undocumented) export class DefaultApiClient { constructor(options: { @@ -35,6 +37,7 @@ export type ProjectsGet = { query: { page?: number; pageSize?: number; + order?: 'asc' | 'desc'; sort?: 'createdAt' | 'name' | 'description' | 'createdBy'; }; }; @@ -87,4 +90,16 @@ export type TypedResponse = Omit & { json: () => Promise; }; +// @public +export const x2aAdminViewPermission: BasicPermission; + +// @public +export const x2aAdminWritePermission: BasicPermission; + +// @public +export const x2aPermissions: BasicPermission[]; + +// @public +export const x2aUserPermission: BasicPermission; + ``` diff --git a/workspaces/x2a/plugins/x2a-common/src/index.ts b/workspaces/x2a/plugins/x2a-common/src/index.ts index 3a1b21dcc5..a1072529b3 100644 --- a/workspaces/x2a/plugins/x2a-common/src/index.ts +++ b/workspaces/x2a/plugins/x2a-common/src/index.ts @@ -20,3 +20,4 @@ * @packageDocumentation */ export * from '../client/src/schema/openapi'; +export * from './permissions'; diff --git a/workspaces/x2a/plugins/x2a-common/src/permissions.ts b/workspaces/x2a/plugins/x2a-common/src/permissions.ts new file mode 100644 index 0000000000..122d7c120d --- /dev/null +++ b/workspaces/x2a/plugins/x2a-common/src/permissions.ts @@ -0,0 +1,62 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createPermission } from '@backstage/plugin-permission-common'; + +/** + * Can view (read-only) all x2a projects and modules. + * + * @public + */ +export const x2aAdminViewPermission = createPermission({ + name: 'x2a.admin', + attributes: { + action: 'read', + }, +}); + +/** + * Can create, update and delete x2a projects and modules. + * + * @public + */ +export const x2aAdminWritePermission = createPermission({ + name: 'x2a.admin', + attributes: { + action: 'update', + }, +}); + +/** + * Can view and manage (read-write) x2a projects created by the user. + * + * @public + */ +export const x2aUserPermission = createPermission({ + name: 'x2a.user', + attributes: {}, +}); + +/** + * All x2a permissions. + * + * @public + */ +export const x2aPermissions = [ + x2aAdminViewPermission, + x2aAdminWritePermission, + x2aUserPermission, +]; diff --git a/workspaces/x2a/yarn.lock b/workspaces/x2a/yarn.lock index a952b7aa1e..3e4bf96157 100644 --- a/workspaces/x2a/yarn.lock +++ b/workspaces/x2a/yarn.lock @@ -7831,6 +7831,7 @@ __metadata: resolution: "@red-hat-developer-hub/backstage-plugin-x2a-common@workspace:plugins/x2a-common" dependencies: "@backstage/cli": ^0.34.5 + "@backstage/plugin-permission-common": ^0.9.4 cross-fetch: ^4.1.0 uri-template: ^2.0.0 languageName: unknown From 8cafda6a41aaedcecfb6229949d941a265f77167 Mon Sep 17 00:00:00 2001 From: Marek Libra Date: Mon, 26 Jan 2026 14:02:04 +0100 Subject: [PATCH 2/2] Add chores to package.json --- workspaces/x2a/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/workspaces/x2a/package.json b/workspaces/x2a/package.json index a3d7756f76..3f44187d84 100644 --- a/workspaces/x2a/package.json +++ b/workspaces/x2a/package.json @@ -23,6 +23,7 @@ "lint:all": "backstage-cli repo lint", "prettier:check": "prettier --check .", "prettier:fix": "prettier --write .", + "chores": "yarn prettier:fix && yarn lint:all --fix && yarn tsc:full && yarn build:api-reports && yarn test:all", "new": "backstage-cli new --scope @red-hat-developer-hub", "postinstall": "cd ../../ && yarn install" },