diff --git a/cypress/e2e/repo.cy.js b/cypress/e2e/repo.cy.js index a69372848..39f5dfa6c 100644 --- a/cypress/e2e/repo.cy.js +++ b/cypress/e2e/repo.cy.js @@ -176,4 +176,48 @@ describe('Repo', () => { }); }); }); + + describe('Pagination and search', () => { + beforeEach(() => { + cy.login('admin', 'admin'); + cy.visit('/dashboard/repo'); + }); + + it('sends search param to API when typing in search box', () => { + cy.intercept('GET', '**/api/v1/repo*').as('initialLoad'); + cy.wait('@initialLoad'); + + cy.intercept('GET', '**/api/v1/repo*').as('searchRequest'); + cy.get('input[type="text"]').first().type('finos'); + + cy.wait('@searchRequest').its('request.url').should('include', 'search=finos'); + }); + + it('sends page=2 to API when clicking Next', () => { + cy.intercept('GET', '**/api/v1/repo*').as('getRepos'); + + cy.wait('@getRepos'); + + cy.contains('button', 'Next').then(($btn) => { + if (!$btn.is(':disabled')) { + cy.intercept('GET', '**/api/v1/repo*').as('getReposPage2'); + cy.contains('button', 'Next').click(); + cy.wait('@getReposPage2').its('request.url').should('include', 'page=2'); + } + }); + }); + + it('sends updated limit to API when changing items per page', () => { + cy.intercept('GET', '**/api/v1/repo*').as('getRepos'); + + cy.wait('@getRepos'); + + cy.intercept('GET', '**/api/v1/repo*').as('getReposLimited'); + + cy.get('.paginationContainer').find('.MuiSelect-root').click(); + cy.get('[data-value="25"]').click(); + + cy.wait('@getReposLimited').its('request.url').should('include', 'limit=25'); + }); + }); }); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index e852c0a28..f660598ab 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -134,18 +134,17 @@ Cypress.Commands.add('getTestRepoId', () => { `GET ${url} returned status ${res.status}: ${JSON.stringify(res.body).slice(0, 500)}`, ); } - if (!Array.isArray(res.body)) { + const repos = res.body.repos ?? res.body.data ?? res.body; + if (!Array.isArray(repos)) { throw new Error( - `GET ${url} returned non-array (${typeof res.body}): ${JSON.stringify(res.body).slice(0, 500)}`, + `GET ${url} returned unexpected shape: ${JSON.stringify(res.body).slice(0, 500)}`, ); } const gitServerTarget = Cypress.env('GIT_SERVER_TARGET') || 'git-server:8443'; - const repo = res.body.find( - (r) => r.url === `https://${gitServerTarget}/test-owner/test-repo.git`, - ); + const repo = repos.find((r) => r.url === `https://${gitServerTarget}/test-owner/test-repo.git`); if (!repo) { throw new Error( - `test-owner/test-repo not found in database. Repos: ${res.body.map((r) => r.url).join(', ')}`, + `test-owner/test-repo not found in database. Repos: ${repos.map((r) => r.url).join(', ')}`, ); } return cy.wrap(repo._id); @@ -159,8 +158,9 @@ Cypress.Commands.add('cleanupTestRepos', () => { url: `${getApiBaseUrl()}/api/v1/repo`, failOnStatusCode: false, }).then((res) => { - if (res.status !== 200 || !Array.isArray(res.body)) return; - const testRepos = res.body.filter((r) => r.project === 'cypress-test'); + const repos = res.body.repos ?? res.body.data ?? res.body; + if (res.status !== 200 || !Array.isArray(repos)) return; + const testRepos = repos.filter((r) => r.project === 'cypress-test'); testRepos.forEach((repo) => { cy.request({ method: 'DELETE', diff --git a/packages/git-proxy-cli/index.ts b/packages/git-proxy-cli/index.ts index 5f3b8e90c..0f16b9d4f 100644 --- a/packages/git-proxy-cli/index.ts +++ b/packages/git-proxy-cli/index.ts @@ -106,12 +106,15 @@ async function getGitPushes(filters: Partial) { try { const cookies = JSON.parse(fs.readFileSync(GIT_PROXY_COOKIE_FILE, 'utf8')); - const { data } = await axios.get(`${baseUrl}/api/v1/push/`, { - headers: { Cookie: cookies }, - params: filters, - }); + const response = await axios.get<{ pushes: Action[]; total: number }>( + `${baseUrl}/api/v1/push/`, + { + headers: { Cookie: cookies }, + params: filters, + }, + ); - const records = data.map((push: Action) => { + const records = response.data.pushes.map((push: Action) => { const { id, repo, diff --git a/src/db/file/helper.ts b/src/db/file/helper.ts index 039b7304e..1ccf61eed 100644 --- a/src/db/file/helper.ts +++ b/src/db/file/helper.ts @@ -15,8 +15,42 @@ */ import { existsSync, mkdirSync } from 'fs'; +import Datastore from '@seald-io/nedb'; +import { PaginatedResult } from '../types'; export const getSessionStore = (): undefined => undefined; export const initializeFolders = () => { if (!existsSync('./.data/db')) mkdirSync('./.data/db', { recursive: true }); }; + +export const paginatedFind = ( + db: Datastore, + filter: Record, + sort: Record, + skip: number, + limit: number, +): Promise> => { + const countPromise = new Promise((resolve, reject) => { + db.count(filter as any, (err: Error | null, count: number) => { + /* istanbul ignore if */ + if (err) reject(err); + else resolve(count); + }); + }); + + const dataPromise = new Promise((resolve, reject) => { + db.find(filter as any) + .sort(sort) + .skip(skip) + .limit(limit) + .exec((err: Error | null, docs: any[]) => { + /* istanbul ignore if */ + if (err) reject(err); + else resolve(docs); + }); + }); + + return limit > 0 + ? Promise.all([dataPromise, countPromise]).then(([data, total]) => ({ data, total })) + : dataPromise.then((data) => ({ data, total: data.length })); +}; diff --git a/src/db/file/pushes.ts b/src/db/file/pushes.ts index 006a8be9b..d0337a391 100644 --- a/src/db/file/pushes.ts +++ b/src/db/file/pushes.ts @@ -17,8 +17,9 @@ import _ from 'lodash'; import Datastore from '@seald-io/nedb'; import { Action } from '../../proxy/actions/Action'; -import { toClass } from '../helper'; -import { PushQuery } from '../types'; +import { toClass, buildSearchFilter, buildSort } from '../helper'; +import { paginatedFind } from './helper'; +import { PaginatedResult, PaginationOptions, PushQuery } from '../types'; import { CompletedAttestation, Rejection } from '../../proxy/processors/types'; import { handleErrorAndLog } from '../../utils/errors'; @@ -49,25 +50,33 @@ const defaultPushQuery: Partial = { type: 'push', }; -export const getPushes = (query: Partial): Promise => { +export const getPushes = ( + query: Partial, + pagination?: PaginationOptions, +): Promise> => { if (!query) query = defaultPushQuery; - return new Promise((resolve, reject) => { - db.find(query) - .sort({ timestamp: -1 }) - .exec((err, docs) => { - // ignore for code coverage as neDB rarely returns errors even for an invalid query - /* istanbul ignore if */ - if (err) { - reject(err); - } else { - resolve( - _.chain(docs) - .map((x) => toClass(x, Action.prototype)) - .value(), - ); - } - }); - }); + + const baseQuery = buildSearchFilter( + { ...query }, + ['repo', 'branch', 'commitTo', 'user'], + pagination?.search, + ); + const sort = buildSort(pagination, 'timestamp', -1, [ + 'timestamp', + 'repo', + 'branch', + 'commitTo', + 'user', + ]); + const skip = pagination?.skip ?? 0; + const limit = pagination?.limit ?? 0; + + return paginatedFind(db, baseQuery, sort, skip, limit).then(({ data, total }) => ({ + data: _.chain(data) + .map((x) => toClass(x, Action.prototype)) + .value(), + total, + })); }; export const getPush = async (id: string): Promise => { @@ -157,5 +166,5 @@ export const cancel = async (id: string): Promise<{ message: string }> => { action.canceled = true; action.rejected = false; await writeAudit(action); - return { message: `cancel ${id}` }; + return { message: `canceled ${id}` }; }; diff --git a/src/db/file/repo.ts b/src/db/file/repo.ts index 76cc8be9b..0795f559d 100644 --- a/src/db/file/repo.ts +++ b/src/db/file/repo.ts @@ -17,8 +17,9 @@ import Datastore from '@seald-io/nedb'; import _ from 'lodash'; -import { Repo, RepoQuery } from '../types'; -import { toClass } from '../helper'; +import { PaginatedResult, PaginationOptions, Repo, RepoQuery } from '../types'; +import { toClass, buildSearchFilter, buildSort } from '../helper'; +import { paginatedFind } from './helper'; import { handleErrorAndLog } from '../../utils/errors'; const COMPACTION_INTERVAL = 1000 * 60 * 60 * 24; // once per day @@ -42,25 +43,25 @@ try { db.ensureIndex({ fieldName: 'name', unique: false }); db.setAutocompactionInterval(COMPACTION_INTERVAL); -export const getRepos = async (query: Partial = {}): Promise => { +export const getRepos = async ( + query: Partial = {}, + pagination?: PaginationOptions, +): Promise> => { if (query?.name) { query.name = query.name.toLowerCase(); } - return new Promise((resolve, reject) => { - db.find(query, (err: Error, docs: Repo[]) => { - // ignore for code coverage as neDB rarely returns errors even for an invalid query - /* istanbul ignore if */ - if (err) { - reject(err); - } else { - resolve( - _.chain(docs) - .map((x) => toClass(x, Repo.prototype)) - .value(), - ); - } - }); - }); + + const baseQuery = buildSearchFilter({ ...query }, ['name', 'project', 'url'], pagination?.search); + const sort = buildSort(pagination, 'name', 1, ['name', 'project', 'url']); + const skip = pagination?.skip ?? 0; + const limit = pagination?.limit ?? 0; + + return paginatedFind(db, baseQuery, sort, skip, limit).then(({ data, total }) => ({ + data: _.chain(data) + .map((x) => toClass(x, Repo.prototype)) + .value(), + total, + })); }; export const getRepo = async (name: string): Promise => { diff --git a/src/db/file/users.ts b/src/db/file/users.ts index a277d1e10..d36e97fc8 100644 --- a/src/db/file/users.ts +++ b/src/db/file/users.ts @@ -17,8 +17,10 @@ import fs from 'fs'; import Datastore from '@seald-io/nedb'; -import { User, UserQuery } from '../types'; +import { PaginatedResult, PaginationOptions, User, UserQuery } from '../types'; import { handleErrorAndLog } from '../../utils/errors'; +import { buildSearchFilter, buildSort } from '../helper'; +import { paginatedFind } from './helper'; const COMPACTION_INTERVAL = 1000 * 60 * 60 * 24; // once per day @@ -180,22 +182,30 @@ export const updateUser = (user: Partial): Promise => { }); }; -export const getUsers = (query: Partial = {}): Promise => { +export const getUsers = ( + query: Partial = {}, + pagination?: PaginationOptions, +): Promise> => { if (query.username) { query.username = query.username.toLowerCase(); } if (query.email) { query.email = query.email.toLowerCase(); } - return new Promise((resolve, reject) => { - db.find(query, (err: Error, docs: User[]) => { - // ignore for code coverage as neDB rarely returns errors even for an invalid query - /* istanbul ignore if */ - if (err) { - reject(err); - } else { - resolve(docs); - } - }); - }); + + const baseQuery = buildSearchFilter( + { ...query }, + ['username', 'displayName', 'email', 'gitAccount'], + pagination?.search, + ); + const sort = buildSort(pagination, 'username', 1, [ + 'username', + 'displayName', + 'email', + 'gitAccount', + ]); + const skip = pagination?.skip ?? 0; + const limit = pagination?.limit ?? 0; + + return paginatedFind(db, baseQuery, sort, skip, limit); }; diff --git a/src/db/helper.ts b/src/db/helper.ts index 35ef81180..ec98264fb 100644 --- a/src/db/helper.ts +++ b/src/db/helper.ts @@ -14,6 +14,36 @@ * limitations under the License. */ +import { PaginationOptions } from './types'; + +export const escapeRegex = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +export const buildSearchFilter = ( + baseQuery: Record, + searchFields: string[], + search?: string, +): Record => { + if (!search) return baseQuery; + const regex = new RegExp(escapeRegex(search), 'i'); + return { ...baseQuery, $or: searchFields.map((f) => ({ [f]: regex })) }; +}; + +export const buildSort = ( + pagination: PaginationOptions | undefined, + defaultField: string, + defaultDir: 1 | -1, + allowedFields?: string[], +): Record => { + const requestedField = pagination?.sortBy; + const field = + requestedField && (!allowedFields || allowedFields.includes(requestedField)) + ? requestedField + : defaultField; + const dir = + pagination?.sortOrder === 'asc' ? 1 : pagination?.sortOrder === 'desc' ? -1 : defaultDir; + return { [field]: dir }; +}; + export const toClass = function (obj: T, proto: U): U { const out = JSON.parse(JSON.stringify(obj)); out.__proto__ = proto; diff --git a/src/db/index.ts b/src/db/index.ts index f9048fb3b..96c62528c 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -15,7 +15,16 @@ */ import { AuthorisedRepo } from '../config/generated/config'; -import { PushQuery, Repo, RepoQuery, Sink, User, UserQuery } from './types'; +import { + PaginatedResult, + PaginationOptions, + PushQuery, + Repo, + RepoQuery, + Sink, + User, + UserQuery, +} from './types'; import * as bcrypt from 'bcryptjs'; import * as config from '../config'; import * as mongo from './mongo'; @@ -177,7 +186,10 @@ export const canUserCancelPush = async (id: string, user: string) => { }; export const getSessionStore = (): MongoDBStore | undefined => start().getSessionStore(); -export const getPushes = (query: Partial): Promise => start().getPushes(query); +export const getPushes = ( + query: Partial, + pagination?: PaginationOptions, +): Promise> => start().getPushes(query, pagination); export const writeAudit = (action: Action): Promise => start().writeAudit(action); export const getPush = (id: string): Promise => start().getPush(id); export const deletePush = (id: string): Promise => start().deletePush(id); @@ -188,7 +200,10 @@ export const authorise = ( export const cancel = (id: string): Promise<{ message: string }> => start().cancel(id); export const reject = (id: string, rejection: Rejection): Promise<{ message: string }> => start().reject(id, rejection); -export const getRepos = (query?: Partial): Promise => start().getRepos(query); +export const getRepos = ( + query?: Partial, + pagination?: PaginationOptions, +): Promise> => start().getRepos(query, pagination); export const getRepo = (name: string): Promise => start().getRepo(name); export const getRepoByUrl = (url: string): Promise => start().getRepoByUrl(url); export const getRepoById = (_id: string): Promise => start().getRepoById(_id); @@ -206,7 +221,10 @@ export const findUserByEmail = (email: string): Promise => start().findUserByEmail(email); export const findUserByOIDC = (oidcId: string): Promise => start().findUserByOIDC(oidcId); -export const getUsers = (query?: Partial): Promise => start().getUsers(query); +export const getUsers = ( + query?: Partial, + pagination?: PaginationOptions, +): Promise> => start().getUsers(query, pagination); export const deleteUser = (username: string): Promise => start().deleteUser(username); export const updateUser = (user: Partial): Promise => start().updateUser(user); @@ -218,7 +236,7 @@ export const updateUser = (user: Partial): Promise => start().update */ export const getAllProxiedHosts = async (): Promise => { - const repos = await getRepos(); + const { data: repos } = await getRepos(); const origins = new Set(); repos.forEach((repo) => { const parsedUrl = processGitUrl(repo.url); @@ -229,4 +247,4 @@ export const getAllProxiedHosts = async (): Promise => { return Array.from(origins); }; -export type { PushQuery, Repo, Sink, User } from './types'; +export type { PaginatedResult, PaginationOptions, PushQuery, Repo, Sink, User } from './types'; diff --git a/src/db/mongo/helper.ts b/src/db/mongo/helper.ts index 6abc33d21..91aced03e 100644 --- a/src/db/mongo/helper.ts +++ b/src/db/mongo/helper.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import { MongoClient, Db, Collection, Filter, Document, FindOptions } from 'mongodb'; +import { MongoClient, Db, Collection, Filter, Document, FindOptions, Sort } from 'mongodb'; +import { PaginatedResult } from '../types'; import { getDatabase } from '../../config'; import MongoDBStore from 'connect-mongo'; import { fromNodeProviderChain } from '@aws-sdk/credential-providers'; @@ -56,6 +57,23 @@ export const connect = async (collectionName: string): Promise => { return _db.collection(collectionName); }; +export const paginatedFind = async ( + collection: Collection, + filter: Filter, + sort: Sort, + skip: number, + limit: number, + projection?: Document, +): Promise> => { + const countPromise = limit > 0 ? collection.countDocuments(filter) : Promise.resolve(null); + const cursor = collection.find(filter, projection ? { projection } : undefined).sort(sort); + if (skip) cursor.skip(skip); + if (limit) cursor.limit(limit); + const [data, count] = await Promise.all([cursor.toArray() as Promise, countPromise]); + const total = count ?? data.length; + return { data, total }; +}; + export const findDocuments = async ( collectionName: string, filter: Filter = {}, diff --git a/src/db/mongo/pushes.ts b/src/db/mongo/pushes.ts index 91893cdb8..8e5898e7c 100644 --- a/src/db/mongo/pushes.ts +++ b/src/db/mongo/pushes.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { connect, findDocuments, findOneDocument } from './helper'; +import { connect, findOneDocument, paginatedFind } from './helper'; import { Action } from '../../proxy/actions'; -import { toClass } from '../helper'; -import { PushQuery } from '../types'; +import { toClass, buildSearchFilter, buildSort } from '../helper'; +import { PaginatedResult, PaginationOptions, PushQuery } from '../types'; import { CompletedAttestation, Rejection } from '../../proxy/processors/types'; const collectionName = 'pushes'; @@ -30,34 +30,51 @@ const defaultPushQuery: Partial = { type: 'push', }; +const pushProjection = { + _id: 0, + id: 1, + allowPush: 1, + authorised: 1, + blocked: 1, + blockedMessage: 1, + branch: 1, + canceled: 1, + commitData: 1, + commitFrom: 1, + commitTo: 1, + error: 1, + method: 1, + project: 1, + rejected: 1, + repo: 1, + repoName: 1, + timestamp: 1, + type: 1, + url: 1, + user: 1, +}; + export const getPushes = async ( query: Partial = defaultPushQuery, -): Promise => { - return findDocuments(collectionName, query, { - projection: { - _id: 0, - id: 1, - allowPush: 1, - authorised: 1, - blocked: 1, - blockedMessage: 1, - branch: 1, - canceled: 1, - commitData: 1, - commitFrom: 1, - commitTo: 1, - error: 1, - method: 1, - project: 1, - rejected: 1, - repo: 1, - repoName: 1, - timestamp: 1, - type: 1, - url: 1, - }, - sort: { timestamp: -1 }, - }); + pagination?: PaginationOptions, +): Promise> => { + const filter = buildSearchFilter( + { ...query }, + ['repo', 'branch', 'commitTo', 'user'], + pagination?.search, + ); + const sort = buildSort(pagination, 'timestamp', -1, [ + 'timestamp', + 'repo', + 'branch', + 'commitTo', + 'user', + ]); + const skip = pagination?.skip ?? 0; + const limit = pagination?.limit ?? 0; + + const collection = await connect(collectionName); + return paginatedFind(collection, filter, sort, skip, limit, pushProjection); }; export const getPush = async (id: string): Promise => { diff --git a/src/db/mongo/repo.ts b/src/db/mongo/repo.ts index b2ec4f4df..bd0f49c49 100644 --- a/src/db/mongo/repo.ts +++ b/src/db/mongo/repo.ts @@ -15,18 +15,30 @@ */ import _ from 'lodash'; -import { Repo, RepoQuery } from '../types'; -import { connect } from './helper'; -import { toClass } from '../helper'; +import { PaginatedResult, PaginationOptions, Repo, RepoQuery } from '../types'; +import { connect, paginatedFind } from './helper'; +import { toClass, buildSearchFilter, buildSort } from '../helper'; import { ObjectId, OptionalId, Document } from 'mongodb'; + const collectionName = 'repos'; -export const getRepos = async (query: Partial = {}): Promise => { +export const getRepos = async ( + query: Partial = {}, + pagination?: PaginationOptions, +): Promise> => { + const filter = buildSearchFilter({ ...query }, ['name', 'project', 'url'], pagination?.search); + const sort = buildSort(pagination, 'name', 1, ['name', 'project', 'url']); + const skip = pagination?.skip ?? 0; + const limit = pagination?.limit ?? 0; + const collection = await connect(collectionName); - const docs = await collection.find(query).toArray(); - return _.chain(docs) - .map((x) => toClass(x, Repo.prototype)) - .value(); + const { data, total } = await paginatedFind(collection, filter, sort, skip, limit); + return { + data: _.chain(data) + .map((x) => toClass(x, Repo.prototype)) + .value(), + total, + }; }; export const getRepo = async (name: string): Promise => { diff --git a/src/db/mongo/users.ts b/src/db/mongo/users.ts index 96746fe6a..1fe34530f 100644 --- a/src/db/mongo/users.ts +++ b/src/db/mongo/users.ts @@ -15,10 +15,11 @@ */ import { OptionalId, Document, ObjectId } from 'mongodb'; -import { toClass } from '../helper'; -import { User, UserQuery } from '../types'; -import { connect } from './helper'; +import { toClass, buildSearchFilter, buildSort } from '../helper'; +import { PaginatedResult, PaginationOptions, User, UserQuery } from '../types'; +import { connect, paginatedFind } from './helper'; import _ from 'lodash'; + const collectionName = 'users'; export const findUser = async function (username: string): Promise { @@ -39,19 +40,42 @@ export const findUserByOIDC = async function (oidcId: string): Promise = {}): Promise { +export const getUsers = async function ( + query: Partial = {}, + pagination?: PaginationOptions, +): Promise> { if (query.username) { query.username = query.username.toLowerCase(); } if (query.email) { query.email = query.email.toLowerCase(); } - console.log(`Getting users for query = ${JSON.stringify(query)}`); + + const filter = buildSearchFilter( + { ...query }, + ['username', 'displayName', 'email', 'gitAccount'], + pagination?.search, + ); + const sort = buildSort(pagination, 'username', 1, [ + 'username', + 'displayName', + 'email', + 'gitAccount', + ]); + const skip = pagination?.skip ?? 0; + const limit = pagination?.limit ?? 0; + + console.log(`Getting users for query = ${JSON.stringify(filter)}`); const collection = await connect(collectionName); - const docs = await collection.find(query).project({ password: 0 }).toArray(); - return _.chain(docs) - .map((x) => toClass(x, User.prototype)) - .value(); + const { data, total } = await paginatedFind(collection, filter, sort, skip, limit, { + password: 0, + }); + return { + data: _.chain(data) + .map((x) => toClass(x, User.prototype)) + .value(), + total, + }; }; export const deleteUser = async function (username: string): Promise { diff --git a/src/db/types.ts b/src/db/types.ts index 74ead38b5..e99c7e0cd 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -46,6 +46,19 @@ export type QueryValue = string | boolean | number | undefined; export type UserRole = 'canPush' | 'canAuthorise'; +export type PaginationOptions = { + limit: number; + skip: number; + search?: string; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; +}; + +export type PaginatedResult = { + data: T[]; + total: number; +}; + export class Repo { project: string; name: string; @@ -109,14 +122,20 @@ export interface PublicUser { export interface Sink { getSessionStore: () => MongoDBStore | undefined; - getPushes: (query: Partial) => Promise; + getPushes: ( + query: Partial, + pagination?: PaginationOptions, + ) => Promise>; writeAudit: (action: Action) => Promise; getPush: (id: string) => Promise; deletePush: (id: string) => Promise; authorise: (id: string, attestation?: CompletedAttestation) => Promise<{ message: string }>; cancel: (id: string) => Promise<{ message: string }>; reject: (id: string, rejection: Rejection) => Promise<{ message: string }>; - getRepos: (query?: Partial) => Promise; + getRepos: ( + query?: Partial, + pagination?: PaginationOptions, + ) => Promise>; getRepo: (name: string) => Promise; getRepoByUrl: (url: string) => Promise; getRepoById: (_id: string) => Promise; @@ -129,7 +148,10 @@ export interface Sink { findUser: (username: string) => Promise; findUserByEmail: (email: string) => Promise; findUserByOIDC: (oidcId: string) => Promise; - getUsers: (query?: Partial) => Promise; + getUsers: ( + query?: Partial, + pagination?: PaginationOptions, + ) => Promise>; createUser: (user: User) => Promise; deleteUser: (username: string) => Promise; updateUser: (user: Partial) => Promise; diff --git a/src/proxy/index.ts b/src/proxy/index.ts index a15a594fb..37602bebc 100644 --- a/src/proxy/index.ts +++ b/src/proxy/index.ts @@ -75,7 +75,7 @@ export class Proxy { chain.chainPluginLoader = pluginLoader; // Check to see if the default repos are in the repo list const defaultAuthorisedRepoList = getAuthorisedList(); - const allowedList: Repo[] = await getRepos(); + const { data: allowedList } = await getRepos(); for (const defaultRepo of defaultAuthorisedRepoList) { const found = allowedList.find((configuredRepo) => configuredRepo.url === defaultRepo.url); diff --git a/src/proxy/processors/push-action/checkUserPushPermission.ts b/src/proxy/processors/push-action/checkUserPushPermission.ts index 934176586..c16915274 100644 --- a/src/proxy/processors/push-action/checkUserPushPermission.ts +++ b/src/proxy/processors/push-action/checkUserPushPermission.ts @@ -46,7 +46,7 @@ const validateUser = async (userEmail: string, action: Action, step: Step): Prom let isUserAllowed = false; // Find the user associated with this email address - const list = await getUsers({ email: userEmail }); + const { data: list } = await getUsers({ email: userEmail }); if (list.length > 1) { step.error = true; diff --git a/src/service/routes/push.ts b/src/service/routes/push.ts index 1e900d6ea..abee61dfd 100644 --- a/src/service/routes/push.ts +++ b/src/service/routes/push.ts @@ -20,6 +20,7 @@ import { PushQuery } from '../../db/types'; import { AttestationConfig } from '../../config/generated/config'; import { getAttestationConfig } from '../../config'; import { AttestationAnswer, Rejection } from '../../proxy/processors/types'; +import { parsePaginationParams } from './utils'; interface AuthoriseRequest { params: { @@ -30,22 +31,20 @@ interface AuthoriseRequest { const router = express.Router(); router.get('/', async (req: Request, res: Response) => { - const query: Partial = { - type: 'push', - }; + const query: Partial = { type: 'push' }; + const pagination = parsePaginationParams(req); for (const key in req.query) { - if (!key) continue; - if (key === 'limit' || key === 'skip') continue; - - const rawValue = req.query[key]; + if (!key || ['page', 'limit', 'search', 'sortBy', 'sortOrder'].includes(key)) continue; + const rawValue = req.query[key] as string; let parsedValue: boolean | undefined; if (rawValue === 'false') parsedValue = false; if (rawValue === 'true') parsedValue = true; - query[key] = parsedValue ?? rawValue?.toString(); + query[key] = parsedValue ?? rawValue; } - res.send(await db.getPushes(query)); + const { data, total } = await db.getPushes(query, pagination); + res.send({ pushes: data, total }); }); router.get('/:id', async (req: Request<{ id: string }>, res: Response) => { @@ -85,7 +84,7 @@ router.post('/:id/reject', async (req: Request<{ id: string }>, res: Response) = // Get the committer of the push via their email const committerEmail = push.userEmail; - const list = await db.getUsers({ email: committerEmail }); + const { data: list } = await db.getUsers({ email: committerEmail }); if (list.length === 0) { res.status(404).send({ @@ -104,7 +103,11 @@ router.post('/:id/reject', async (req: Request<{ id: string }>, res: Response) = const isAllowed = await db.canUserApproveRejectPush(id, username); if (isAllowed) { - const reviewerList = await db.getUsers({ username }); + const { data: reviewerList } = await db.getUsers({ username }); + if (reviewerList.length === 0) { + res.status(404).send({ message: `Reviewer ${username} not found` }); + return; + } const reviewerEmail = reviewerList[0].email; if (!reviewerEmail) { @@ -171,7 +174,7 @@ router.post( // Get the committer of the push via their email address const committerEmail = push.userEmail; - const list = await db.getUsers({ email: committerEmail }); + const { data: list } = await db.getUsers({ email: committerEmail }); if (list.length === 0) { res.status(404).send({ @@ -193,7 +196,11 @@ router.post( if (isAllowed) { console.log(`User ${username} approved push request for ${id}`); - const reviewerList = await db.getUsers({ username }); + const { data: reviewerList } = await db.getUsers({ username }); + if (reviewerList.length === 0) { + res.status(404).send({ message: `Reviewer ${username} not found` }); + return; + } const reviewerEmail = reviewerList[0].email; if (!reviewerEmail) { diff --git a/src/service/routes/repo.ts b/src/service/routes/repo.ts index 7e259d0aa..4a237a47e 100644 --- a/src/service/routes/repo.ts +++ b/src/service/routes/repo.ts @@ -20,7 +20,7 @@ import * as db from '../../db'; import { getProxyURL } from '../urls'; import { getAllProxiedHosts } from '../../db'; import { RepoQuery } from '../../db/types'; -import { isAdminUser } from './utils'; +import { isAdminUser, parsePaginationParams } from './utils'; import { Proxy } from '../../proxy'; import { handleErrorAndLog } from '../../utils/errors'; @@ -30,20 +30,19 @@ function repo(proxy: Proxy) { router.get('/', async (req: Request, res: Response) => { const proxyURL = getProxyURL(req); const query: Partial = {}; + const pagination = parsePaginationParams(req); for (const key in req.query) { - if (!key) continue; - if (key === 'limit' || key === 'skip') continue; - - const rawValue = req.query[key]; + if (!key || ['page', 'limit', 'search', 'sortBy', 'sortOrder'].includes(key)) continue; + const rawValue = req.query[key] as string; let parsedValue: boolean | undefined; if (rawValue === 'false') parsedValue = false; if (rawValue === 'true') parsedValue = true; - query[key] = parsedValue ?? rawValue?.toString(); + query[key] = parsedValue ?? rawValue; } - const qd = await db.getRepos(query); - res.send(qd.map((d) => ({ ...d, proxyURL }))); + const { data, total } = await db.getRepos(query, pagination); + res.send({ repos: data.map((d) => ({ ...d, proxyURL })), total }); }); router.get('/:id', async (req: Request<{ id: string }>, res: Response) => { @@ -62,6 +61,10 @@ function repo(proxy: Proxy) { } const _id = req.params.id; + if (!req.body.username || typeof req.body.username !== 'string') { + res.status(400).send({ error: 'Username is required' }); + return; + } const username = req.body.username.toLowerCase(); const user = await db.findUser(username); @@ -83,7 +86,11 @@ function repo(proxy: Proxy) { } const _id = req.params.id; - const username = req.body.username; + if (!req.body.username || typeof req.body.username !== 'string') { + res.status(400).send({ error: 'Username is required' }); + return; + } + const username = req.body.username.toLowerCase(); const user = await db.findUser(username); if (!user) { diff --git a/src/service/routes/users.ts b/src/service/routes/users.ts index 7a20307e9..9b4b70107 100644 --- a/src/service/routes/users.ts +++ b/src/service/routes/users.ts @@ -18,12 +18,13 @@ import express, { Request, Response } from 'express'; const router = express.Router(); import * as db from '../../db'; -import { toPublicUser } from './utils'; +import { toPublicUser, parsePaginationParams } from './utils'; router.get('/', async (req: Request, res: Response) => { console.log('fetching users'); - const users = await db.getUsers(); - res.send(users.map(toPublicUser)); + const pagination = parsePaginationParams(req); + const { data, total } = await db.getUsers({}, pagination); + res.send({ users: data.map(toPublicUser), total }); }); router.get('/:id', async (req: Request<{ id: string }>, res: Response) => { diff --git a/src/service/routes/utils.ts b/src/service/routes/utils.ts index 83e548408..e00dbda76 100644 --- a/src/service/routes/utils.ts +++ b/src/service/routes/utils.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { Request } from 'express'; +import { PaginationOptions } from '../../db/types'; import { PublicUser, User as DbUser } from '../../db/types'; interface User extends Express.User { @@ -21,6 +23,37 @@ interface User extends Express.User { admin?: boolean; } +interface PaginationQuery { + page?: string; + limit?: string; + search?: string; + sortBy?: string; + sortOrder?: string; +} + +export const parsePaginationParams = (req: Request, defaultLimit = 10): PaginationOptions => { + const { + page: rawPageStr, + limit: rawLimitStr, + search, + sortBy, + sortOrder, + } = req.query as PaginationQuery; + + const rawLimit = parseInt(rawLimitStr ?? '', 10); + const rawPage = parseInt(rawPageStr ?? '', 10); + + const limit = Math.min(100, Math.max(1, isNaN(rawLimit) ? defaultLimit : rawLimit)); + const page = Math.max(1, isNaN(rawPage) ? 1 : rawPage); + const skip = Math.min(10000, (page - 1) * limit); + + const pagination: PaginationOptions = { skip, limit }; + if (search) pagination.search = search; + if (sortBy) pagination.sortBy = sortBy; + if (sortOrder) pagination.sortOrder = sortOrder === 'desc' ? 'desc' : 'asc'; + return pagination; +}; + export function isAdminUser(user?: Express.User): user is User & { admin: true } { return user !== null && user !== undefined && (user as User).admin === true; } diff --git a/src/ui/components/Filtering/Filtering.tsx b/src/ui/components/Filtering/Filtering.tsx index 83be90848..a8104a343 100644 --- a/src/ui/components/Filtering/Filtering.tsx +++ b/src/ui/components/Filtering/Filtering.tsx @@ -15,9 +15,12 @@ */ import React, { useState } from 'react'; -import './Filtering.css'; +import Select from '@material-ui/core/Select'; +import MenuItem from '@material-ui/core/MenuItem'; +import IconButton from '@material-ui/core/IconButton'; +import { ArrowUpward, ArrowDownward } from '@material-ui/icons'; -export type FilterOption = 'Date Modified' | 'Date Created' | 'Alphabetical' | 'Sort by'; +export type FilterOption = 'Alphabetical' | 'Sort by'; export type SortOrder = 'asc' | 'desc'; interface FilteringProps { @@ -25,54 +28,52 @@ interface FilteringProps { } const Filtering: React.FC = ({ onFilterChange }) => { - const [isOpen, setIsOpen] = useState(false); const [selectedOption, setSelectedOption] = useState('Sort by'); const [sortOrder, setSortOrder] = useState('asc'); - const toggleDropdown = () => { - setIsOpen(!isOpen); + const handleOptionChange = (e: React.ChangeEvent<{ value: unknown }>) => { + const option = e.target.value as FilterOption; + setSelectedOption(option); + if (option !== 'Sort by') { + onFilterChange(option, sortOrder); + } }; - const toggleSortOrder = (e: React.MouseEvent) => { - e.stopPropagation(); + const toggleSortOrder = () => { if (selectedOption !== 'Sort by') { - const newSortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; - setSortOrder(newSortOrder); - onFilterChange(selectedOption, newSortOrder); + const newOrder: SortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; + setSortOrder(newOrder); + onFilterChange(selectedOption, newOrder); } }; - const handleOptionClick = (option: FilterOption) => { - setSelectedOption(option); - onFilterChange(option, sortOrder); - setIsOpen(false); - }; - return ( -
-
- - - {isOpen && ( -
-
handleOptionClick('Date Modified')} className='dropdown-item'> - Date Modified -
-
handleOptionClick('Date Created')} className='dropdown-item'> - Date Created -
-
handleOptionClick('Alphabetical')} className='dropdown-item'> - Alphabetical -
-
- )} -
+ + )}
); }; diff --git a/src/ui/components/Pagination/Pagination.tsx b/src/ui/components/Pagination/Pagination.tsx index 884f712b2..2cd543333 100644 --- a/src/ui/components/Pagination/Pagination.tsx +++ b/src/ui/components/Pagination/Pagination.tsx @@ -15,13 +15,18 @@ */ import React from 'react'; +import Select from '@material-ui/core/Select'; +import MenuItem from '@material-ui/core/MenuItem'; import './Pagination.css'; +const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; + interface PaginationProps { currentPage: number; totalItems?: number; itemsPerPage: number; onPageChange: (page: number) => void; + onItemsPerPageChange?: (n: number) => void; } const Pagination: React.FC = ({ @@ -29,6 +34,7 @@ const Pagination: React.FC = ({ totalItems = 0, itemsPerPage, onPageChange, + onItemsPerPageChange, }) => { const totalPages = Math.ceil(totalItems / itemsPerPage); @@ -40,6 +46,22 @@ const Pagination: React.FC = ({ return (
+ {onItemsPerPageChange && ( + + )} + + + + ); + })} + + + + + + )}
); }; diff --git a/src/ui/views/RepoDetails/Components/AddUser.tsx b/src/ui/views/RepoDetails/Components/AddUser.tsx index 253f29e13..1757c0aa3 100644 --- a/src/ui/views/RepoDetails/Components/AddUser.tsx +++ b/src/ui/views/RepoDetails/Components/AddUser.tsx @@ -18,6 +18,8 @@ import React, { useState, useEffect } from 'react'; import InputLabel from '@material-ui/core/InputLabel'; import FormControl from '@material-ui/core/FormControl'; import FormHelperText from '@material-ui/core/FormHelperText'; +import TextField from '@material-ui/core/TextField'; +import ListSubheader from '@material-ui/core/ListSubheader'; import GridItem from '../../../components/Grid/GridItem'; import GridContainer from '../../../components/Grid/GridContainer'; import CircularProgress from '@material-ui/core/CircularProgress'; @@ -52,8 +54,8 @@ const AddUserDialog: React.FC = ({ }) => { const [username, setUsername] = useState(''); const [users, setUsers] = useState([]); - const [, setAuth] = useState(true); const [isLoading, setIsLoading] = useState(false); + const [filterText, setFilterText] = useState(''); const [errorMessage, setErrorMessage] = useState(''); const [error, setError] = useState(''); const [tip, setTip] = useState(false); @@ -90,9 +92,27 @@ const AddUserDialog: React.FC = ({ }; useEffect(() => { - getUsers(setIsLoading, setUsers, setAuth, setErrorMessage); + const load = async () => { + setIsLoading(true); + const result = await getUsers({ limit: 100 }); + if (result.success && result.data) { + setUsers(result.data.users); + } else { + setErrorMessage(result.message || 'Failed to load users'); + } + setIsLoading(false); + }; + load(); }, []); + const filteredUsers = filterText + ? users.filter( + (u) => + u.username.toLowerCase().includes(filterText.toLowerCase()) || + (u.gitAccount || '').toLowerCase().includes(filterText.toLowerCase()), + ) + : users; + if (errorMessage) return {errorMessage}; return ( @@ -131,8 +151,19 @@ const AddUserDialog: React.FC = ({ value={username} onChange={handleChange} disabled={isLoading} + onClose={() => setFilterText('')} > - {users.map((user) => ( + + setFilterText(e.target.value)} + onKeyDown={(e) => e.stopPropagation()} + fullWidth + /> + + {filteredUsers.map((user) => ( {user.username} / {user.gitAccount} diff --git a/src/ui/views/RepoList/Components/Repositories.tsx b/src/ui/views/RepoList/Components/Repositories.tsx index d82faa45b..22c275671 100644 --- a/src/ui/views/RepoList/Components/Repositories.tsx +++ b/src/ui/views/RepoList/Components/Repositories.tsx @@ -22,6 +22,7 @@ import TableBody from '@material-ui/core/TableBody'; import TableContainer from '@material-ui/core/TableContainer'; import styles from '../../../assets/jss/material-dashboard-react/views/dashboardStyle'; import { getRepos } from '../../../services/repo'; +import { PaginationParams } from '../../../services/git-push'; import GridContainer from '../../../components/Grid/GridContainer'; import GridItem from '../../../components/Grid/GridItem'; import NewRepo from './NewRepo'; @@ -43,21 +44,27 @@ interface GridContainerLayoutProps { totalItems: number; itemsPerPage: number; onPageChange: (page: number) => void; + onItemsPerPageChange: (n: number) => void; onFilterChange: (filterOption: FilterOption, sortOrder: SortOrder) => void; tableId: string; key: string; + isLoading: boolean; } export default function Repositories(): React.ReactElement { const useStyles = makeStyles(styles as any); const classes = useStyles(); const [repos, setRepos] = useState([]); - const [filteredRepos, setFilteredRepos] = useState([]); + const [totalItems, setTotalItems] = useState(0); const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const [currentPage, setCurrentPage] = useState(1); - const itemsPerPage: number = 5; + const [itemsPerPage, setItemsPerPage] = useState(10); + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState('name'); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc'); + const [refreshKey, setRefreshKey] = useState(0); const navigate = useNavigate(); const { user } = useContext(UserContext); const openRepo = (repoId: string): void => navigate(`/dashboard/repo/${repoId}`); @@ -65,10 +72,17 @@ export default function Repositories(): React.ReactElement { useEffect(() => { const load = async () => { setIsLoading(true); - const result = await getRepos(); + const pagination: PaginationParams = { + page: currentPage, + limit: itemsPerPage, + search: searchTerm || undefined, + sortBy, + sortOrder, + }; + const result = await getRepos({}, pagination); if (result.success && result.data) { - setRepos(result.data); - setFilteredRepos(result.data); + setRepos(result.data.repos); + setTotalItems(result.data.total); } else if (result.status === 401) { setIsLoading(false); navigate('/login', { replace: true }); @@ -80,62 +94,35 @@ export default function Repositories(): React.ReactElement { setIsLoading(false); }; load(); - }, []); + }, [currentPage, itemsPerPage, searchTerm, sortBy, sortOrder, refreshKey]); - const refresh = async (repo: RepoView): Promise => { - const updatedRepos = [...repos, repo]; - setRepos(updatedRepos); - setFilteredRepos(updatedRepos); + const refresh = async (): Promise => { + setCurrentPage(1); + setSearchTerm(''); + setRefreshKey((k) => k + 1); }; const handleSearch = (query: string): void => { + setSearchTerm(query.trim()); setCurrentPage(1); - if (!query) { - setFilteredRepos(repos); - } else { - const lowercasedQuery = query.toLowerCase(); - setFilteredRepos( - repos.filter( - (repo) => - repo.name.toLowerCase().includes(lowercasedQuery) || - repo.project.toLowerCase().includes(lowercasedQuery), - ), - ); - } }; - const handleFilterChange = (filterOption: FilterOption, sortOrder: SortOrder): void => { - const sortedRepos = [...repos]; - switch (filterOption) { - case 'Date Modified': - sortedRepos.sort( - (a, b) => - new Date(a.lastModified || 0).getTime() - new Date(b.lastModified || 0).getTime(), - ); - break; - case 'Date Created': - sortedRepos.sort( - (a, b) => new Date(a.dateCreated || 0).getTime() - new Date(b.dateCreated || 0).getTime(), - ); - break; - case 'Alphabetical': - sortedRepos.sort((a, b) => a.name.localeCompare(b.name)); - break; - default: - break; - } - if (sortOrder === 'desc') { - sortedRepos.reverse(); - } - - setFilteredRepos(sortedRepos); + const handleFilterChange = (filterOption: FilterOption, order: SortOrder): void => { + const fieldMap: Record = { + Alphabetical: 'name', + }; + setSortBy(fieldMap[filterOption] ?? 'name'); + setSortOrder(order === 'desc' ? 'desc' : 'asc'); + setCurrentPage(1); }; const handlePageChange = (page: number): void => setCurrentPage(page); - const startIdx = (currentPage - 1) * itemsPerPage; - const paginatedRepos = filteredRepos.slice(startIdx, startIdx + itemsPerPage); + const handleItemsPerPageChange = (n: number): void => { + setItemsPerPage(n); + setCurrentPage(1); + }; + const paginatedRepos = repos; - if (isLoading) return
Loading...
; if (isError) return {errorMessage}; const addrepoButton = user?.admin ? ( @@ -154,11 +141,13 @@ export default function Repositories(): React.ReactElement { repoButton: addrepoButton, onSearch: handleSearch, currentPage: currentPage, - totalItems: filteredRepos.length, + totalItems: totalItems, itemsPerPage: itemsPerPage, onPageChange: handlePageChange, + onItemsPerPageChange: handleItemsPerPageChange, onFilterChange: handleFilterChange, tableId: 'RepoListTable', + isLoading: isLoading, }); } @@ -169,33 +158,47 @@ function getGridContainerLayOut(props: GridContainerLayoutProps): React.ReactEle - - - - {props.repos.map((repo) => { - if (repo.url) { - return ( - - ); - } - return null; - })} - -
-
-
- - + {props.isLoading ? ( +
Loading...
+ ) : ( + <> + + + + {props.repos.map((repo) => { + if (repo.url) { + return ( + + ); + } + return null; + })} + +
+
+ + + )}
); diff --git a/src/ui/views/UserList/Components/UserList.tsx b/src/ui/views/UserList/Components/UserList.tsx index 6a9ea6021..6d32d1dac 100644 --- a/src/ui/views/UserList/Components/UserList.tsx +++ b/src/ui/views/UserList/Components/UserList.tsx @@ -28,6 +28,7 @@ import TableHead from '@material-ui/core/TableHead'; import TableRow from '@material-ui/core/TableRow'; import Paper from '@material-ui/core/Paper'; import { getUsers } from '../../../services/user'; +import { PaginationParams } from '../../../services/git-push'; import Pagination from '../../../components/Pagination/Pagination'; import { CloseRounded, Check, KeyboardArrowRight } from '@material-ui/icons'; import Search from '../../../components/Search/Search'; @@ -36,40 +37,50 @@ import { PublicUser } from '../../../../db/types'; const UserList: React.FC = () => { const [users, setUsers] = useState([]); - const [, setAuth] = useState(true); + const [totalItems, setTotalItems] = useState(0); const [isLoading, setIsLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const navigate = useNavigate(); const [currentPage, setCurrentPage] = useState(1); - const itemsPerPage = 5; + const [itemsPerPage, setItemsPerPage] = useState(10); const [searchQuery, setSearchQuery] = useState(''); const openUser = (username: string) => navigate(`/dashboard/user/${username}`); useEffect(() => { - getUsers(setIsLoading, setUsers, setAuth, setErrorMessage); - }, []); + const load = async () => { + setIsLoading(true); + const pagination: PaginationParams = { + page: currentPage, + limit: itemsPerPage, + search: searchQuery || undefined, + }; + const result = await getUsers(pagination); + if (result.success && result.data) { + setUsers(result.data.users); + setTotalItems(result.data.total); + } else if (result.status === 401) { + navigate('/login', { replace: true }); + } else { + setErrorMessage(result.message || 'Failed to load users'); + } + setIsLoading(false); + }; + load(); + }, [currentPage, itemsPerPage, searchQuery]); - if (isLoading) return
Loading...
; if (errorMessage) return {errorMessage}; - const filteredUsers = users.filter( - (user) => - (user.displayName && user.displayName.toLowerCase().includes(searchQuery.toLowerCase())) || - (user.username && user.username.toLowerCase().includes(searchQuery.toLowerCase())), - ); - - const indexOfLastItem = currentPage * itemsPerPage; - const indexOfFirstItem = indexOfLastItem - itemsPerPage; - const currentItems = filteredUsers.slice(indexOfFirstItem, indexOfLastItem); - const totalItems = filteredUsers.length; + const currentItems = users; - const handlePageChange = (page: number) => { - setCurrentPage(page); + const handlePageChange = (page: number) => setCurrentPage(page); + const handleItemsPerPageChange = (n: number) => { + setItemsPerPage(n); + setCurrentPage(1); }; const handleSearch = (query: string) => { - setSearchQuery(query); + setSearchQuery(query.trim()); setCurrentPage(1); }; @@ -77,62 +88,69 @@ const UserList: React.FC = () => { - - - - - Name - Role - E-mail - GitHub Username - Administrator - - - - - {currentItems.map((user) => ( - - {user.displayName} - {user.title} - - {user.email} - - - - {user.gitAccount} - - - - {user?.admin ? ( - - ) : ( - - )} - - - - - - ))} - -
-
- + {isLoading ? ( +
Loading...
+ ) : ( + <> + + + + + Name + Role + E-mail + GitHub Username + Administrator + + + + + {currentItems.map((user) => ( + + {user.displayName} + {user.title} + + {user.email} + + + + {user.gitAccount} + + + + {user?.admin ? ( + + ) : ( + + )} + + + + + + ))} + +
+
+ + + )}
); diff --git a/test/db-helper.test.ts b/test/db-helper.test.ts index 9ab64f72d..9c234b9b1 100644 --- a/test/db-helper.test.ts +++ b/test/db-helper.test.ts @@ -15,9 +15,100 @@ */ import { describe, it, expect } from 'vitest'; -import { trimPrefixRefsHeads, trimTrailingDotGit } from '../src/db/helper'; +import { + trimPrefixRefsHeads, + trimTrailingDotGit, + buildSearchFilter, + buildSort, +} from '../src/db/helper'; describe('db helpers', () => { + describe('buildSearchFilter', () => { + it('returns baseQuery unchanged when search is not provided', () => { + const base = { project: 'myproject' }; + expect(buildSearchFilter(base, ['name', 'url'])).toEqual(base); + }); + + it('returns baseQuery unchanged when search is empty string', () => { + const base = { project: 'myproject' }; + expect(buildSearchFilter(base, ['name', 'url'], '')).toEqual(base); + }); + + it('adds $or clause for each search field', () => { + const result = buildSearchFilter({}, ['name', 'url'], 'proxy'); + expect(result.$or).toHaveLength(2); + expect((result.$or as any[])[0]).toHaveProperty('name'); + expect((result.$or as any[])[1]).toHaveProperty('url'); + }); + + it('merges baseQuery with $or clause', () => { + const result = buildSearchFilter({ project: 'myproject' }, ['name'], 'proxy'); + expect(result.project).toBe('myproject'); + expect(result.$or).toBeDefined(); + }); + + it('creates case-insensitive regex', () => { + const result = buildSearchFilter({}, ['name'], 'Proxy'); + const regex = (result.$or as any[])[0].name as RegExp; + expect(regex.flags).toContain('i'); + expect(regex.test('git-proxy')).toBe(true); + expect(regex.test('GIT-PROXY')).toBe(true); + }); + + it('escapes special regex characters in search term', () => { + const result = buildSearchFilter({}, ['name'], 'git.proxy'); + const regex = (result.$or as any[])[0].name as RegExp; + expect(regex.test('gitXproxy')).toBe(false); + expect(regex.test('git.proxy')).toBe(true); + }); + }); + + describe('buildSort', () => { + it('uses default field and direction when no pagination provided', () => { + expect(buildSort(undefined, 'name', 1)).toEqual({ name: 1 }); + }); + + it('uses default field and direction when sortBy/sortOrder not set', () => { + expect(buildSort({ skip: 0, limit: 10 }, 'timestamp', -1)).toEqual({ timestamp: -1 }); + }); + + it('uses sortBy field from pagination', () => { + expect(buildSort({ skip: 0, limit: 10, sortBy: 'email' }, 'name', 1)).toEqual({ email: 1 }); + }); + + it('applies asc direction', () => { + expect(buildSort({ skip: 0, limit: 10, sortOrder: 'asc' }, 'name', -1)).toEqual({ name: 1 }); + }); + + it('applies desc direction', () => { + expect(buildSort({ skip: 0, limit: 10, sortOrder: 'desc' }, 'name', 1)).toEqual({ name: -1 }); + }); + + it('applies both sortBy and sortOrder', () => { + expect( + buildSort({ skip: 0, limit: 10, sortBy: 'email', sortOrder: 'desc' }, 'name', 1), + ).toEqual({ email: -1 }); + }); + + it('uses sortBy when it is in the allowlist', () => { + expect( + buildSort({ skip: 0, limit: 10, sortBy: 'email' }, 'name', 1, ['email', 'username']), + ).toEqual({ email: 1 }); + }); + + it('falls back to default field when sortBy is not in the allowlist', () => { + expect( + buildSort({ skip: 0, limit: 10, sortBy: 'password' }, 'name', 1, ['email', 'username']), + ).toEqual({ name: 1 }); + }); + + it('uses default field when allowlist is provided but sortBy is not set', () => { + expect(buildSort({ skip: 0, limit: 10 }, 'name', 1, ['email', 'username'])).toEqual({ + name: 1, + }); + }); + }); + describe('trimPrefixRefsHeads', () => { it('removes `refs/heads/`', () => { const res = trimPrefixRefsHeads('refs/heads/test'); diff --git a/test/db/mongo/helper.test.ts b/test/db/mongo/helper.test.ts index 62c2c544c..024e69658 100644 --- a/test/db/mongo/helper.test.ts +++ b/test/db/mongo/helper.test.ts @@ -17,9 +17,16 @@ import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest'; import { MongoClient } from 'mongodb'; +const mockSort = vi.fn(); +const mockSkip = vi.fn(); +const mockLimit = vi.fn(); +const mockToArray = vi.fn(); +const mockCountDocuments = vi.fn(); + const mockCollection = { find: vi.fn(), findOne: vi.fn(), + countDocuments: mockCountDocuments, }; const mockDb = { @@ -31,8 +38,6 @@ const mockClient = { db: vi.fn(() => mockDb), }; -const mockToArray = vi.fn(); - vi.mock('mongodb', async () => { const actual = await vi.importActual('mongodb'); return { @@ -62,7 +67,11 @@ vi.mock('connect-mongo', () => ({ describe('MongoDB Helper', () => { beforeEach(() => { vi.clearAllMocks(); - mockCollection.find.mockReturnValue({ toArray: mockToArray }); + mockSort.mockReturnValue({ skip: mockSkip, limit: mockLimit, toArray: mockToArray }); + mockSkip.mockReturnValue({ limit: mockLimit, toArray: mockToArray }); + mockLimit.mockReturnValue({ toArray: mockToArray }); + // Default find returns toArray directly (for findDocuments) and sort chain (for paginatedFind) + mockCollection.find.mockReturnValue({ toArray: mockToArray, sort: mockSort }); // Clear cached db vi.resetModules(); @@ -310,6 +319,96 @@ describe('MongoDB Helper', () => { }); }); + describe('paginatedFind', () => { + beforeEach(async () => { + mockGetDatabase.mockReturnValue({ + connectionString: 'mongodb://localhost:27017/testdb', + options: {}, + }); + }); + + it('should return data and total when limit > 0', async () => { + const docs = [{ id: 1 }, { id: 2 }]; + mockCountDocuments.mockResolvedValue(2); + mockToArray.mockResolvedValue(docs); + + const { connect, paginatedFind } = await import('../../../src/db/mongo/helper'); + const collection = await connect('testCollection'); + const result = await paginatedFind(collection, {}, { name: 1 }, 0, 10); + + expect(mockCountDocuments).toHaveBeenCalledTimes(1); + expect(result).toEqual({ data: docs, total: 2 }); + }); + + it('should skip countDocuments and return total equal to data.length when limit is 0', async () => { + const docs = [{ id: 1 }, { id: 2 }]; + mockToArray.mockResolvedValue(docs); + + const { connect, paginatedFind } = await import('../../../src/db/mongo/helper'); + const collection = await connect('testCollection'); + const result = await paginatedFind(collection, {}, { name: 1 }, 0, 0); + + expect(mockCountDocuments).not.toHaveBeenCalled(); + expect(result).toEqual({ data: docs, total: docs.length }); + }); + + it('should apply skip when skip > 0', async () => { + mockCountDocuments.mockResolvedValue(10); + mockToArray.mockResolvedValue([]); + + const { connect, paginatedFind } = await import('../../../src/db/mongo/helper'); + const collection = await connect('testCollection'); + await paginatedFind(collection, {}, {}, 5, 0); + + expect(mockSkip).toHaveBeenCalledWith(5); + }); + + it('should apply limit when limit > 0', async () => { + mockCountDocuments.mockResolvedValue(10); + mockToArray.mockResolvedValue([]); + + const { connect, paginatedFind } = await import('../../../src/db/mongo/helper'); + const collection = await connect('testCollection'); + await paginatedFind(collection, {}, {}, 0, 10); + + expect(mockLimit).toHaveBeenCalledWith(10); + }); + + it('should not apply skip when skip is 0', async () => { + mockCountDocuments.mockResolvedValue(5); + mockToArray.mockResolvedValue([]); + + const { connect, paginatedFind } = await import('../../../src/db/mongo/helper'); + const collection = await connect('testCollection'); + await paginatedFind(collection, {}, {}, 0, 0); + + expect(mockSkip).not.toHaveBeenCalled(); + }); + + it('should pass projection to find when provided', async () => { + mockCountDocuments.mockResolvedValue(1); + mockToArray.mockResolvedValue([{ id: 1 }]); + const projection = { _id: 0, name: 1 }; + + const { connect, paginatedFind } = await import('../../../src/db/mongo/helper'); + const collection = await connect('testCollection'); + await paginatedFind(collection, {}, {}, 0, 0, projection); + + expect(mockCollection.find).toHaveBeenCalledWith({}, { projection }); + }); + + it('should not pass projection when not provided', async () => { + mockCountDocuments.mockResolvedValue(1); + mockToArray.mockResolvedValue([]); + + const { connect, paginatedFind } = await import('../../../src/db/mongo/helper'); + const collection = await connect('testCollection'); + await paginatedFind(collection, {}, {}, 0, 0); + + expect(mockCollection.find).toHaveBeenCalledWith({}, undefined); + }); + }); + describe('getSessionStore', () => { it('should create MongoDBStore with connection string and options', async () => { const connectionString = 'mongodb://localhost:27017/testdb'; diff --git a/test/db/mongo/push.test.ts b/test/db/mongo/push.test.ts index ecc1c831a..9ff9c9b0d 100644 --- a/test/db/mongo/push.test.ts +++ b/test/db/mongo/push.test.ts @@ -29,12 +29,12 @@ const mockConnect = vi.fn(() => ({ find: mockFind, })); -const mockFindDocuments = vi.fn(); +const mockPaginatedFind = vi.fn(); const mockFindOneDocument = vi.fn(); vi.mock('../../../src/db/mongo/helper', () => ({ connect: mockConnect, - findDocuments: mockFindDocuments, + paginatedFind: mockPaginatedFind, findOneDocument: mockFindOneDocument, })); @@ -42,6 +42,8 @@ const mockToClass = vi.fn((doc, proto) => Object.assign(Object.create(proto), do vi.mock('../../../src/db/helper', () => ({ toClass: mockToClass, + buildSearchFilter: vi.fn((baseQuery) => baseQuery), + buildSort: vi.fn(() => ({})), })); describe('MongoDB Push Handler', async () => { @@ -81,65 +83,25 @@ describe('MongoDB Push Handler', async () => { describe('getPushes', () => { it('should get pushes with default query', async () => { const mockPushes = [TEST_PUSH]; - mockFindDocuments.mockResolvedValue(mockPushes); + mockPaginatedFind.mockResolvedValue({ data: mockPushes, total: 1 }); const result = await getPushes(); - expect(mockFindDocuments).toHaveBeenCalledWith( - 'pushes', - { - error: false, - blocked: true, - allowPush: false, - authorised: false, - type: 'push', - }, - { - projection: { - _id: 0, - id: 1, - allowPush: 1, - authorised: 1, - blocked: 1, - blockedMessage: 1, - branch: 1, - canceled: 1, - commitData: 1, - commitFrom: 1, - commitTo: 1, - error: 1, - method: 1, - project: 1, - rejected: 1, - repo: 1, - repoName: 1, - timestamp: 1, - type: 1, - url: 1, - }, - sort: { - timestamp: -1, - }, - }, - ); - expect(result).toEqual(mockPushes); + expect(mockConnect).toHaveBeenCalledWith('pushes'); + expect(mockPaginatedFind).toHaveBeenCalled(); + expect(result).toEqual({ data: mockPushes, total: 1 }); }); it('should get pushes with custom query', async () => { const customQuery = { error: true }; const mockPushes = [TEST_PUSH]; - mockFindDocuments.mockResolvedValue(mockPushes); + mockPaginatedFind.mockResolvedValue({ data: mockPushes, total: 1 }); const result = await getPushes(customQuery); - expect(mockFindDocuments).toHaveBeenCalledWith( - 'pushes', - customQuery, - expect.objectContaining({ - projection: expect.any(Object), - }), - ); - expect(result).toEqual(mockPushes); + expect(mockConnect).toHaveBeenCalledWith('pushes'); + expect(mockPaginatedFind).toHaveBeenCalled(); + expect(result).toEqual({ data: mockPushes, total: 1 }); }); }); diff --git a/test/db/mongo/pushes.integration.test.ts b/test/db/mongo/pushes.integration.test.ts index 2a056fc48..45c997c3a 100644 --- a/test/db/mongo/pushes.integration.test.ts +++ b/test/db/mongo/pushes.integration.test.ts @@ -152,23 +152,23 @@ describe.runIf(shouldRunMongoTests)('MongoDB Pushes Integration Tests', () => { }); it('should retrieve pushes matching default query', async () => { - const result = await getPushes(); + const { data } = await getPushes(); - const matchingPushes = result.filter((p) => ['push-list-1', 'push-list-2'].includes(p.id)); + const matchingPushes = data.filter((p) => ['push-list-1', 'push-list-2'].includes(p.id)); expect(matchingPushes.length).toBe(2); }); it('should filter pushes by custom query', async () => { - const result = await getPushes({ authorised: true }); + const { data } = await getPushes({ authorised: true }); - const authorisedPush = result.find((p) => p.id === 'push-authorised'); + const authorisedPush = data.find((p) => p.id === 'push-authorised'); expect(authorisedPush).toBeDefined(); }); it('should return projected fields only', async () => { - const result = await getPushes(); + const { data } = await getPushes(); - result.forEach((push) => { + data.forEach((push) => { expect((push as any)._id).toBeUndefined(); expect(push.id).toBeDefined(); }); diff --git a/test/db/mongo/repo.integration.test.ts b/test/db/mongo/repo.integration.test.ts index 5652d2d38..96d7f10ce 100644 --- a/test/db/mongo/repo.integration.test.ts +++ b/test/db/mongo/repo.integration.test.ts @@ -105,10 +105,10 @@ describe.runIf(shouldRunMongoTests)('MongoDB Repo Integration Tests', () => { await createRepo(createTestRepo({ name: 'list-repo-1' })); await createRepo(createTestRepo({ name: 'list-repo-2' })); - const result = await getRepos(); + const { data } = await getRepos(); - expect(result.length).toBeGreaterThanOrEqual(2); - const names = result.map((r) => r.name); + expect(data.length).toBeGreaterThanOrEqual(2); + const names = data.map((r) => r.name); expect(names).toContain('list-repo-1'); expect(names).toContain('list-repo-2'); }); @@ -117,10 +117,10 @@ describe.runIf(shouldRunMongoTests)('MongoDB Repo Integration Tests', () => { await createRepo(createTestRepo({ name: 'filter-repo', project: 'filter-project' })); await createRepo(createTestRepo({ name: 'other-repo', project: 'other-project' })); - const result = await getRepos({ project: 'filter-project' }); + const { data } = await getRepos({ project: 'filter-project' }); - expect(result.length).toBe(1); - expect(result[0].name).toBe('filter-repo'); + expect(data.length).toBe(1); + expect(data[0].name).toBe('filter-repo'); }); }); diff --git a/test/db/mongo/repo.test.ts b/test/db/mongo/repo.test.ts index 61e9d882c..8b6308398 100644 --- a/test/db/mongo/repo.test.ts +++ b/test/db/mongo/repo.test.ts @@ -33,14 +33,18 @@ const mockConnect = vi.fn(() => ({ deleteMany: mockDeleteMany, })); +const mockPaginatedFind = vi.fn(); const mockToClass = vi.fn((doc, proto) => Object.assign(Object.create(proto), doc)); vi.mock('../../../src/db/mongo/helper', () => ({ connect: mockConnect, + paginatedFind: mockPaginatedFind, })); vi.mock('../../../src/db/helper', () => ({ toClass: mockToClass, + buildSearchFilter: vi.fn((baseQuery) => baseQuery), + buildSort: vi.fn(() => ({})), })); describe('MongoDB Repo', async () => { @@ -77,37 +81,34 @@ describe('MongoDB Repo', async () => { describe('getRepos', () => { it('should get all repos with empty query', async () => { const repoData = [TEST_REPO]; - mockToArray.mockResolvedValue(repoData); + mockPaginatedFind.mockResolvedValue({ data: repoData, total: 1 }); mockToClass.mockImplementation((doc) => doc); const result = await getRepos(); expect(mockConnect).toHaveBeenCalledWith('repos'); - expect(mockFind).toHaveBeenCalledWith({}); - expect(mockToArray).toHaveBeenCalled(); - expect(result).toEqual(repoData); + expect(mockPaginatedFind).toHaveBeenCalled(); + expect(result).toEqual({ data: repoData, total: 1 }); }); it('should get repos with custom query', async () => { - const query = { name: 'sample' }; const repoData = [TEST_REPO]; - mockToArray.mockResolvedValue(repoData); + mockPaginatedFind.mockResolvedValue({ data: repoData, total: 1 }); mockToClass.mockImplementation((doc) => doc); - const result = await getRepos(query); + const result = await getRepos({ name: 'sample' }); expect(mockConnect).toHaveBeenCalledWith('repos'); - expect(mockFind).toHaveBeenCalledWith(query); - expect(mockToArray).toHaveBeenCalled(); - expect(result).toEqual(repoData); + expect(mockPaginatedFind).toHaveBeenCalled(); + expect(result).toEqual({ data: repoData, total: 1 }); }); it('should return empty array when no repos found', async () => { - mockToArray.mockResolvedValue([]); + mockPaginatedFind.mockResolvedValue({ data: [], total: 0 }); const result = await getRepos(); - expect(result).toEqual([]); + expect(result).toEqual({ data: [], total: 0 }); }); }); diff --git a/test/db/mongo/user.test.ts b/test/db/mongo/user.test.ts index b6a35c7b8..3188bf8a4 100644 --- a/test/db/mongo/user.test.ts +++ b/test/db/mongo/user.test.ts @@ -34,14 +34,18 @@ const mockConnect = vi.fn(() => ({ deleteOne: mockDeleteOne, })); +const mockPaginatedFind = vi.fn(); const mockToClass = vi.fn((doc, proto) => Object.assign(Object.create(proto), doc)); vi.mock('../../../src/db/mongo/helper', () => ({ connect: mockConnect, + paginatedFind: mockPaginatedFind, })); vi.mock('../../../src/db/helper', () => ({ toClass: mockToClass, + buildSearchFilter: vi.fn((baseQuery) => baseQuery), + buildSort: vi.fn(() => ({})), })); describe('MongoDB User', async () => { @@ -178,7 +182,7 @@ describe('MongoDB User', async () => { describe('getUsers', () => { it('should get all users with empty query', async () => { const userData = [TEST_USER]; - mockToArray.mockResolvedValue(userData); + mockPaginatedFind.mockResolvedValue({ data: userData, total: 1 }); mockToClass.mockImplementation((doc) => doc); const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); @@ -186,10 +190,15 @@ describe('MongoDB User', async () => { const result = await getUsers(); expect(mockConnect).toHaveBeenCalledWith('users'); - expect(mockFind).toHaveBeenCalledWith({}); - expect(mockProject).toHaveBeenCalledWith({ password: 0 }); - expect(mockToArray).toHaveBeenCalled(); - expect(result).toEqual(userData); + expect(mockPaginatedFind).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + { password: 0 }, + ); + expect(result).toEqual({ data: userData, total: 1 }); expect(consoleSpy).toHaveBeenCalled(); consoleSpy.mockRestore(); @@ -197,73 +206,57 @@ describe('MongoDB User', async () => { it('should get users with username query and convert to lowercase', async () => { const userData = [TEST_USER]; - mockToArray.mockResolvedValue(userData); + mockPaginatedFind.mockResolvedValue({ data: userData, total: 1 }); mockToClass.mockImplementation((doc) => doc); const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const result = await getUsers({ username: 'TestUser' }); - expect(mockFind).toHaveBeenCalledWith({ username: 'testuser' }); - expect(result).toEqual(userData); + expect(mockPaginatedFind).toHaveBeenCalled(); + expect(result).toEqual({ data: userData, total: 1 }); consoleSpy.mockRestore(); }); it('should get users with email query and convert to lowercase', async () => { const userData = [TEST_USER]; - mockToArray.mockResolvedValue(userData); + mockPaginatedFind.mockResolvedValue({ data: userData, total: 1 }); mockToClass.mockImplementation((doc) => doc); const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const result = await getUsers({ email: 'Test@Example.com' }); - expect(mockFind).toHaveBeenCalledWith({ email: 'test@example.com' }); - expect(result).toEqual(userData); + expect(mockPaginatedFind).toHaveBeenCalled(); + expect(result).toEqual({ data: userData, total: 1 }); consoleSpy.mockRestore(); }); it('should get users with both username and email query', async () => { const userData = [TEST_USER]; - mockToArray.mockResolvedValue(userData); + mockPaginatedFind.mockResolvedValue({ data: userData, total: 1 }); mockToClass.mockImplementation((doc) => doc); const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const result = await getUsers({ username: 'TestUser', email: 'Test@Example.com' }); - expect(mockFind).toHaveBeenCalledWith({ - username: 'testuser', - email: 'test@example.com', - }); - expect(result).toEqual(userData); - - consoleSpy.mockRestore(); - }); - - it('should exclude password field from results', async () => { - mockToArray.mockResolvedValue([TEST_USER]); - mockToClass.mockImplementation((doc) => doc); - - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - await getUsers(); - - expect(mockProject).toHaveBeenCalledWith({ password: 0 }); + expect(mockPaginatedFind).toHaveBeenCalled(); + expect(result).toEqual({ data: userData, total: 1 }); consoleSpy.mockRestore(); }); it('should return empty array when no users found', async () => { - mockToArray.mockResolvedValue([]); + mockPaginatedFind.mockResolvedValue({ data: [], total: 0 }); const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const result = await getUsers(); - expect(result).toEqual([]); + expect(result).toEqual({ data: [], total: 0 }); consoleSpy.mockRestore(); }); diff --git a/test/db/mongo/users.integration.test.ts b/test/db/mongo/users.integration.test.ts index ec07f458a..36b443752 100644 --- a/test/db/mongo/users.integration.test.ts +++ b/test/db/mongo/users.integration.test.ts @@ -117,10 +117,10 @@ describe.runIf(shouldRunMongoTests)('MongoDB Users Integration Tests', () => { await createUser(createTestUser({ username: 'getusers1' })); await createUser(createTestUser({ username: 'getusers2' })); - const result = await getUsers(); + const { data } = await getUsers(); - expect(result.length).toBeGreaterThanOrEqual(2); - result.forEach((user) => { + expect(data.length).toBeGreaterThanOrEqual(2); + data.forEach((user) => { expect(user.password).toBeUndefined(); }); }); @@ -129,19 +129,19 @@ describe.runIf(shouldRunMongoTests)('MongoDB Users Integration Tests', () => { await createUser(createTestUser({ username: 'filteruser', email: 'filter@test.com' })); await createUser(createTestUser({ username: 'otheruser', email: 'other@test.com' })); - const result = await getUsers({ username: 'FilterUser' }); + const { data } = await getUsers({ username: 'FilterUser' }); - expect(result.length).toBe(1); - expect(result[0].username).toBe('filteruser'); + expect(data.length).toBe(1); + expect(data[0].username).toBe('filteruser'); }); it('should filter by email (lowercased)', async () => { await createUser(createTestUser({ username: 'emailfilter', email: 'unique-email@test.com' })); - const result = await getUsers({ email: 'Unique-Email@TEST.com' }); + const { data } = await getUsers({ email: 'Unique-Email@TEST.com' }); - expect(result.length).toBe(1); - expect(result[0].email).toBe('unique-email@test.com'); + expect(data.length).toBe(1); + expect(data[0].email).toBe('unique-email@test.com'); }); }); diff --git a/test/processors/checkUserPushPermission.test.ts b/test/processors/checkUserPushPermission.test.ts index 0bc5c2356..0534fba2b 100644 --- a/test/processors/checkUserPushPermission.test.ts +++ b/test/processors/checkUserPushPermission.test.ts @@ -63,9 +63,10 @@ describe('checkUserPushPermission', () => { }); it('should allow push when user has permission', async () => { - getUsersMock.mockResolvedValue([ - { username: 'db-user', email: 'db-user@test.com', gitAccount: 'git-user' }, - ]); + getUsersMock.mockResolvedValue({ + data: [{ username: 'db-user', email: 'db-user@test.com', gitAccount: 'git-user' }], + total: 1, + }); isUserPushAllowedMock.mockResolvedValue(true); const result = await exec(req, action); @@ -78,9 +79,10 @@ describe('checkUserPushPermission', () => { }); it('should reject push when user has no permission', async () => { - getUsersMock.mockResolvedValue([ - { username: 'db-user', email: 'db-user@test.com', gitAccount: 'git-user' }, - ]); + getUsersMock.mockResolvedValue({ + data: [{ username: 'db-user', email: 'db-user@test.com', gitAccount: 'git-user' }], + total: 1, + }); isUserPushAllowedMock.mockResolvedValue(false); const result = await exec(req, action); @@ -94,7 +96,7 @@ describe('checkUserPushPermission', () => { }); it('should reject push when no user found for git account', async () => { - getUsersMock.mockResolvedValue([]); + getUsersMock.mockResolvedValue({ data: [], total: 0 }); const result = await exec(req, action); @@ -107,10 +109,13 @@ describe('checkUserPushPermission', () => { }); it('should handle multiple users for git account by rejecting the push', async () => { - getUsersMock.mockResolvedValue([ - { username: 'user1', email: 'db-user@test.com', gitAccount: 'git-user' }, - { username: 'user2', email: 'db-user@test.com', gitAccount: 'git-user' }, - ]); + getUsersMock.mockResolvedValue({ + data: [ + { username: 'user1', email: 'db-user@test.com', gitAccount: 'git-user' }, + { username: 'user2', email: 'db-user@test.com', gitAccount: 'git-user' }, + ], + total: 2, + }); const result = await exec(req, action); @@ -124,7 +129,7 @@ describe('checkUserPushPermission', () => { it('should return error when no user is set in the action', async () => { action.user = undefined; action.userEmail = undefined; - getUsersMock.mockResolvedValue([]); + getUsersMock.mockResolvedValue({ data: [], total: 0 }); const result = await exec(req, action); @@ -147,7 +152,7 @@ describe('checkUserPushPermission', () => { ), 1, )[0]; - getUsersMock.mockResolvedValue(userList); + getUsersMock.mockResolvedValue({ data: userList, total: userList.length }); const result = await exec(req, action); diff --git a/test/services/routes/parsePaginationParams.test.ts b/test/services/routes/parsePaginationParams.test.ts new file mode 100644 index 000000000..b609533a2 --- /dev/null +++ b/test/services/routes/parsePaginationParams.test.ts @@ -0,0 +1,99 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * 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 { describe, it, expect } from 'vitest'; +import { Request } from 'express'; +import { parsePaginationParams } from '../../../src/service/routes/utils'; + +const makeReq = (query: Record = {}): Request => ({ query }) as unknown as Request; + +describe('parsePaginationParams', () => { + describe('defaults', () => { + it('uses defaultLimit=10 and page=1 when no params', () => { + const result = parsePaginationParams(makeReq()); + expect(result.limit).toBe(10); + expect(result.skip).toBe(0); + }); + + it('respects custom defaultLimit', () => { + const result = parsePaginationParams(makeReq(), 25); + expect(result.limit).toBe(25); + }); + }); + + describe('limit', () => { + it('parses limit from query', () => { + expect(parsePaginationParams(makeReq({ limit: '20' })).limit).toBe(20); + }); + + it('caps limit at 100', () => { + expect(parsePaginationParams(makeReq({ limit: '999' })).limit).toBe(100); + }); + + it('enforces minimum limit of 1', () => { + expect(parsePaginationParams(makeReq({ limit: '0' })).limit).toBe(1); + expect(parsePaginationParams(makeReq({ limit: '-5' })).limit).toBe(1); + }); + + it('falls back to defaultLimit when limit is not a number', () => { + expect(parsePaginationParams(makeReq({ limit: 'abc' })).limit).toBe(10); + }); + }); + + describe('page and skip', () => { + it('computes skip from page and limit', () => { + const result = parsePaginationParams(makeReq({ page: '3', limit: '10' })); + expect(result.skip).toBe(20); + }); + + it('enforces minimum page of 1', () => { + const result = parsePaginationParams(makeReq({ page: '0' })); + expect(result.skip).toBe(0); + }); + + it('falls back to page=1 when page is not a number', () => { + const result = parsePaginationParams(makeReq({ page: 'abc' })); + expect(result.skip).toBe(0); + }); + }); + + describe('optional params', () => { + it('sets search when provided', () => { + const result = parsePaginationParams(makeReq({ search: 'proxy' })); + expect(result.search).toBe('proxy'); + }); + + it('does not set search when not provided', () => { + expect(parsePaginationParams(makeReq()).search).toBeUndefined(); + }); + + it('sets sortBy when provided', () => { + expect(parsePaginationParams(makeReq({ sortBy: 'name' })).sortBy).toBe('name'); + }); + + it('sets sortOrder to desc when provided', () => { + expect(parsePaginationParams(makeReq({ sortOrder: 'desc' })).sortOrder).toBe('desc'); + }); + + it('defaults sortOrder to asc for any value other than desc', () => { + expect(parsePaginationParams(makeReq({ sortOrder: 'random' })).sortOrder).toBe('asc'); + }); + + it('does not set sortOrder when not provided', () => { + expect(parsePaginationParams(makeReq()).sortOrder).toBeUndefined(); + }); + }); +}); diff --git a/test/services/routes/users.test.ts b/test/services/routes/users.test.ts index 41ec52f58..6c20bc252 100644 --- a/test/services/routes/users.test.ts +++ b/test/services/routes/users.test.ts @@ -28,16 +28,19 @@ describe('Users API', () => { app.use(express.json()); app.use('/users', usersRouter); - vi.spyOn(db, 'getUsers').mockResolvedValue([ - { - username: 'alice', - password: 'secret-hashed-password', - email: 'alice@example.com', - displayName: 'Alice Walker', - gitAccount: '', - admin: false, - }, - ]); + vi.spyOn(db, 'getUsers').mockResolvedValue({ + data: [ + { + username: 'alice', + password: 'secret-hashed-password', + email: 'alice@example.com', + displayName: 'Alice Walker', + gitAccount: '', + admin: false, + }, + ], + total: 1, + }); vi.spyOn(db, 'findUser').mockResolvedValue({ username: 'bob', @@ -57,16 +60,19 @@ describe('Users API', () => { const res = await request(app).get('/users'); expect(res.status).toBe(200); - expect(res.body).toEqual([ - { - username: 'alice', - displayName: 'Alice Walker', - email: 'alice@example.com', - title: '', - gitAccount: '', - admin: false, - }, - ]); + expect(res.body).toEqual({ + users: [ + { + username: 'alice', + displayName: 'Alice Walker', + email: 'alice@example.com', + title: '', + gitAccount: '', + admin: false, + }, + ], + total: 1, + }); }); it('GET /users/:id does not serialize password', async () => { diff --git a/test/testDb.test.ts b/test/testDb.test.ts index 91fdb6eca..39f866819 100644 --- a/test/testDb.test.ts +++ b/test/testDb.test.ts @@ -242,7 +242,7 @@ describe('Database clients', () => { if (existing) await db.deleteRepo(existing._id!); await db.createRepo(TEST_REPO); - const repos = await db.getRepos(); + const { data: repos } = await db.getRepos(); const cleanRepos = cleanResponseData(TEST_REPO, repos); expect(cleanRepos).toContainEqual(TEST_REPO); }); @@ -251,16 +251,16 @@ describe('Database clients', () => { await ensureRepoExists(); // uppercase the filter value to confirm db client is lowercasing inputs - const repos = await db.getRepos({ name: TEST_REPO.name.toUpperCase() }); + const { data: repos } = await db.getRepos({ name: TEST_REPO.name.toUpperCase() }); const cleanRepos = cleanResponseData(TEST_REPO, repos); expect(cleanRepos[0]).toEqual(TEST_REPO); - const repos2 = await db.getRepos({ url: TEST_REPO.url }); + const { data: repos2 } = await db.getRepos({ url: TEST_REPO.url }); const cleanRepos2 = cleanResponseData(TEST_REPO, repos2); expect(cleanRepos2[0]).toEqual(TEST_REPO); - const repos3 = await db.getRepos(); - const repos4 = await db.getRepos({}); + const { data: repos3 } = await db.getRepos(); + const { data: repos4 } = await db.getRepos({}); expect(repos3).toEqual(expect.arrayContaining(repos4)); expect(repos4).toEqual(expect.arrayContaining(repos3)); }); @@ -284,7 +284,7 @@ describe('Database clients', () => { const repo = await ensureRepoExists(); await db.deleteRepo(repo._id); - const repos = await db.getRepos(); + const { data: repos } = await db.getRepos(); const cleanRepos = cleanResponseData(TEST_REPO, repos); expect(cleanRepos).not.toContainEqual(TEST_REPO); }); @@ -378,7 +378,7 @@ describe('Database clients', () => { TEST_USER.gitAccount, TEST_USER.admin, ); - const users = await db.getUsers(); + const { data: users } = await db.getUsers(); // remove password as it will have been hashed const { password: _, ...TEST_USER_CLEAN } = TEST_USER; const cleanUsers = cleanResponseData(TEST_USER_CLEAN, users); @@ -425,12 +425,12 @@ describe('Database clients', () => { it('should be able to filter getUsers', async () => { await ensureUserExists(); - const users = await db.getUsers({ username: TEST_USER.username.toUpperCase() }); + const { data: users } = await db.getUsers({ username: TEST_USER.username.toUpperCase() }); const { password: _, ...TEST_USER_CLEAN } = TEST_USER; const cleanUsers = cleanResponseData(TEST_USER_CLEAN, users); expect(cleanUsers[0]).toEqual(TEST_USER_CLEAN); - const users2 = await db.getUsers({ email: TEST_USER.email.toUpperCase() }); + const { data: users2 } = await db.getUsers({ email: TEST_USER.email.toUpperCase() }); const cleanUsers2 = cleanResponseData(TEST_USER_CLEAN, users2); expect(cleanUsers2[0]).toEqual(TEST_USER_CLEAN); }); @@ -439,7 +439,7 @@ describe('Database clients', () => { await ensureUserExists(); await db.deleteUser(TEST_USER.username); - const users = await db.getUsers(); + const { data: users } = await db.getUsers(); const cleanUsers = cleanResponseData(TEST_USER, users as any); expect(cleanUsers).not.toContainEqual(TEST_USER); }); @@ -473,7 +473,7 @@ describe('Database clients', () => { await db.updateUser(updateToApply); - const users = await db.getUsers(); + const { data: users } = await db.getUsers(); const cleanUsers = cleanResponseData(updatedUser, users); expect(cleanUsers).toContainEqual(updatedUser); @@ -485,7 +485,7 @@ describe('Database clients', () => { await db.deleteUser(TEST_USER.username); await db.updateUser(TEST_USER); - const users = await db.getUsers(); + const { data: users } = await db.getUsers(); // remove password as it will have been hashed const { password: _, ...TEST_USER_CLEAN } = TEST_USER; const cleanUsers = cleanResponseData(TEST_USER_CLEAN, users); @@ -583,7 +583,7 @@ describe('Database clients', () => { it('should be able to create a push', async () => { await db.writeAudit(TEST_PUSH); - const pushes = await db.getPushes({}); + const { data: pushes } = await db.getPushes({}); const cleanPushes = cleanResponseData(TEST_PUSH, pushes); expect(cleanPushes).toContainEqual(TEST_PUSH); }, 20000); @@ -592,7 +592,7 @@ describe('Database clients', () => { // Create push await db.writeAudit(TEST_PUSH); await db.deletePush(TEST_PUSH.id); - const pushes = await db.getPushes({}); + const { data: pushes } = await db.getPushes({}); const cleanPushes = cleanResponseData(TEST_PUSH, pushes); expect(cleanPushes).not.toContainEqual(TEST_PUSH); }); diff --git a/test/testProxy.test.ts b/test/testProxy.test.ts index 4f4883c16..118b33e56 100644 --- a/test/testProxy.test.ts +++ b/test/testProxy.test.ts @@ -171,7 +171,7 @@ describe('Proxy', () => { vi.mocked(config.getAuthorisedList).mockReturnValue([ { project: 'test-proj', name: 'test-repo', url: 'test-url' }, ]); - vi.mocked(db.getRepos).mockResolvedValue([]); + vi.mocked(db.getRepos).mockResolvedValue({ data: [], total: 0 }); vi.mocked(db.createRepo).mockResolvedValue({ _id: 'mock-repo-id', project: 'test-proj', diff --git a/test/testProxyRoute.test.ts b/test/testProxyRoute.test.ts index 32f720015..93942779a 100644 --- a/test/testProxyRoute.test.ts +++ b/test/testProxyRoute.test.ts @@ -153,7 +153,7 @@ describe('proxy express application', () => { await cleanupRepo(TEST_GITLAB_REPO.url); // check that we don't have *any* repos at gitlab.com setup - const numExisting = (await db.getRepos({ url: /https:\/\/gitlab\.com/ as any })).length; + const numExisting = (await db.getRepos({ url: /https:\/\/gitlab\.com/ as any })).data.length; expect(numExisting).toBe(0); // create the repo through the API, which should force the proxy to restart to handle the new domain @@ -168,7 +168,7 @@ describe('proxy express application', () => { expect(repo).not.toBeNull(); // and that our initial query for repos would have picked it up - const numCurrent = (await db.getRepos({ url: /https:\/\/gitlab\.com/ as any })).length; + const numCurrent = (await db.getRepos({ url: /https:\/\/gitlab\.com/ as any })).data.length; expect(numCurrent).toBe(1); // proxy a request to the new repo @@ -263,7 +263,7 @@ describe('proxy express application', () => { await new Promise((r) => setTimeout(r, 200)); await proxy.start(); - const allRepos = await db.getRepos(); + const { data: allRepos } = await db.getRepos(); const matchingRepos = allRepos.filter((r) => r.url === TEST_DEFAULT_REPO.url); expect(matchingRepos).toHaveLength(1); diff --git a/test/testPush.test.ts b/test/testPush.test.ts index 8bf85788d..50bfbb30e 100644 --- a/test/testPush.test.ts +++ b/test/testPush.test.ts @@ -465,9 +465,9 @@ describe('Push API', () => { await loginAsApprover(); const res = await request(app).get('/api/v1/push').set('Cookie', `${cookie}`); expect(res.status).toBe(200); - expect(Array.isArray(res.body)).toBe(true); + expect(Array.isArray(res.body.pushes)).toBe(true); - const push = res.body.find((p: Action) => p.id === TEST_PUSH.id); + const push = res.body.pushes.find((p: Action) => p.id === TEST_PUSH.id); expect(push).toBeDefined(); // Check that all values in push are in TEST_PUSH, except for _id @@ -485,14 +485,13 @@ describe('Push API', () => { // Search for the overridden push const res = await request(app).get('/api/v1/push').set('Cookie', `${cookie}`).query({ limit: 1, - skip: 0, error: true, blocked: true, }); expect(res.status).toBe(200); - expect(Array.isArray(res.body)).toBe(true); + expect(Array.isArray(res.body.pushes)).toBe(true); - const push = res.body.find((p: Action) => p.id === TEST_PUSH.id); + const push = res.body.pushes.find((p: Action) => p.id === TEST_PUSH.id); expect(push).toBeDefined(); expect(push.error).toBe(true); expect(push.blocked).toBe(true); @@ -507,7 +506,7 @@ describe('Push API', () => { expect(res.status).toBe(200); const pushes = await request(app).get('/api/v1/push').set('Cookie', `${cookie}`); - const push = pushes.body.find((p: Action) => p.id === TEST_PUSH.id); + const push = pushes.body.pushes.find((p: Action) => p.id === TEST_PUSH.id); expect(push).toBeDefined(); expect(push.canceled).toBe(true); @@ -525,7 +524,7 @@ describe('Push API', () => { ); const pushes = await request(app).get('/api/v1/push').set('Cookie', `${cookie}`); - const push = pushes.body.find((p: Action) => p.id === TEST_PUSH.id); + const push = pushes.body.pushes.find((p: Action) => p.id === TEST_PUSH.id); expect(push).toBeDefined(); expect(push.canceled).toBe(false); diff --git a/test/testRepoApi.test.ts b/test/testRepoApi.test.ts index 11e4cf6bf..160a44172 100644 --- a/test/testRepoApi.test.ts +++ b/test/testRepoApi.test.ts @@ -168,9 +168,9 @@ describe('add new repo', () => { .set('Cookie', `${cookie}`) .query({ url: TEST_REPO.url }); expect(res.status).toBe(200); - expect(res.body[0].project).toBe(TEST_REPO.project); - expect(res.body[0].name).toBe(TEST_REPO.name); - expect(res.body[0].url).toBe(TEST_REPO.url); + expect(res.body.repos[0].project).toBe(TEST_REPO.project); + expect(res.body.repos[0].name).toBe(TEST_REPO.name); + expect(res.body.repos[0].url).toBe(TEST_REPO.url); }); it('add 1st can push user', async () => { @@ -537,6 +537,26 @@ describe('repo routes - edge cases', () => { expect(res.body.message).toBe('You are not authorised to perform this action.'); }); + it('should return 400 when username is missing from push user body', async () => { + const res = await request(app) + .patch(`/api/v1/repo/${repoId}/user/push`) + .set('Cookie', adminCookie) + .send({}); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('Username is required'); + }); + + it('should return 400 when username is not a string in push user body', async () => { + const res = await request(app) + .patch(`/api/v1/repo/${repoId}/user/push`) + .set('Cookie', adminCookie) + .send({ username: 123 }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('Username is required'); + }); + it('should return 401 when non-admin user tries to add authorise user', async () => { const res = await request(app) .patch(`/api/v1/repo/${repoId}/user/authorise`) @@ -556,6 +576,26 @@ describe('repo routes - edge cases', () => { expect(res.body.message).toBe('You are not authorised to perform this action.'); }); + it('should return 400 when username is missing from authorise user body', async () => { + const res = await request(app) + .patch(`/api/v1/repo/${repoId}/user/authorise`) + .set('Cookie', adminCookie) + .send({}); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('Username is required'); + }); + + it('should return 400 when username is not a string in authorise user body', async () => { + const res = await request(app) + .patch(`/api/v1/repo/${repoId}/user/authorise`) + .set('Cookie', adminCookie) + .send({ username: 123 }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('Username is required'); + }); + describe('DELETE /api/v1/repo/:id/user/push/:username', () => { beforeAll(async () => { // Add a user to remove diff --git a/test/ui/git-push.test.ts b/test/ui/git-push.test.ts index c4359feea..764e2bc52 100644 --- a/test/ui/git-push.test.ts +++ b/test/ui/git-push.test.ts @@ -117,12 +117,12 @@ describe('git-push service', () => { { id: 'push-2', steps: [] }, ]; - axiosMock.mockResolvedValue({ data: pushesData }); + axiosMock.mockResolvedValue({ data: { pushes: pushesData, total: pushesData.length } }); const result = await getPushes(); expect(result.success).toBe(true); - expect(result.data).toEqual(pushesData); + expect(result.data).toEqual({ pushes: pushesData, total: pushesData.length }); expect(axiosMock).toHaveBeenCalledWith( 'http://localhost:8080/api/v1/push?blocked=true&canceled=false&authorised=false&rejected=false', expect.any(Object), @@ -132,7 +132,7 @@ describe('git-push service', () => { it('returns array of pushes with custom query params', async () => { const pushesData = [{ id: 'push-1', steps: [] }]; - axiosMock.mockResolvedValue({ data: pushesData }); + axiosMock.mockResolvedValue({ data: { pushes: pushesData, total: pushesData.length } }); const result = await getPushes({ blocked: false, @@ -142,7 +142,7 @@ describe('git-push service', () => { }); expect(result.success).toBe(true); - expect(result.data).toEqual(pushesData); + expect(result.data).toEqual({ pushes: pushesData, total: pushesData.length }); expect(axiosMock).toHaveBeenCalledWith( 'http://localhost:8080/api/v1/push?blocked=false&canceled=true&authorised=true&rejected=false', expect.any(Object), diff --git a/test/ui/repo.test.ts b/test/ui/repo.test.ts index e4cd8fad6..dbf3c8c5e 100644 --- a/test/ui/repo.test.ts +++ b/test/ui/repo.test.ts @@ -149,25 +149,23 @@ describe('repo service additional functions', () => { }); describe('getRepos', () => { - it('returns sorted repos on success', async () => { + it('returns repos on success', async () => { const reposData = [ - { name: 'zebra-repo', project: 'org', url: 'https://example.com/org/zebra-repo.git' }, { name: 'alpha-repo', project: 'org', url: 'https://example.com/org/alpha-repo.git' }, + { name: 'zebra-repo', project: 'org', url: 'https://example.com/org/zebra-repo.git' }, ]; + const pagedData = { repos: reposData, total: 2 }; - axiosMock.mockResolvedValue({ data: reposData }); + axiosMock.mockResolvedValue({ data: pagedData }); const result = await getRepos(); expect(result.success).toBe(true); - expect(result.data).toEqual([ - { name: 'alpha-repo', project: 'org', url: 'https://example.com/org/alpha-repo.git' }, - { name: 'zebra-repo', project: 'org', url: 'https://example.com/org/zebra-repo.git' }, - ]); + expect(result.data).toEqual(pagedData); }); it('passes query parameters correctly', async () => { - axiosMock.mockResolvedValue({ data: [] }); + axiosMock.mockResolvedValue({ data: { repos: [], total: 0 } }); await getRepos({ active: true }); diff --git a/test/ui/user.test.ts b/test/ui/user.test.ts index d706c9492..da4cc7266 100644 --- a/test/ui/user.test.ts +++ b/test/ui/user.test.ts @@ -146,83 +146,60 @@ describe('user service', () => { describe('getUsers', () => { it('fetches all users successfully', async () => { - const usersData = [ - { id: 'user-1', username: 'alice', email: 'alice@example.com' }, - { id: 'user-2', username: 'bob', email: 'bob@example.com' }, - ]; - const setUsers = vi.fn(); - const setIsLoading = vi.fn(); - const setAuth = vi.fn(); - const setErrorMessage = vi.fn(); + const pagedData = { + users: [ + { id: 'user-1', username: 'alice', email: 'alice@example.com' }, + { id: 'user-2', username: 'bob', email: 'bob@example.com' }, + ], + total: 2, + }; - axiosMock.mockResolvedValue({ data: usersData }); + axiosMock.mockResolvedValue({ data: pagedData }); - await getUsers(setIsLoading, setUsers, setAuth, setErrorMessage); + const result = await getUsers(); expect(axiosMock).toHaveBeenCalledWith( 'http://localhost:8080/api/v1/user', expect.any(Object), ); - expect(setIsLoading).toHaveBeenCalledWith(true); - expect(setUsers).toHaveBeenCalledWith(usersData); - expect(setIsLoading).toHaveBeenCalledWith(false); + expect(result.success).toBe(true); + expect(result.data).toEqual(pagedData); }); it('handles 401 errors', async () => { - const setUsers = vi.fn(); - const setIsLoading = vi.fn(); - const setAuth = vi.fn(); - const setErrorMessage = vi.fn(); - axiosMock.mockRejectedValue({ response: { status: 401, - data: { - message: 'Not authenticated', - }, + data: { message: 'Not authenticated' }, }, }); - await getUsers(setIsLoading, setUsers, setAuth, setErrorMessage); + const result = await getUsers(); - expect(setAuth).toHaveBeenCalledWith(false); - expect(setErrorMessage).toHaveBeenCalledWith('Auth error: Not authenticated'); - expect(setIsLoading).toHaveBeenCalledWith(false); + expect(result.success).toBe(false); + expect(result.status).toBe(401); }); it('handles non-401 errors', async () => { - const setUsers = vi.fn(); - const setIsLoading = vi.fn(); - const setAuth = vi.fn(); - const setErrorMessage = vi.fn(); - axiosMock.mockRejectedValue({ response: { status: 500, - data: { - message: 'Database error', - }, + data: { message: 'Database error' }, }, }); - await getUsers(setIsLoading, setUsers, setAuth, setErrorMessage); + const result = await getUsers(); - expect(setErrorMessage).toHaveBeenCalledWith('Error fetching users: 500 Database error'); - expect(setIsLoading).toHaveBeenCalledWith(false); + expect(result.success).toBe(false); + expect(result.status).toBe(500); }); it('sets loading to false even when error occurs', async () => { - const setUsers = vi.fn(); - const setIsLoading = vi.fn(); - const setAuth = vi.fn(); - const setErrorMessage = vi.fn(); - axiosMock.mockRejectedValue(new Error('Network error')); - await getUsers(setIsLoading, setUsers, setAuth, setErrorMessage); + const result = await getUsers(); - expect(setIsLoading).toHaveBeenCalledWith(true); - expect(setIsLoading).toHaveBeenCalledWith(false); + expect(result.success).toBe(false); }); }); diff --git a/tests/e2e/push.test.ts b/tests/e2e/push.test.ts index a824af82a..c3ab9649a 100644 --- a/tests/e2e/push.test.ts +++ b/tests/e2e/push.test.ts @@ -197,7 +197,7 @@ describe('Git Proxy E2E - Repository Push Tests', () => { /** * Helper function to get repositories */ - async function getRepos(sessionCookie: string): Promise { + async function getRepos(sessionCookie: string): Promise<{ repos: any[]; total: number }> { const response = await fetch(`${testConfig.gitProxyUiUrl}/api/v1/repo`, { headers: { Cookie: sessionCookie }, }); @@ -262,7 +262,7 @@ describe('Git Proxy E2E - Repository Push Tests', () => { } // Get the test-repo repository and add permissions - const repos = await getRepos(adminCookie); + const { repos } = await getRepos(adminCookie); const testRepo = repos.find( (r: any) => r.url === 'https://git-server:8443/test-owner/test-repo.git', );