diff --git a/backend/package-lock.json b/backend/package-lock.json index 97712df54..697ced742 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -79,6 +79,7 @@ "@types/morgan": "^1.9.9", "@types/node": "^22.7.5", "@types/nodemailer": "^6.4.16", + "@types/semver": "^7.5.8", "@types/shelljs": "^0.8.15", "@types/supertest": "^6.0.2", "@types/uuid": "^10.0.0", @@ -7268,6 +7269,13 @@ "@types/node": "*" } }, + "node_modules/@types/semver": { + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", + "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/send": { "version": "0.17.1", "license": "MIT", diff --git a/backend/package.json b/backend/package.json index 3cfaf5c03..c6fbf21bb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -88,6 +88,7 @@ "@types/mjml": "^4.7.4", "@types/mongoose-delete": "^1.0.6", "@types/morgan": "^1.9.9", + "@types/semver": "^7.5.8", "@types/node": "^22.7.5", "@types/nodemailer": "^6.4.16", "@types/shelljs": "^0.8.15", diff --git a/backend/src/migrations/012_convert_semver_string_to_object.ts b/backend/src/migrations/012_convert_semver_string_to_object.ts new file mode 100644 index 000000000..f5fc27abb --- /dev/null +++ b/backend/src/migrations/012_convert_semver_string_to_object.ts @@ -0,0 +1,16 @@ +import Release from '../models/Release.js' + +export async function up() { + const releases = await Release.find({}) + for (const release of releases) { + const semver = release.get('semver') + if (semver !== undefined && typeof semver === typeof '') { + release.set('semver', semver) + await release.save() + } + } +} + +export async function down() { + /* NOOP */ +} diff --git a/backend/src/models/Release.ts b/backend/src/models/Release.ts index 1b8cec811..061991048 100644 --- a/backend/src/models/Release.ts +++ b/backend/src/models/Release.ts @@ -1,6 +1,8 @@ import { Document, model, Schema } from 'mongoose' import MongooseDelete from 'mongoose-delete' +import { semverObjectToString, semverStringToObject } from '../services/release.js' + // This interface stores information about the properties on the base object. // It should be used for plain object representations, e.g. for sending to the // client. @@ -35,12 +37,31 @@ export interface ImageRef { // object from Mongoose it should use this interface export type ReleaseDoc = ReleaseInterface & Document -const ReleaseSchema = new Schema( +export interface SemverObject { + major: number + minor: number + patch: number + metadata?: string +} + +const ReleaseSchema = new Schema( { modelId: { type: String, required: true }, modelCardVersion: { type: Number, required: true }, - semver: { type: String, required: true }, + semver: { + type: Schema.Types.Mixed, + required: true, + set: function (semver: string) { + return semverStringToObject(semver) + }, + get: function (semver: SemverObject | string) { + if (typeof semver === 'string') { + return semver + } else return semverObjectToString(semver) + }, + }, + notes: { type: String, required: true }, minor: { type: Boolean, required: true }, @@ -60,6 +81,8 @@ const ReleaseSchema = new Schema( { timestamps: true, collection: 'v2_releases', + toJSON: { getters: true }, + toObject: { getters: true }, }, ) diff --git a/backend/src/routes/v2/release/getReleases.ts b/backend/src/routes/v2/release/getReleases.ts index 426ee1832..f4aa42199 100644 --- a/backend/src/routes/v2/release/getReleases.ts +++ b/backend/src/routes/v2/release/getReleases.ts @@ -17,6 +17,11 @@ export const getReleasesSchema = z.object({ required_error: 'Must specify model id as URL parameter', }), }), + query: z.object({ + querySemver: z.string().optional().openapi({ example: '>2.2.2' }).openapi({ + description: 'Query for semver ranges, as described in https://docs.npmjs.com/cli/v6/using-npm/semver#ranges', + }), + }), }) registerPath({ @@ -49,9 +54,10 @@ export const getReleases = [ req.audit = AuditInfo.ViewReleases const { params: { modelId }, + query: { querySemver }, } = parse(req, getReleasesSchema) - const releases = await getModelReleases(req.user, modelId) + const releases = await getModelReleases(req.user, modelId, querySemver) await audit.onViewReleases(req, releases) return res.json({ diff --git a/backend/src/services/release.ts b/backend/src/services/release.ts index a51f63c4f..fc5fbd305 100644 --- a/backend/src/services/release.ts +++ b/backend/src/services/release.ts @@ -3,9 +3,9 @@ import { Optional } from 'utility-types' import { ReleaseAction } from '../connectors/authorisation/actions.js' import authorisation from '../connectors/authorisation/index.js' -import { FileInterface } from '../models/File.js' +import { FileInterface, FileInterfaceDoc } from '../models/File.js' import { ModelDoc, ModelInterface } from '../models/Model.js' -import Release, { ImageRef, ReleaseDoc, ReleaseInterface } from '../models/Release.js' +import Release, { ImageRef, ReleaseDoc, ReleaseInterface, SemverObject } from '../models/Release.js' import ResponseModel, { ResponseKind } from '../models/Response.js' import { UserInterface } from '../models/User.js' import { WebhookEvent } from '../models/Webhook.js' @@ -55,7 +55,7 @@ async function validateRelease(user: UserInterface, model: ModelDoc, release: Re const fileNames: Array = [] for (const fileId of release.fileIds) { - let file + let file: FileInterfaceDoc | undefined try { file = await getFileById(user, fileId) } catch (e) { @@ -195,8 +195,8 @@ export async function updateRelease(user: UserInterface, modelId: string, semver modelId: modelId, }) } - - const updatedRelease = await Release.findOneAndUpdate({ modelId, semver }, { $set: release }) + const semverObj = semverStringToObject(semver) + const updatedRelease = await Release.findOneAndUpdate({ modelId, semver: semverObj }, { $set: release }) if (!updatedRelease) { throw NotFound(`The requested release was not found.`, { modelId, semver }) @@ -211,7 +211,8 @@ export async function newReleaseComment(user: UserInterface, modelId: string, se throw BadReq(`Cannot create a new comment on a mirrored model.`) } - const release = await Release.findOne({ modelId, semver }) + const semverObj = semverStringToObject(semver) + const release = await Release.findOne({ modelId, semver: semverObj }) if (!release) { throw NotFound(`The requested release was not found.`, { modelId, semver }) } @@ -237,25 +238,37 @@ export async function newReleaseComment(user: UserInterface, modelId: string, se export async function getModelReleases( user: UserInterface, modelId: string, + querySemver?: string, ): Promise> { + const query = querySemver === undefined ? { modelId } : convertSemverQueryToMongoQuery(querySemver, modelId) const results = await Release.aggregate() - .match({ modelId }) + .match(query) .sort({ updatedAt: -1 }) .lookup({ from: 'v2_models', localField: 'modelId', foreignField: 'id', as: 'model' }) - .append({ $set: { model: { $arrayElemAt: ['$model', 0] } } }) .lookup({ from: 'v2_files', localField: 'fileIds', foreignField: '_id', as: 'files' }) + .append({ $set: { model: { $arrayElemAt: ['$model', 0] } } }) const model = await getModelById(user, modelId) const auths = await authorisation.releases(user, model, results, ReleaseAction.View) - return results.filter((_, i) => auths[i].success) + return results.reduce>( + (updatedResults, result, index) => { + if (auths[index].success) { + updatedResults.push({ ...result, semver: semverObjectToString(result.semver) }) + } + + return updatedResults + }, + [], + ) } export async function getReleasesForExport(user: UserInterface, modelId: string, semvers: string[]) { const model = await getModelById(user, modelId) + const semverObjs = semvers.map((semver) => semverStringToObject(semver)) const releases = await Release.find({ modelId, - semver: semvers, + semver: semverObjs, }) const missing = semvers.filter((x) => !releases.some((release) => release.semver === x)) @@ -276,11 +289,36 @@ export async function getReleasesForExport(user: UserInterface, modelId: string, return releases } +export function semverStringToObject(semver: string): SemverObject { + const vIdentifierIndex = semver.indexOf('v') + const trimmedSemver = vIdentifierIndex === -1 ? semver : semver.slice(vIdentifierIndex + 1) + const [version, metadata] = trimmedSemver.split('-') + const [major, minor, patch] = version.split('.') + const majorNum: number = Number(major) + const minorNum: number = Number(minor) + const patchNum: number = Number(patch) + return { major: majorNum, minor: minorNum, patch: patchNum, ...(metadata && { metadata }) } +} + +export function semverObjectToString(semver: SemverObject): string { + if (!semver) { + return '' + } + let metadata = '' + if (semver.metadata != undefined) { + metadata = `-${semver.metadata}` + } else { + metadata = `` + } + return `${semver.major}.${semver.minor}.${semver.patch}${metadata}` +} + export async function getReleaseBySemver(user: UserInterface, modelId: string, semver: string) { const model = await getModelById(user, modelId) + const semverObj = semverStringToObject(semver) const release = await Release.findOne({ modelId, - semver, + semver: semverObj, }) if (!release) { @@ -295,6 +333,136 @@ export async function getReleaseBySemver(user: UserInterface, modelId: string, s return release } +function parseSemverQuery(querySemver: string) { + const semverRangeStandardised = semver.validRange(querySemver, { includePrerelease: false }) + + if (!semverRangeStandardised) { + throw BadReq('Semver range is invalid.', { semverQuery: querySemver }) + } + + const [expressionA, expressionB] = semverRangeStandardised.split(' ') + + let lowerInclusivity: boolean = false + let upperInclusivity: boolean = false + let lowerSemverObj: SemverObject | undefined + let upperSemverObj: SemverObject | undefined + + //LOWER SEMVER + if (expressionA.includes('>')) { + lowerInclusivity = expressionA.includes('>=') + lowerSemverObj = semverStringToObject(expressionA.replace(/[<>=]/g, '')) + } else { + //upper semver + upperInclusivity = expressionA.includes('<=') + upperSemverObj = semverStringToObject(expressionA.replace(/[<=]/g, '')) + } + + if (expressionB) { + upperInclusivity = expressionB.includes('<=') + upperSemverObj = semverStringToObject(expressionB.replace(/[<=]/g, '')) + } + + return { lowerSemverObj, upperSemverObj, lowerInclusivity, upperInclusivity } +} + +interface QueryBoundInterface { + $or: ( + | { + 'semver.major': + | { + $lte: number + } + | { + $gte: number + } + 'semver.minor': + | { + $lte: number + } + | { $gte: number } + 'semver.patch': + | { + $gte: number + } + | { $gt: number } + | { $lte: number } + | { $lt: number } + } + | { + 'semver.major': + | { + $gt: number + } + | { $lt: number } + } + | { + 'semver.major': + | { + $gte: number + } + | { $lte: number } + 'semver.minor': + | { + $gt: number + } + | { $lt: number } + } + )[] +} + +function convertSemverQueryToMongoQuery(querySemver: string, modelID: string) { + const { lowerSemverObj, upperSemverObj, lowerInclusivity, upperInclusivity } = parseSemverQuery(querySemver) + + const queryQueue: QueryBoundInterface[] = [] + + if (lowerSemverObj) { + const lowerQuery: QueryBoundInterface = { + $or: [ + { + 'semver.major': { $gte: lowerSemverObj.major }, + 'semver.minor': { $gte: lowerSemverObj.minor }, + 'semver.patch': lowerInclusivity ? { $gte: lowerSemverObj.patch } : { $gt: lowerSemverObj.patch }, + }, + { + 'semver.major': { $gt: lowerSemverObj.major }, + }, + { + 'semver.major': { $gte: lowerSemverObj.major }, + 'semver.minor': { $gt: lowerSemverObj.minor }, + }, + ], + } + queryQueue.push(lowerQuery) + } + + if (upperSemverObj) { + const upperQuery: QueryBoundInterface = { + $or: [ + { + 'semver.major': { $lte: upperSemverObj.major }, + 'semver.minor': { $lte: upperSemverObj.minor }, + 'semver.patch': upperInclusivity ? { $lte: upperSemverObj.patch } : { $lt: upperSemverObj.patch }, + }, + { + 'semver.major': { $lt: upperSemverObj.major }, + }, + { + 'semver.major': { $lte: upperSemverObj.major }, + 'semver.minor': { $lt: upperSemverObj.minor }, + }, + ], + } + queryQueue.push(upperQuery) + } + + const combinedQuery = { + modelId: modelID, + $and: queryQueue, + } + + return combinedQuery +} + export async function deleteRelease(user: UserInterface, modelId: string, semver: string) { const model = await getModelById(user, modelId) if (model.settings.mirror.sourceModelId) { @@ -358,8 +526,9 @@ export async function getFileByReleaseFileName(user: UserInterface, modelId: str } export async function getAllFileIds(modelId: string, semvers: string[]) { + const semverObjs = semvers.map((semver) => semverStringToObject(semver)) const result = await Release.aggregate() - .match({ modelId, semver: { $in: semvers } }) + .match({ modelId, semver: { $in: semverObjs } }) .unwind({ path: '$fileIds' }) .group({ _id: null, diff --git a/backend/src/utils/__mocks__/config.ts b/backend/src/utils/__mocks__/config.ts index 57dfdc45c..092656b5f 100644 --- a/backend/src/utils/__mocks__/config.ts +++ b/backend/src/utils/__mocks__/config.ts @@ -36,6 +36,19 @@ const config: PartialDeep = { kinds: [], }, }, + smtp: { + enabled: true, + connection: { + host: 'localhost', + port: 1025, + secure: false, + auth: undefined, + tls: { + rejectUnauthorized: false, + }, + }, + from: '"Bailo 📝" ', + }, log: { level: 'debug', }, @@ -79,6 +92,18 @@ const config: PartialDeep = { userIdAttribute: '', }, }, + avScanning: { + clamdscan: { + host: '127.0.0.1', + port: 8080, + }, + + modelscan: { + protocol: 'http', + host: '127.0.0.1', + port: 8081, + }, + }, mongo: { uri: 'mongodb://localhost:27017/bailo?directConnection=true', user: undefined, diff --git a/backend/test/services/__snapshots__/release.spec.ts.snap b/backend/test/services/__snapshots__/release.spec.ts.snap index ea07e12b0..2f64032d4 100644 --- a/backend/test/services/__snapshots__/release.spec.ts.snap +++ b/backend/test/services/__snapshots__/release.spec.ts.snap @@ -1,5 +1,109 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`services > release > getModelReleases > convertSemverQueryToMongoQuery functions 1`] = ` +[ + { + "$and": [ + { + "$or": [ + { + "semver.major": { + "$gte": 2, + }, + "semver.minor": { + "$gte": 2, + }, + "semver.patch": { + "$gte": 0, + }, + }, + { + "semver.major": { + "$gt": 2, + }, + }, + { + "semver.major": { + "$gte": 2, + }, + "semver.minor": { + "$gt": 2, + }, + }, + ], + }, + { + "$or": [ + { + "semver.major": { + "$lte": 2, + }, + "semver.minor": { + "$lte": 3, + }, + "semver.patch": { + "$lt": 0, + }, + }, + { + "semver.major": { + "$lt": 2, + }, + }, + { + "semver.major": { + "$lte": 2, + }, + "semver.minor": { + "$lt": 3, + }, + }, + ], + }, + ], + "modelId": "modelId", + }, +] +`; + +exports[`services > release > getModelReleases > convertSemverQueryToMongoQuery functions with less than 1`] = ` +[ + { + "$and": [ + { + "$or": [ + { + "semver.major": { + "$lte": 2, + }, + "semver.minor": { + "$lte": 2, + }, + "semver.patch": { + "$lt": 2, + }, + }, + { + "semver.major": { + "$lt": 2, + }, + }, + { + "semver.major": { + "$lte": 2, + }, + "semver.minor": { + "$lt": 2, + }, + }, + ], + }, + ], + "modelId": "modelID", + }, +] +`; + exports[`services > release > getModelReleases > good 1`] = ` [ { diff --git a/backend/test/services/release.spec.ts b/backend/test/services/release.spec.ts index 34d34d1bd..19ad1b184 100644 --- a/backend/test/services/release.spec.ts +++ b/backend/test/services/release.spec.ts @@ -2,6 +2,8 @@ import { describe, expect, test, vi } from 'vitest' import { ReleaseAction } from '../../src/connectors/authorisation/actions.js' import authorisation from '../../src/connectors/authorisation/index.js' +import { SemverObject } from '../../src/models/Release.js' +import { UserInterface } from '../../src/models/User.js' import { createRelease, deleteRelease, @@ -12,6 +14,7 @@ import { getReleasesForExport, newReleaseComment, removeFileFromReleases, + semverObjectToString, updateRelease, } from '../../src/services/release.js' @@ -342,11 +345,8 @@ describe('services > release', () => { }) test('getModelReleases > good', async () => { - await getModelReleases({} as any, 'modelId') - - vi.mocked(releaseModelMocks.lookup).mockImplementation(() => ({ - ...releaseModelMocks.lookup, - })) + vi.mocked(releaseModelMocks.append).mockReturnValueOnce([]) + await getModelReleases({ dn: 'user' } as UserInterface, 'modelId') expect(releaseModelMocks.match.mock.calls.at(0)).toMatchSnapshot() expect(releaseModelMocks.sort.mock.calls.at(0)).toMatchSnapshot() @@ -354,6 +354,41 @@ describe('services > release', () => { expect(releaseModelMocks.append.mock.calls.at(0)).toMatchSnapshot() }) + test('semverObjectToString > deals with edge cases', async () => { + const semObj: SemverObject = { + major: 1, + minor: 1, + patch: 1, + metadata: 'test', + } + const semObj2: SemverObject = { + major: 1, + minor: 1, + patch: 1, + } + expect(semverObjectToString(semObj)).toBe('1.1.1-test') + expect(semverObjectToString(undefined as any)).toBe('') + expect(semverObjectToString(semObj2)).toBe('1.1.1') + }) + + test('getModelReleases > convertSemverQueryToMongoQuery functions', async () => { + vi.mocked(releaseModelMocks.append).mockReturnValueOnce([]) + await getModelReleases({ dn: 'user' } as UserInterface, 'modelId', '2.2.X') + expect(releaseModelMocks.match.mock.calls.at(0)).toMatchSnapshot() + }) + + test('getModelReleases > convertSemverQueryToMongoQuery functions with less than', async () => { + vi.mocked(releaseModelMocks.append).mockReturnValueOnce([]) + await getModelReleases({ dn: 'user' } as UserInterface, 'modelID', '<2.2.2') + expect(releaseModelMocks.match.mock.calls.at(0)).toMatchSnapshot() + }) + + test('convertSemverQueryToMongoQuery > convertSemverQueryToMongoQuery handles bad semver', async () => { + expect(async () => await getModelReleases({ dn: 'user' } as UserInterface, 'test', '^2.2v.x')).rejects.toThrowError( + /^Semver range is invalid./, + ) + }) + test('getReleaseBySemver > good', async () => { const mockRelease = { _id: 'release' } diff --git a/backend/test/services/smtp/smtp.spec.ts b/backend/test/services/smtp/smtp.spec.ts index 9e0366c94..d6daac004 100644 --- a/backend/test/services/smtp/smtp.spec.ts +++ b/backend/test/services/smtp/smtp.spec.ts @@ -12,35 +12,6 @@ import { import config from '../../../src/utils/config.js' import { testReviewResponse } from '../../testUtils/testModels.js' -vi.mock('../../../src/utils/config.js', () => { - return { - __esModule: true, - default: { - app: { - protocol: '', - host: '', - port: 3000, - }, - - smtp: { - enabled: true, - - connection: { - host: 'localhost', - port: 1025, - secure: false, - auth: undefined, - tls: { - rejectUnauthorized: false, - }, - }, - - from: '"Bailo 📝" ', - }, - }, - } -}) - const logMock = vi.hoisted(() => ({ info: vi.fn(), warn: vi.fn(),