diff --git a/src/db/mongo/pushes.ts b/src/db/mongo/pushes.ts index 968b2858a..191d2de03 100644 --- a/src/db/mongo/pushes.ts +++ b/src/db/mongo/pushes.ts @@ -35,7 +35,7 @@ export const getPushes = async ( rejected: 1, repo: 1, repoName: 1, - timepstamp: 1, + timestamp: 1, type: 1, url: 1, }, diff --git a/src/service/routes/auth.ts b/src/service/routes/auth.ts index 9835af3c8..a80d91e87 100644 --- a/src/service/routes/auth.ts +++ b/src/service/routes/auth.ts @@ -144,7 +144,7 @@ router.get('/profile', async (req: Request, res: Response) => { const userVal = await db.findUser((req.user as User).username); if (!userVal) { - res.status(404).send('User not found').end(); + res.status(404).send({ message: 'User not found' }).end(); return; } diff --git a/test/db/mongo/helper.test.ts b/test/db/mongo/helper.test.ts new file mode 100644 index 000000000..4937f5457 --- /dev/null +++ b/test/db/mongo/helper.test.ts @@ -0,0 +1,315 @@ +import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest'; +import { MongoClient } from 'mongodb'; + +const mockCollection = { + find: vi.fn(), + findOne: vi.fn(), +}; + +const mockDb = { + collection: vi.fn(() => mockCollection), +}; + +const mockClient = { + connect: vi.fn().mockResolvedValue(undefined), + db: vi.fn(() => mockDb), +}; + +const mockToArray = vi.fn(); + +vi.mock('mongodb', async () => { + const actual = await vi.importActual('mongodb'); + return { + ...actual, + MongoClient: vi.fn(() => mockClient), + }; +}); + +const mockGetDatabase = vi.fn(); + +vi.mock('../../../src/config', () => ({ + getDatabase: mockGetDatabase, +})); + +const mockFromNodeProviderChain = vi.fn(); + +vi.mock('@aws-sdk/credential-providers', () => ({ + fromNodeProviderChain: mockFromNodeProviderChain, +})); + +const mockMongoDBStore = vi.fn(); + +vi.mock('connect-mongo', () => ({ + default: mockMongoDBStore, +})); + +describe('MongoDB Helper', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCollection.find.mockReturnValue({ toArray: mockToArray }); + + // Clear cached db + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('connect', () => { + it('should connect to MongoDB and return collection', async () => { + mockGetDatabase.mockReturnValue({ + connectionString: 'mongodb://localhost:27017/testdb', + options: {}, + }); + + const { connect } = await import('../../../src/db/mongo/helper'); + + const result = await connect('testCollection'); + + expect(MongoClient).toHaveBeenCalledWith('mongodb://localhost:27017/testdb', {}); + expect(mockClient.connect).toHaveBeenCalled(); + expect(mockClient.db).toHaveBeenCalled(); + expect(mockDb.collection).toHaveBeenCalledWith('testCollection'); + expect(result).toBe(mockCollection); + }); + + it('should reuse existing connection', async () => { + mockGetDatabase.mockReturnValue({ + connectionString: 'mongodb://localhost:27017/testdb', + options: {}, + }); + + const { connect } = await import('../../../src/db/mongo/helper'); + + await connect('collection1'); + + vi.clearAllMocks(); + mockDb.collection.mockReturnValue(mockCollection); + + await connect('collection2'); + + expect(MongoClient).not.toHaveBeenCalled(); + expect(mockClient.connect).not.toHaveBeenCalled(); + expect(mockDb.collection).toHaveBeenCalledWith('collection2'); + }); + + it('should throw error when connection string is not provided', async () => { + mockGetDatabase.mockReturnValue({ + connectionString: '', + options: {}, + }); + + const { connect } = await import('../../../src/db/mongo/helper'); + + await expect(connect('testCollection')).rejects.toThrow( + 'MongoDB connection string is not provided', + ); + }); + + it('should throw error when connection string is undefined', async () => { + mockGetDatabase.mockReturnValue({ + connectionString: undefined, + options: {}, + }); + + const { connect } = await import('../../../src/db/mongo/helper'); + + await expect(connect('testCollection')).rejects.toThrow( + 'MongoDB connection string is not provided', + ); + }); + + it('should handle AWS credential provider', async () => { + const mockCredentialProvider = vi.fn(); + mockFromNodeProviderChain.mockReturnValue(mockCredentialProvider); + + mockGetDatabase.mockReturnValue({ + connectionString: 'mongodb://localhost:27017/testdb', + options: { + authMechanismProperties: { + AWS_CREDENTIAL_PROVIDER: 'placeholder', + }, + }, + }); + + const { connect } = await import('../../../src/db/mongo/helper'); + + await connect('testCollection'); + + expect(mockFromNodeProviderChain).toHaveBeenCalled(); + expect(MongoClient).toHaveBeenCalledWith( + 'mongodb://localhost:27017/testdb', + expect.objectContaining({ + authMechanismProperties: { + AWS_CREDENTIAL_PROVIDER: mockCredentialProvider, + }, + }), + ); + }); + + it('should pass options to MongoClient', async () => { + const options = { + maxPoolSize: 10, + minPoolSize: 5, + serverSelectionTimeoutMS: 5000, + }; + + mockGetDatabase.mockReturnValue({ + connectionString: 'mongodb://localhost:27017/testdb', + options, + }); + + const { connect } = await import('../../../src/db/mongo/helper'); + + await connect('testCollection'); + + expect(MongoClient).toHaveBeenCalledWith('mongodb://localhost:27017/testdb', options); + }); + }); + + describe('findDocuments', () => { + beforeEach(async () => { + mockGetDatabase.mockReturnValue({ + connectionString: 'mongodb://localhost:27017/testdb', + options: {}, + }); + }); + + it('should find documents with default filter and options', async () => { + const mockDocs = [ + { id: 1, name: 'test1' }, + { id: 2, name: 'test2' }, + ]; + mockToArray.mockResolvedValue(mockDocs); + + const { findDocuments } = await import('../../../src/db/mongo/helper'); + + const result = await findDocuments('testCollection'); + + expect(mockDb.collection).toHaveBeenCalledWith('testCollection'); + expect(mockCollection.find).toHaveBeenCalledWith({}, {}); + expect(mockToArray).toHaveBeenCalled(); + expect(result).toEqual(mockDocs); + }); + + it('should find documents with custom filter', async () => { + const mockDocs = [{ id: 1, name: 'test1' }]; + mockToArray.mockResolvedValue(mockDocs); + + const { findDocuments } = await import('../../../src/db/mongo/helper'); + + const filter = { name: 'test1' }; + const result = await findDocuments('testCollection', filter); + + expect(mockCollection.find).toHaveBeenCalledWith(filter, {}); + expect(result).toEqual(mockDocs); + }); + + it('should find documents with custom options', async () => { + const mockDocs = [{ id: 1, name: 'test1' }]; + mockToArray.mockResolvedValue(mockDocs); + + const { findDocuments } = await import('../../../src/db/mongo/helper'); + + const filter = { name: 'test1' }; + const options = { projection: { _id: 0, name: 1 }, limit: 10 }; + const result = await findDocuments('testCollection', filter, options); + + expect(mockCollection.find).toHaveBeenCalledWith(filter, options); + expect(result).toEqual(mockDocs); + }); + + it('should return empty array when no documents found', async () => { + mockToArray.mockResolvedValue([]); + + const { findDocuments } = await import('../../../src/db/mongo/helper'); + + const result = await findDocuments('testCollection'); + + expect(result).toEqual([]); + }); + }); + + describe('findOneDocument', () => { + beforeEach(async () => { + mockGetDatabase.mockReturnValue({ + connectionString: 'mongodb://localhost:27017/testdb', + options: {}, + }); + }); + + it('should find one document with default filter and options', async () => { + const mockDoc = { id: 1, name: 'test1' }; + mockCollection.findOne.mockResolvedValue(mockDoc); + + const { findOneDocument } = await import('../../../src/db/mongo/helper'); + + const result = await findOneDocument('testCollection'); + + expect(mockDb.collection).toHaveBeenCalledWith('testCollection'); + expect(mockCollection.findOne).toHaveBeenCalledWith({}, {}); + expect(result).toEqual(mockDoc); + }); + + it('should find one document with custom filter', async () => { + const mockDoc = { id: 1, name: 'test1' }; + mockCollection.findOne.mockResolvedValue(mockDoc); + + const { findOneDocument } = await import('../../../src/db/mongo/helper'); + + const filter = { id: 1 }; + const result = await findOneDocument('testCollection', filter); + + expect(mockCollection.findOne).toHaveBeenCalledWith(filter, {}); + expect(result).toEqual(mockDoc); + }); + + it('should find one document with custom options', async () => { + const mockDoc = { id: 1, name: 'test1' }; + mockCollection.findOne.mockResolvedValue(mockDoc); + + const { findOneDocument } = await import('../../../src/db/mongo/helper'); + + const filter = { id: 1 }; + const options = { projection: { _id: 0, name: 1 } }; + const result = await findOneDocument('testCollection', filter, options); + + expect(mockCollection.findOne).toHaveBeenCalledWith(filter, options); + expect(result).toEqual(mockDoc); + }); + + it('should return null when document not found', async () => { + mockCollection.findOne.mockResolvedValue(null); + + const { findOneDocument } = await import('../../../src/db/mongo/helper'); + + const result = await findOneDocument('testCollection', { id: 999 }); + + expect(result).toBeNull(); + }); + }); + + describe('getSessionStore', () => { + it('should create MongoDBStore with connection string and options', async () => { + const connectionString = 'mongodb://localhost:27017/testdb'; + const options = { maxPoolSize: 10 }; + + mockGetDatabase.mockReturnValue({ + connectionString, + options, + }); + + const { getSessionStore } = await import('../../../src/db/mongo/helper'); + + const result = getSessionStore(); + + expect(result).toBeDefined(); + expect(mockMongoDBStore).toHaveBeenCalledWith({ + mongoUrl: connectionString, + collectionName: 'user_session', + mongoOptions: options, + }); + }); + }); +}); diff --git a/test/db/mongo/push.test.ts b/test/db/mongo/push.test.ts new file mode 100644 index 000000000..263ecaf34 --- /dev/null +++ b/test/db/mongo/push.test.ts @@ -0,0 +1,292 @@ +import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest'; +import { Action } from '../../../src/proxy/actions'; + +const mockFindOne = vi.fn(); +const mockDeleteOne = vi.fn(); +const mockUpdateOne = vi.fn(); +const mockFind = vi.fn(); + +const mockConnect = vi.fn(() => ({ + findOne: mockFindOne, + deleteOne: mockDeleteOne, + updateOne: mockUpdateOne, + find: mockFind, +})); + +const mockFindDocuments = vi.fn(); +const mockFindOneDocument = vi.fn(); + +vi.mock('../../../src/db/mongo/helper', () => ({ + connect: mockConnect, + findDocuments: mockFindDocuments, + findOneDocument: mockFindOneDocument, +})); + +const mockToClass = vi.fn((doc, proto) => Object.assign(Object.create(proto), doc)); + +vi.mock('../../../src/db/helper', () => ({ + toClass: mockToClass, +})); + +describe('MongoDB Push Handler', async () => { + const { getPushes, getPush, deletePush, writeAudit, authorise, reject, cancel } = + await import('../../../src/db/mongo/pushes'); + + const TEST_PUSH = { + id: 'test-push-123', + allowPush: false, + authorised: false, + blocked: true, + blockedMessage: 'Test blocked message', + branch: 'main', + canceled: false, + commitData: [], + commitFrom: 'abc123', + commitTo: 'def456', + error: false, + method: 'POST', + project: 'test-project', + rejected: false, + repo: 'test-repo', + repoName: 'test-repo-name', + timestamp: 1744380903338, + type: 'push', + url: 'https://example.com', + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getPushes', () => { + it('should get pushes with default query', async () => { + const mockPushes = [TEST_PUSH]; + mockFindDocuments.mockResolvedValue(mockPushes); + + 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, + }, + }, + ); + expect(result).toEqual(mockPushes); + }); + + it('should get pushes with custom query', async () => { + const customQuery = { error: true }; + const mockPushes = [TEST_PUSH]; + mockFindDocuments.mockResolvedValue(mockPushes); + + const result = await getPushes(customQuery); + + expect(mockFindDocuments).toHaveBeenCalledWith( + 'pushes', + customQuery, + expect.objectContaining({ + projection: expect.any(Object), + }), + ); + expect(result).toEqual(mockPushes); + }); + }); + + describe('getPush', () => { + it('should get a single push by id', async () => { + mockFindOneDocument.mockResolvedValue(TEST_PUSH); + + const result = await getPush(TEST_PUSH.id); + + expect(mockFindOneDocument).toHaveBeenCalledWith('pushes', { id: TEST_PUSH.id }); + expect(mockToClass).toHaveBeenCalledWith(TEST_PUSH, Action.prototype); + expect(result).toBeTruthy(); + }); + + it('should return null when push not found', async () => { + mockFindOneDocument.mockResolvedValue(null); + + const result = await getPush('non-existent-id'); + + expect(mockFindOneDocument).toHaveBeenCalledWith('pushes', { id: 'non-existent-id' }); + expect(result).toBeNull(); + expect(mockToClass).not.toHaveBeenCalled(); + }); + }); + + describe('deletePush', () => { + it('should delete a push by id', async () => { + mockDeleteOne.mockResolvedValue({ deletedCount: 1 }); + + await deletePush(TEST_PUSH.id); + + expect(mockConnect).toHaveBeenCalledWith('pushes'); + expect(mockDeleteOne).toHaveBeenCalledWith({ id: TEST_PUSH.id }); + }); + }); + + describe('writeAudit', () => { + it('should write audit data', async () => { + const action = { ...TEST_PUSH, _id: 'some-mongo-id' } as any; + mockUpdateOne.mockResolvedValue({ modifiedCount: 1 }); + + await writeAudit(action); + + expect(mockConnect).toHaveBeenCalledWith('pushes'); + expect(mockUpdateOne).toHaveBeenCalledWith( + { id: TEST_PUSH.id }, + { + $set: expect.objectContaining({ + id: TEST_PUSH.id, + allowPush: TEST_PUSH.allowPush, + authorised: TEST_PUSH.authorised, + }), + }, + { upsert: true }, + ); + + const updateCall = mockUpdateOne.mock.calls[0]; + expect(updateCall[1].$set).not.toHaveProperty('_id'); + }); + + it('should throw error if id is not a string', async () => { + const action = { ...TEST_PUSH, id: 123 } as any; + + await expect(writeAudit(action)).rejects.toThrow('Invalid id'); + expect(mockUpdateOne).not.toHaveBeenCalled(); + }); + }); + + describe('authorise', () => { + it('should authorise a push', async () => { + mockFindOneDocument.mockResolvedValue({ ...TEST_PUSH }); + mockUpdateOne.mockResolvedValue({ modifiedCount: 1 }); + + const attestation = { signature: 'test-sig' }; + const result = await authorise(TEST_PUSH.id, attestation); + + expect(mockFindOneDocument).toHaveBeenCalledWith('pushes', { id: TEST_PUSH.id }); + expect(mockConnect).toHaveBeenCalledWith('pushes'); + expect(mockUpdateOne).toHaveBeenCalledWith( + { id: TEST_PUSH.id }, + { + $set: expect.objectContaining({ + authorised: true, + canceled: false, + rejected: false, + attestation: attestation, + }), + }, + { upsert: true }, + ); + expect(result).toEqual({ message: `authorised ${TEST_PUSH.id}` }); + }); + + it('should throw error when push not found', async () => { + mockFindOneDocument.mockResolvedValue(null); + + await expect(authorise('non-existent-id', null)).rejects.toThrow( + 'push non-existent-id not found', + ); + expect(mockUpdateOne).not.toHaveBeenCalled(); + }); + }); + + describe('reject', () => { + it('should reject a push', async () => { + mockFindOneDocument.mockResolvedValue({ ...TEST_PUSH }); + mockUpdateOne.mockResolvedValue({ modifiedCount: 1 }); + + const attestation = { signature: 'test-sig' }; + const result = await reject(TEST_PUSH.id, attestation); + + expect(mockFindOneDocument).toHaveBeenCalledWith('pushes', { id: TEST_PUSH.id }); + expect(mockConnect).toHaveBeenCalledWith('pushes'); + expect(mockUpdateOne).toHaveBeenCalledWith( + { id: TEST_PUSH.id }, + { + $set: expect.objectContaining({ + authorised: false, + canceled: false, + rejected: true, + attestation: attestation, + }), + }, + { upsert: true }, + ); + expect(result).toEqual({ message: `reject ${TEST_PUSH.id}` }); + }); + + it('should throw error when push not found', async () => { + mockFindOneDocument.mockResolvedValue(null); + + await expect(reject('non-existent-id', null)).rejects.toThrow( + 'push non-existent-id not found', + ); + expect(mockUpdateOne).not.toHaveBeenCalled(); + }); + }); + + describe('cancel', () => { + it('should cancel a push', async () => { + mockFindOneDocument.mockResolvedValue({ ...TEST_PUSH }); + mockUpdateOne.mockResolvedValue({ modifiedCount: 1 }); + + const result = await cancel(TEST_PUSH.id); + + expect(mockFindOneDocument).toHaveBeenCalledWith('pushes', { id: TEST_PUSH.id }); + expect(mockConnect).toHaveBeenCalledWith('pushes'); + expect(mockUpdateOne).toHaveBeenCalledWith( + { id: TEST_PUSH.id }, + { + $set: expect.objectContaining({ + authorised: false, + canceled: true, + rejected: false, + }), + }, + { upsert: true }, + ); + expect(result).toEqual({ message: `canceled ${TEST_PUSH.id}` }); + }); + + it('should throw error when push not found', async () => { + mockFindOneDocument.mockResolvedValue(null); + + await expect(cancel('non-existent-id')).rejects.toThrow('push non-existent-id not found'); + expect(mockUpdateOne).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/test/db/mongo/repo.test.ts b/test/db/mongo/repo.test.ts index eea1e2c7a..b9ad05a55 100644 --- a/test/db/mongo/repo.test.ts +++ b/test/db/mongo/repo.test.ts @@ -1,26 +1,100 @@ import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest'; import { Repo } from '../../../src/db/types'; +import { ObjectId } from 'mongodb'; const mockFindOne = vi.fn(); +const mockFind = vi.fn(); +const mockToArray = vi.fn(); +const mockInsertOne = vi.fn(); +const mockUpdateOne = vi.fn(); +const mockDeleteMany = vi.fn(); + const mockConnect = vi.fn(() => ({ findOne: mockFindOne, + find: mockFind, + insertOne: mockInsertOne, + updateOne: mockUpdateOne, + deleteMany: mockDeleteMany, })); +const mockToClass = vi.fn((doc, proto) => Object.assign(Object.create(proto), doc)); + vi.mock('../../../src/db/mongo/helper', () => ({ connect: mockConnect, })); -describe('MongoDB', async () => { - const { getRepo, getRepoByUrl } = await import('../../../src/db/mongo/repo'); +vi.mock('../../../src/db/helper', () => ({ + toClass: mockToClass, +})); + +describe('MongoDB Repo', async () => { + const { + getRepos, + getRepo, + getRepoByUrl, + getRepoById, + createRepo, + addUserCanPush, + addUserCanAuthorise, + removeUserCanPush, + removeUserCanAuthorise, + deleteRepo, + } = await import('../../../src/db/mongo/repo'); + + const TEST_REPO: Repo = { + _id: '507f1f77bcf86cd799439011', + name: 'sample', + project: 'test-project', + users: { canPush: ['user1'], canAuthorise: ['admin1'] }, + url: 'https://github.com/finos/git-proxy.git', + }; beforeEach(() => { vi.clearAllMocks(); + mockFind.mockReturnValue({ toArray: mockToArray }); }); afterEach(() => { vi.restoreAllMocks(); }); + describe('getRepos', () => { + it('should get all repos with empty query', async () => { + const repoData = [TEST_REPO]; + mockToArray.mockResolvedValue(repoData); + mockToClass.mockImplementation((doc) => doc); + + const result = await getRepos(); + + expect(mockConnect).toHaveBeenCalledWith('repos'); + expect(mockFind).toHaveBeenCalledWith({}); + expect(mockToArray).toHaveBeenCalled(); + expect(result).toEqual(repoData); + }); + + it('should get repos with custom query', async () => { + const query = { name: 'sample' }; + const repoData = [TEST_REPO]; + mockToArray.mockResolvedValue(repoData); + mockToClass.mockImplementation((doc) => doc); + + const result = await getRepos(query); + + expect(mockConnect).toHaveBeenCalledWith('repos'); + expect(mockFind).toHaveBeenCalledWith(query); + expect(mockToArray).toHaveBeenCalled(); + expect(result).toEqual(repoData); + }); + + it('should return empty array when no repos found', async () => { + mockToArray.mockResolvedValue([]); + + const result = await getRepos(); + + expect(result).toEqual([]); + }); + }); + describe('getRepo', () => { it('should get the repo using the name', async () => { const repoData: Partial = { @@ -28,14 +102,24 @@ describe('MongoDB', async () => { users: { canPush: [], canAuthorise: [] }, url: 'http://example.com/sample-repo.git', }; - mockFindOne.mockResolvedValue(repoData); + mockToClass.mockReturnValue(repoData); const result = await getRepo('Sample'); expect(result).toEqual(repoData); expect(mockConnect).toHaveBeenCalledWith('repos'); expect(mockFindOne).toHaveBeenCalledWith({ name: { $eq: 'sample' } }); + expect(mockToClass).toHaveBeenCalledWith(repoData, Repo.prototype); + }); + + it('should return null when repo not found', async () => { + mockFindOne.mockResolvedValue(null); + + const result = await getRepo('NonExistent'); + + expect(result).toBeNull(); + expect(mockToClass).not.toHaveBeenCalled(); }); }); @@ -46,8 +130,8 @@ describe('MongoDB', async () => { users: { canPush: [], canAuthorise: [] }, url: 'https://github.com/finos/git-proxy.git', }; - mockFindOne.mockResolvedValue(repoData); + mockToClass.mockReturnValue(repoData); const result = await getRepoByUrl('https://github.com/finos/git-proxy.git'); @@ -56,6 +140,181 @@ describe('MongoDB', async () => { expect(mockFindOne).toHaveBeenCalledWith({ url: { $eq: 'https://github.com/finos/git-proxy.git' }, }); + expect(mockToClass).toHaveBeenCalledWith(repoData, Repo.prototype); + }); + + it('should return null when repo not found by url', async () => { + mockFindOne.mockResolvedValue(null); + + const result = await getRepoByUrl('https://example.com/nonexistent.git'); + + expect(result).toBeNull(); + expect(mockToClass).not.toHaveBeenCalled(); + }); + }); + + describe('getRepoById', () => { + it('should get the repo using the _id', async () => { + const repoData = { ...TEST_REPO }; + mockFindOne.mockResolvedValue(repoData); + mockToClass.mockReturnValue(repoData); + + const result = await getRepoById(TEST_REPO._id!); + + expect(result).toEqual(repoData); + expect(mockConnect).toHaveBeenCalledWith('repos'); + expect(mockFindOne).toHaveBeenCalledWith({ + _id: new ObjectId(TEST_REPO._id!), + }); + expect(mockToClass).toHaveBeenCalledWith(repoData, Repo.prototype); + }); + + it('should return null when repo not found by id', async () => { + mockFindOne.mockResolvedValue(null); + + const result = await getRepoById(TEST_REPO._id!); + + expect(result).toBeNull(); + expect(mockToClass).not.toHaveBeenCalled(); + }); + }); + + describe('createRepo', () => { + it('should create a new repo', async () => { + const newRepo: Repo = { + project: 'test-project', + name: 'new-repo', + users: { canPush: [], canAuthorise: [] }, + url: 'https://github.com/example/new-repo.git', + }; + + const insertedId = new ObjectId(TEST_REPO._id!); + mockInsertOne.mockResolvedValue({ insertedId }); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const result = await createRepo(newRepo); + + expect(mockConnect).toHaveBeenCalledWith('repos'); + expect(mockInsertOne).toHaveBeenCalledWith(newRepo); + expect(result._id).toBe(insertedId.toString()); + expect(result.name).toBe('new-repo'); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + }); + + describe('addUserCanPush', () => { + it('should add user to canPush list', async () => { + mockUpdateOne.mockResolvedValue({ modifiedCount: 1 }); + + await addUserCanPush(TEST_REPO._id!, 'NewUser'); + + expect(mockConnect).toHaveBeenCalledWith('repos'); + expect(mockUpdateOne).toHaveBeenCalledWith( + { _id: new ObjectId(TEST_REPO._id!) }, + { $push: { 'users.canPush': 'newuser' } }, + ); + }); + + it('should convert username to lowercase', async () => { + mockUpdateOne.mockResolvedValue({ modifiedCount: 1 }); + + await addUserCanPush(TEST_REPO._id!, 'UPPERCASE'); + + expect(mockUpdateOne).toHaveBeenCalledWith( + { _id: new ObjectId(TEST_REPO._id!) }, + { $push: { 'users.canPush': 'uppercase' } }, + ); + }); + }); + + describe('addUserCanAuthorise', () => { + it('should add user to canAuthorise list', async () => { + mockUpdateOne.mockResolvedValue({ modifiedCount: 1 }); + + await addUserCanAuthorise(TEST_REPO._id!, 'NewAdmin'); + + expect(mockConnect).toHaveBeenCalledWith('repos'); + expect(mockUpdateOne).toHaveBeenCalledWith( + { _id: new ObjectId(TEST_REPO._id!) }, + { $push: { 'users.canAuthorise': 'newadmin' } }, + ); + }); + + it('should convert username to lowercase', async () => { + mockUpdateOne.mockResolvedValue({ modifiedCount: 1 }); + + await addUserCanAuthorise(TEST_REPO._id!, 'ADMIN'); + + expect(mockUpdateOne).toHaveBeenCalledWith( + { _id: new ObjectId(TEST_REPO._id!) }, + { $push: { 'users.canAuthorise': 'admin' } }, + ); + }); + }); + + describe('removeUserCanPush', () => { + it('should remove user from canPush list', async () => { + mockUpdateOne.mockResolvedValue({ modifiedCount: 1 }); + + await removeUserCanPush(TEST_REPO._id!, 'User1'); + + expect(mockConnect).toHaveBeenCalledWith('repos'); + expect(mockUpdateOne).toHaveBeenCalledWith( + { _id: new ObjectId(TEST_REPO._id!) }, + { $pull: { 'users.canPush': 'user1' } }, + ); + }); + + it('should convert username to lowercase', async () => { + mockUpdateOne.mockResolvedValue({ modifiedCount: 1 }); + + await removeUserCanPush(TEST_REPO._id!, 'USER'); + + expect(mockUpdateOne).toHaveBeenCalledWith( + { _id: new ObjectId(TEST_REPO._id!) }, + { $pull: { 'users.canPush': 'user' } }, + ); + }); + }); + + describe('removeUserCanAuthorise', () => { + it('should remove user from canAuthorise list', async () => { + mockUpdateOne.mockResolvedValue({ modifiedCount: 1 }); + + await removeUserCanAuthorise(TEST_REPO._id!, 'Admin1'); + + expect(mockConnect).toHaveBeenCalledWith('repos'); + expect(mockUpdateOne).toHaveBeenCalledWith( + { _id: new ObjectId(TEST_REPO._id!) }, + { $pull: { 'users.canAuthorise': 'admin1' } }, + ); + }); + + it('should convert username to lowercase', async () => { + mockUpdateOne.mockResolvedValue({ modifiedCount: 1 }); + + await removeUserCanAuthorise(TEST_REPO._id!, 'ADMIN'); + + expect(mockUpdateOne).toHaveBeenCalledWith( + { _id: new ObjectId(TEST_REPO._id!) }, + { $pull: { 'users.canAuthorise': 'admin' } }, + ); + }); + }); + + describe('deleteRepo', () => { + it('should delete a repo by _id', async () => { + mockDeleteMany.mockResolvedValue({ deletedCount: 1 }); + + await deleteRepo(TEST_REPO._id!); + + expect(mockConnect).toHaveBeenCalledWith('repos'); + expect(mockDeleteMany).toHaveBeenCalledWith({ + _id: new ObjectId(TEST_REPO._id), + }); }); }); }); diff --git a/test/db/mongo/user.test.ts b/test/db/mongo/user.test.ts new file mode 100644 index 000000000..d53ca2854 --- /dev/null +++ b/test/db/mongo/user.test.ts @@ -0,0 +1,414 @@ +import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest'; +import { User } from '../../../src/db/types'; +import { ObjectId } from 'mongodb'; + +const mockFindOne = vi.fn(); +const mockFind = vi.fn(); +const mockToArray = vi.fn(); +const mockProject = vi.fn(); +const mockInsertOne = vi.fn(); +const mockUpdateOne = vi.fn(); +const mockDeleteOne = vi.fn(); + +const mockConnect = vi.fn(() => ({ + findOne: mockFindOne, + find: mockFind, + insertOne: mockInsertOne, + updateOne: mockUpdateOne, + deleteOne: mockDeleteOne, +})); + +const mockToClass = vi.fn((doc, proto) => Object.assign(Object.create(proto), doc)); + +vi.mock('../../../src/db/mongo/helper', () => ({ + connect: mockConnect, +})); + +vi.mock('../../../src/db/helper', () => ({ + toClass: mockToClass, +})); + +describe('MongoDB User', async () => { + const { + findUser, + findUserByEmail, + findUserByOIDC, + getUsers, + deleteUser, + createUser, + updateUser, + } = await import('../../../src/db/mongo/users'); + + const TEST_USER: Partial = { + _id: '507f1f77bcf86cd799439011', + username: 'testuser', + email: 'test@example.com', + oidcId: 'test-oidc-id', + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockFind.mockReturnValue({ + project: mockProject, + }); + mockProject.mockReturnValue({ + toArray: mockToArray, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('findUser', () => { + it('should find user by username', async () => { + const userData = { ...TEST_USER }; + mockFindOne.mockResolvedValue(userData); + mockToClass.mockReturnValue(userData); + + const result = await findUser('TestUser'); + + expect(mockConnect).toHaveBeenCalledWith('users'); + expect(mockFindOne).toHaveBeenCalledWith({ username: { $eq: 'testuser' } }); + expect(mockToClass).toHaveBeenCalledWith(userData, User.prototype); + expect(result).toEqual(userData); + }); + + it('should convert username to lowercase', async () => { + mockFindOne.mockResolvedValue(TEST_USER); + mockToClass.mockReturnValue(TEST_USER); + + await findUser('UPPERCASE'); + + expect(mockFindOne).toHaveBeenCalledWith({ username: { $eq: 'uppercase' } }); + }); + + it('should return null when user not found', async () => { + mockFindOne.mockResolvedValue(null); + + const result = await findUser('nonexistent'); + + expect(result).toBeNull(); + expect(mockToClass).not.toHaveBeenCalled(); + }); + }); + + describe('findUserByEmail', () => { + it('should find user by email', async () => { + const userData = { ...TEST_USER }; + mockFindOne.mockResolvedValue(userData); + mockToClass.mockReturnValue(userData); + + const result = await findUserByEmail('Test@Example.com'); + + expect(mockConnect).toHaveBeenCalledWith('users'); + expect(mockFindOne).toHaveBeenCalledWith({ email: { $eq: 'test@example.com' } }); + expect(mockToClass).toHaveBeenCalledWith(userData, User.prototype); + expect(result).toEqual(userData); + }); + + it('should convert email to lowercase', async () => { + mockFindOne.mockResolvedValue(TEST_USER); + mockToClass.mockReturnValue(TEST_USER); + + await findUserByEmail('UPPERCASE@EXAMPLE.COM'); + + expect(mockFindOne).toHaveBeenCalledWith({ email: { $eq: 'uppercase@example.com' } }); + }); + + it('should return null when user not found', async () => { + mockFindOne.mockResolvedValue(null); + + const result = await findUserByEmail('nonexistent@example.com'); + + expect(result).toBeNull(); + expect(mockToClass).not.toHaveBeenCalled(); + }); + }); + + describe('findUserByOIDC', () => { + it('should find user by OIDC ID', async () => { + const userData = { ...TEST_USER }; + mockFindOne.mockResolvedValue(userData); + mockToClass.mockReturnValue(userData); + + const result = await findUserByOIDC('test-oidc-id'); + + expect(mockConnect).toHaveBeenCalledWith('users'); + expect(mockFindOne).toHaveBeenCalledWith({ oidcId: { $eq: 'test-oidc-id' } }); + expect(mockToClass).toHaveBeenCalledWith(userData, User.prototype); + expect(result).toEqual(userData); + }); + + it('should NOT convert OIDC ID to lowercase', async () => { + mockFindOne.mockResolvedValue(TEST_USER); + mockToClass.mockReturnValue(TEST_USER); + + await findUserByOIDC('OIDC-UPPERCASE-123'); + + expect(mockFindOne).toHaveBeenCalledWith({ oidcId: { $eq: 'OIDC-UPPERCASE-123' } }); + }); + + it('should return null when user not found', async () => { + mockFindOne.mockResolvedValue(null); + + const result = await findUserByOIDC('nonexistent-oidc'); + + expect(result).toBeNull(); + expect(mockToClass).not.toHaveBeenCalled(); + }); + }); + + describe('getUsers', () => { + it('should get all users with empty query', async () => { + const userData = [TEST_USER]; + mockToArray.mockResolvedValue(userData); + mockToClass.mockImplementation((doc) => doc); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const result = await getUsers(); + + expect(mockConnect).toHaveBeenCalledWith('users'); + expect(mockFind).toHaveBeenCalledWith({}); + expect(mockProject).toHaveBeenCalledWith({ password: 0 }); + expect(mockToArray).toHaveBeenCalled(); + expect(result).toEqual(userData); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it('should get users with username query and convert to lowercase', async () => { + const userData = [TEST_USER]; + mockToArray.mockResolvedValue(userData); + 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); + + consoleSpy.mockRestore(); + }); + + it('should get users with email query and convert to lowercase', async () => { + const userData = [TEST_USER]; + mockToArray.mockResolvedValue(userData); + 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); + + consoleSpy.mockRestore(); + }); + + it('should get users with both username and email query', async () => { + const userData = [TEST_USER]; + mockToArray.mockResolvedValue(userData); + 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 }); + + consoleSpy.mockRestore(); + }); + + it('should return empty array when no users found', async () => { + mockToArray.mockResolvedValue([]); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const result = await getUsers(); + + expect(result).toEqual([]); + + consoleSpy.mockRestore(); + }); + }); + + describe('deleteUser', () => { + it('should delete user by username', async () => { + mockDeleteOne.mockResolvedValue({ deletedCount: 1 }); + + await deleteUser('TestUser'); + + expect(mockConnect).toHaveBeenCalledWith('users'); + expect(mockDeleteOne).toHaveBeenCalledWith({ username: 'testuser' }); + }); + + it('should convert username to lowercase when deleting', async () => { + mockDeleteOne.mockResolvedValue({ deletedCount: 1 }); + + await deleteUser('UPPERCASE'); + + expect(mockDeleteOne).toHaveBeenCalledWith({ username: 'uppercase' }); + }); + }); + + describe('createUser', () => { + it('should create a new user', async () => { + const newUser: User = { + username: 'NewUser', + email: 'New@Example.com', + oidcId: 'test-oidc-id-new', + } as User; + + mockInsertOne.mockResolvedValue({ insertedId: new ObjectId() }); + + await createUser(newUser); + + expect(mockConnect).toHaveBeenCalledWith('users'); + expect(mockInsertOne).toHaveBeenCalledWith( + expect.objectContaining({ + username: 'newuser', + email: 'new@example.com', + oidcId: 'test-oidc-id-new', + }), + ); + }); + + it('should convert username and email to lowercase', async () => { + const newUser: User = { + username: 'UPPERCASE', + email: 'UPPERCASE@EXAMPLE.COM', + } as User; + + mockInsertOne.mockResolvedValue({ insertedId: new ObjectId() }); + + await createUser(newUser); + + expect(newUser.username).toBe('uppercase'); + expect(newUser.email).toBe('uppercase@example.com'); + }); + }); + + describe('updateUser', () => { + it('should update user by _id', async () => { + const userUpdate: Partial = { + _id: '507f1f77bcf86cd799439011', + email: 'Updated@Example.com', + }; + + mockUpdateOne.mockResolvedValue({ modifiedCount: 1 }); + + await updateUser(userUpdate); + + expect(mockConnect).toHaveBeenCalledWith('users'); + expect(mockUpdateOne).toHaveBeenCalledWith( + { _id: new ObjectId('507f1f77bcf86cd799439011') }, + { $set: { email: 'updated@example.com' } }, + { upsert: true }, + ); + }); + + it('should update user by username when no _id provided', async () => { + const userUpdate: Partial = { + username: 'TestUser', + email: 'Updated@Example.com', + }; + + mockUpdateOne.mockResolvedValue({ modifiedCount: 1 }); + + await updateUser(userUpdate); + + expect(mockUpdateOne).toHaveBeenCalledWith( + { username: 'testuser' }, + { $set: { username: 'testuser', email: 'updated@example.com' } }, + { upsert: true }, + ); + }); + + it('should convert username and email to lowercase when updating', async () => { + const userUpdate: Partial = { + username: 'UPPERCASE', + email: 'UPPERCASE@EXAMPLE.COM', + }; + + mockUpdateOne.mockResolvedValue({ modifiedCount: 1 }); + + await updateUser(userUpdate); + + expect(mockUpdateOne).toHaveBeenCalledWith( + { username: 'uppercase' }, + { $set: { username: 'uppercase', email: 'uppercase@example.com' } }, + { upsert: true }, + ); + }); + + it('should not include _id in $set operation', async () => { + const userUpdate: Partial = { + _id: '507f1f77bcf86cd799439011', + username: 'testuser', + email: 'test@example.com', + }; + + mockUpdateOne.mockResolvedValue({ modifiedCount: 1 }); + + await updateUser(userUpdate); + + const setOperation = mockUpdateOne.mock.calls[0][1].$set; + expect(setOperation).not.toHaveProperty('_id'); + expect(setOperation).toHaveProperty('username'); + expect(setOperation).toHaveProperty('email'); + }); + + it('should use upsert option', async () => { + const userUpdate: Partial = { + username: 'newuser', + email: 'new@example.com', + }; + + mockUpdateOne.mockResolvedValue({ upsertedCount: 1 }); + + await updateUser(userUpdate); + + expect(mockUpdateOne).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), { + upsert: true, + }); + }); + + it('should handle partial updates without username or email', async () => { + const userUpdate: Partial = { + _id: '507f1f77bcf86cd799439011', + oidcId: 'new-oidc-id', + }; + + mockUpdateOne.mockResolvedValue({ modifiedCount: 1 }); + + await updateUser(userUpdate); + + expect(mockUpdateOne).toHaveBeenCalledWith( + { _id: new ObjectId('507f1f77bcf86cd799439011') }, + { $set: { oidcId: 'new-oidc-id' } }, + { upsert: true }, + ); + }); + }); +}); diff --git a/test/services/passport/ldaphelper.test.ts b/test/services/passport/ldaphelper.test.ts new file mode 100644 index 000000000..2b465f1f1 --- /dev/null +++ b/test/services/passport/ldaphelper.test.ts @@ -0,0 +1,126 @@ +import { describe, it, beforeEach, expect, vi, type Mock, afterEach } from 'vitest'; +import type ActiveDirectory from 'activedirectory2'; + +let axiosGetMock: Mock; +let adIsMemberMock: Mock; + +const mockProfile = { + username: 'test-user', +} as any; + +const mockReq = {} as any; +const mockDomain = 'test.com'; +const mockGroup = 'admins'; + +describe('ldapHelper - isUserInAdGroup', () => { + beforeEach(() => { + vi.resetModules(); + + axiosGetMock = vi.fn(); + adIsMemberMock = vi.fn(); + + // mock axios + vi.doMock('axios', () => ({ + default: { + create: () => ({ + get: axiosGetMock, + }), + }, + })); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + it('uses HTTP API when config.ls.userInADGroup is set (success)', async () => { + vi.doMock('../../../src/config', () => ({ + getAPIs: () => ({ + ls: { + userInADGroup: 'https://api.test///', + }, + }), + })); + + axiosGetMock.mockResolvedValueOnce({ data: true }); + + const { isUserInAdGroup } = await import('../../../src/service/passport/ldaphelper'); + + const result = await isUserInAdGroup( + mockReq, + mockProfile, + {} as ActiveDirectory, + mockDomain, + mockGroup, + ); + + expect(result).toBe(true); + expect(axiosGetMock).toHaveBeenCalledOnce(); + }); + + it('uses HTTP API and returns false on error', async () => { + vi.doMock('../../../src/config', () => ({ + getAPIs: () => ({ + ls: { + userInADGroup: 'https://api.test///', + }, + }), + })); + + axiosGetMock.mockRejectedValueOnce(new Error('HTTP fail')); + + const { isUserInAdGroup } = await import('../../../src/service/passport/ldaphelper'); + + const result = await isUserInAdGroup( + mockReq, + mockProfile, + {} as ActiveDirectory, + mockDomain, + mockGroup, + ); + + expect(result).toBe(false); + }); + + it('uses AD directly when HTTP config is not set (success)', async () => { + vi.doMock('../../../src/config', () => ({ + getAPIs: () => ({ + ls: {}, + }), + })); + + adIsMemberMock.mockImplementation((_u, _g, cb) => cb(null, true)); + + const fakeAd = { + isUserMemberOf: adIsMemberMock, + } as unknown as ActiveDirectory; + + const { isUserInAdGroup } = await import('../../../src/service/passport/ldaphelper'); + + const result = await isUserInAdGroup(mockReq, mockProfile, fakeAd, mockDomain, mockGroup); + + expect(result).toBe(true); + expect(adIsMemberMock).toHaveBeenCalledOnce(); + }); + + it('rejects when AD throws error', async () => { + vi.doMock('../../../src/config', () => ({ + getAPIs: () => ({ + ls: {}, + }), + })); + + adIsMemberMock.mockImplementation((_u, _g, cb) => cb(new Error('AD failure'), null)); + + const fakeAd = { + isUserMemberOf: adIsMemberMock, + } as unknown as ActiveDirectory; + + const { isUserInAdGroup } = await import('../../../src/service/passport/ldaphelper'); + + await expect( + isUserInAdGroup(mockReq, mockProfile, fakeAd, mockDomain, mockGroup), + ).rejects.toContain('ERROR isUserMemberOf'); + }); +}); diff --git a/test/testActiveDirectoryAuth.test.ts b/test/services/passport/testActiveDirectoryAuth.test.ts similarity index 92% rename from test/testActiveDirectoryAuth.test.ts rename to test/services/passport/testActiveDirectoryAuth.test.ts index b48d4c34a..9843c7ca3 100644 --- a/test/testActiveDirectoryAuth.test.ts +++ b/test/services/passport/testActiveDirectoryAuth.test.ts @@ -58,8 +58,8 @@ describe('ActiveDirectory auth method', () => { }); // mock ldaphelper before importing activeDirectory - vi.doMock('../src/service/passport/ldaphelper', () => ldapStub); - vi.doMock('../src/db', () => dbStub); + vi.doMock('../../../src/service/passport/ldaphelper', () => ldapStub); + vi.doMock('../../../src/db', () => dbStub); vi.doMock('passport-activedirectory', () => ({ default: function (options: any, callback: (err: any, user: any) => void) { @@ -72,12 +72,12 @@ describe('ActiveDirectory auth method', () => { })); // First import config - const config = await import('../src/config'); + const config = await import('../../../src/config/index.js'); config.initUserConfig(); - vi.doMock('../src/config', () => config); + vi.doMock('../../../src/config', () => config); // then configure activeDirectory - const { configure } = await import('../src/service/passport/activeDirectory.js'); + const { configure } = await import('../../../src/service/passport/activeDirectory.js'); configure(passportStub as any); }); diff --git a/test/services/routes/auth.test.ts b/test/services/routes/auth.test.ts index 2307e09c3..d095b4f47 100644 --- a/test/services/routes/auth.test.ts +++ b/test/services/routes/auth.test.ts @@ -109,6 +109,15 @@ describe('Auth API', () => { expect(res.status).toBe(403); }); + it('should return 404 Not Found if user is not found', async () => { + const res = await request(newApp('alice')).post('/auth/gitAccount').send({ + username: 'non-existent-user', + gitAccount: 'UPDATED_GIT_ACCOUNT', + }); + + expect(res.status).toBe(404); + }); + it('should return 200 OK if user is an admin and updates git account for authenticated user', async () => { const updateUserSpy = vi.spyOn(db, 'updateUser').mockResolvedValue(); @@ -248,5 +257,43 @@ describe('Auth API', () => { admin: false, }); }); + + it('should return 404 Not Found if user is not found', async () => { + const res = await request(newApp('non-existent-user')).get('/auth/profile'); + expect(res.status).toBe(404); + expect(res.body).toEqual({ message: 'User not found' }); + }); + }); + + describe('GET /', () => { + it('should return 200 OK and the auth endpoints', async () => { + const res = await request(newApp()).get('/auth'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + login: { + action: 'post', + uri: '/api/auth/login', + }, + profile: { + action: 'get', + uri: '/api/auth/profile', + }, + logout: { + action: 'post', + uri: '/api/auth/logout', + }, + }); + }); + }); + + describe('GET /config', () => { + it('should return 200 OK and the default auth config', async () => { + const res = await request(newApp()).get('/auth/config'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + usernamePasswordMethod: 'local', + otherMethods: [], + }); + }); }); }); diff --git a/test/services/routes/config.test.ts b/test/services/routes/config.test.ts new file mode 100644 index 000000000..87376d70c --- /dev/null +++ b/test/services/routes/config.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import express, { Express } from 'express'; +import request from 'supertest'; +import configRouter from '../../../src/service/routes/config'; +import * as config from '../../../src/config'; + +describe('Config API', () => { + let app: Express; + + beforeEach(() => { + app = express(); + app.use(express.json()); + app.use('/config', configRouter); + + vi.spyOn(config, 'getAttestationConfig').mockReturnValue({ questions: [] }); + vi.spyOn(config, 'getURLShortener').mockReturnValue('https://url-shortener.com'); + vi.spyOn(config, 'getContactEmail').mockReturnValue('test@example.com'); + vi.spyOn(config, 'getUIRouteAuth').mockReturnValue({ enabled: false, rules: [] }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('GET /config/attestation should return 200 OK and the default attestation config', async () => { + const res = await request(app).get('/config/attestation'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ questions: [] }); + }); + + it('GET /config/urlShortener should return 200 OK and the default url shortener config', async () => { + const res = await request(app).get('/config/urlShortener'); + expect(res.status).toBe(200); + expect(res.text).toBe('https://url-shortener.com'); // Check res.text as it gets serialized as a string + }); + + it('GET /config/contactEmail should return 200 OK and the default contact email', async () => { + const res = await request(app).get('/config/contactEmail'); + expect(res.status).toBe(200); + expect(res.text).toBe('test@example.com'); + }); + + it('GET /config/uiRouteAuth should return 200 OK and the default ui route auth config', async () => { + const res = await request(app).get('/config/uiRouteAuth'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ enabled: false, rules: [] }); + }); +}); diff --git a/test/services/routes/healthCheck.test.ts b/test/services/routes/healthCheck.test.ts new file mode 100644 index 000000000..4713101df --- /dev/null +++ b/test/services/routes/healthCheck.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import express, { Express } from 'express'; +import request from 'supertest'; +import healthcheck from '../../../src/service/routes/healthcheck'; + +describe('Health Check API', () => { + let app: Express; + + beforeEach(() => { + app = express(); + app.use(express.json()); + app.use('/healthCheck', healthcheck); + }); + + it('GET /healthCheck should return 200 OK and the health check message', async () => { + const res = await request(app).get('/healthCheck'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ message: 'ok' }); + }); +}); diff --git a/test/services/routes/users.test.ts b/test/services/routes/users.test.ts index 2dc401ad9..b0fe17127 100644 --- a/test/services/routes/users.test.ts +++ b/test/services/routes/users.test.ts @@ -62,4 +62,12 @@ describe('Users API', () => { admin: false, }); }); + + it('GET /users/:id should return 404 Not Found if user is not found', async () => { + vi.restoreAllMocks(); + + const res = await request(app).get('/users/non-existent'); + expect(res.status).toBe(404); + expect(res.body).toEqual({ message: 'User non-existent not found' }); + }); }); diff --git a/test/testParsePush.test.ts b/test/testParsePush.test.ts index 25740048d..e832fdfee 100644 --- a/test/testParsePush.test.ts +++ b/test/testParsePush.test.ts @@ -414,6 +414,20 @@ describe('parsePackFile', () => { expect(step.logs[1]).toContain('Expected 1, but got 2'); }); + it('should add error step if extra part in ref update', async () => { + const packetLines = ['oldhash1 newhash1 extra-part refs/heads/main\0caps\n']; + req.body = createPacketLineBuffer(packetLines); + const result = await exec(req, action); + + expect(result).toBe(action); + const step = action.steps[0]; + expect(step.stepName).toBe('parsePackFile'); + expect(step.error).toBe(true); + expect(step.errorMessage).toContain('Invalid ref update format'); + expect(step.logs[0]).toContain('Invalid number of parts in ref update'); + expect(step.logs[1]).toContain('Expected 3, but got 4'); + }); + it('should add error step if PACK data is missing', async () => { const oldCommit = 'a'.repeat(40); const newCommit = 'b'.repeat(40); @@ -705,6 +719,98 @@ describe('parsePackFile', () => { expect(action.setCommit).toHaveBeenCalledWith(oldCommit, newCommit); }); + it('should add error step if multiple tree lines are found in a commit', async () => { + const commitContent = + 'tree 123\ntree 456\nparent 789\nauthor Test Author 1234567890 +0000\ncommitter Test Committer 1234567890 +0000\n\nCommit message'; + const samplePackBuffer = createSamplePackBuffer(1, commitContent, 1); + req.body = Buffer.concat([ + createPacketLineBuffer(['oldhash1 newhash1 refs/heads/main\0caps\n']), + samplePackBuffer, + ]); + const result = await exec(req, action); + expect(result).toBe(action); + const step = action.steps[0]; + expect(step.error).toBe(true); + expect(step.errorMessage).toContain('Multiple tree lines found in commit.'); + }); + + it('should add error step if multiple author lines are found in a commit', async () => { + const commitContent = + 'tree 123\nauthor Test Author 1234567890 +0000\nauthor Test Author 1234567890 +0000\nparent 789\ncommitter Test Committer 1234567890 +0000\n\nCommit message'; + const samplePackBuffer = createSamplePackBuffer(1, commitContent, 1); + req.body = Buffer.concat([ + createPacketLineBuffer(['oldhash1 newhash1 refs/heads/main\0caps\n']), + samplePackBuffer, + ]); + const result = await exec(req, action); + expect(result).toBe(action); + const step = action.steps[0]; + expect(step.error).toBe(true); + expect(step.errorMessage).toContain('Multiple author lines found in commit.'); + }); + + it('should add error step if multiple committer lines are found in a commit', async () => { + const commitContent = + 'tree 123\nauthor Test Author 1234567890 +0000\ncommitter Test Committer 1234567890 +0000\ncommitter Test Committer 1234567890 +0000\nparent 789\n\nCommit message'; + const samplePackBuffer = createSamplePackBuffer(1, commitContent, 1); + req.body = Buffer.concat([ + createPacketLineBuffer(['oldhash1 newhash1 refs/heads/main\0caps\n']), + samplePackBuffer, + ]); + const result = await exec(req, action); + expect(result).toBe(action); + const step = action.steps[0]; + expect(step.error).toBe(true); + expect(step.errorMessage).toContain('Multiple committer lines found in commit.'); + }); + + it('should correctly handle trailing new lines in the commit content', async () => { + const commitContent = + 'tree 123\nparent 789\nauthor Test Author 1234567890 +0000\ncommitter Test Committer 1234567890 +0000\n\nCommit message\n\n\n'; + const samplePackBuffer = createSamplePackBuffer(1, commitContent, 1); + req.body = Buffer.concat([ + createPacketLineBuffer(['oldhash1 newhash1 refs/heads/main\0caps\n']), + samplePackBuffer, + ]); + const result = await exec(req, action); + expect(result).toBe(action); + const step = action.steps[0]; + expect(step.error).toBe(false); + expect(step.errorMessage).toBeNull(); + expect(action.commitData[0].message).toBe('Commit message'); + }); + + it('should correctly handle trailing spaces in the commit content', async () => { + const commitContent = + 'tree 123\nparent 789\nauthor Test Author 1234567890 +0000\ncommitter Test Committer 1234567890 +0000\n\nCommit message '; + const samplePackBuffer = createSamplePackBuffer(1, commitContent, 1); + req.body = Buffer.concat([ + createPacketLineBuffer(['oldhash1 newhash1 refs/heads/main\0caps\n']), + samplePackBuffer, + ]); + const result = await exec(req, action); + expect(result).toBe(action); + const step = action.steps[0]; + expect(step.error).toBe(false); + expect(step.errorMessage).toBeNull(); + expect(action.commitData[0].message).toBe('Commit message'); + }); + + it('should error if commit data is empty (headerEndIndex is -1)', async () => { + const commitContent = 'tree 123'; + const samplePackBuffer = createSamplePackBuffer(1, commitContent, 1); + req.body = Buffer.concat([ + createPacketLineBuffer(['oldhash1 newhash1 refs/heads/main\0caps\n']), + samplePackBuffer, + ]); + const result = await exec(req, action); + expect(result).toBe(action); + const step = action.steps[0]; + expect(step.error).toBe(true); + expect(step.errorMessage).toContain('Invalid commit data'); + expect(action.commitData).toHaveLength(0); + }); + it('should correctly identify PACK data even if "PACK" appears in packet lines', async () => { const oldCommit = 'a'.repeat(40); const newCommit = 'b'.repeat(40); diff --git a/test/testPush.test.ts b/test/testPush.test.ts index 731ed69e5..269d31138 100644 --- a/test/testPush.test.ts +++ b/test/testPush.test.ts @@ -121,7 +121,103 @@ describe('Push API', () => { Service.httpServer.close(); }); - describe('test push API', () => { + describe('GET /api/v1/push', () => { + afterEach(async () => { + await db.deletePush(TEST_PUSH.id); + if (cookie) await logout(); + }); + + it('should fetch all pushes', async () => { + await db.writeAudit(TEST_PUSH as any); + 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); + + const push = res.body.find((p: any) => p.id === TEST_PUSH.id); + expect(push).toBeDefined(); + expect(push).toEqual(TEST_PUSH); + expect(push.canceled).toBe(false); + }); + + it('should fetch pushes with query parameters', async () => { + const timestamp = Date.now(); + const pushWithRejected = { + ...TEST_PUSH, + _id: `${EMPTY_COMMIT_HASH}__${timestamp}`, + rejected: true, + id: `${EMPTY_COMMIT_HASH}__${timestamp}`, + }; + await db.writeAudit(TEST_PUSH as any); + await db.writeAudit(pushWithRejected as any); + + await loginAsApprover(); + const res = await request(app).get('/api/v1/push?rejected=true').set('Cookie', `${cookie}`); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + + const rejectedPushes = res.body.filter((p: any) => p.rejected === true); + expect(rejectedPushes.length).toBeGreaterThan(0); + + await db.deletePush(pushWithRejected.id); + }); + + it('should handle query parameters with false boolean values', async () => { + await db.writeAudit(TEST_PUSH as any); + await loginAsApprover(); + + const res = await request(app).get('/api/v1/push?canceled=false').set('Cookie', `${cookie}`); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('should handle multiple query parameters', async () => { + await db.writeAudit(TEST_PUSH as any); + await loginAsApprover(); + + const res = await request(app) + .get('/api/v1/push?canceled=false&rejected=false&authorised=false') + .set('Cookie', `${cookie}`); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('should ignore limit and skip query parameters in filtering', async () => { + await db.writeAudit(TEST_PUSH as any); + await loginAsApprover(); + + const res = await request(app).get('/api/v1/push?limit=10&skip=0').set('Cookie', `${cookie}`); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('should handle empty query keys', async () => { + await db.writeAudit(TEST_PUSH as any); + await loginAsApprover(); + + const res = await request(app).get('/api/v1/push?=test').set('Cookie', `${cookie}`); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('should return empty array when no pushes match query', async () => { + await loginAsApprover(); + + const res = await request(app) + .get('/api/v1/push?canceled=true&rejected=true') + .set('Cookie', `${cookie}`); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + }); + + describe('GET /api/v1/push/:id', () => { afterEach(async () => { await db.deletePush(TEST_PUSH.id); if (cookie) await logout(); @@ -132,6 +228,25 @@ describe('Push API', () => { const commitId = `${EMPTY_COMMIT_HASH}__79b4d8953cbc324bcc1eb53d6412ff89666c241f`; const res = await request(app).get(`/api/v1/push/${commitId}`).set('Cookie', `${cookie}`); expect(res.status).toBe(404); + expect(res.body.message).toBe('not found'); + }); + + it('should get a specific push by id', async () => { + await db.writeAudit(TEST_PUSH as any); + await loginAsApprover(); + + const res = await request(app).get(`/api/v1/push/${TEST_PUSH.id}`).set('Cookie', `${cookie}`); + + expect(res.status).toBe(200); + expect(res.body.id).toBe(TEST_PUSH.id); + expect(res.body).toEqual(TEST_PUSH); + }); + }); + + describe('POST /api/v1/push/:id/authorise', () => { + afterEach(async () => { + await db.deletePush(TEST_PUSH.id); + if (cookie) await logout(); }); it('should allow an authorizer to approve a push', async () => { @@ -158,10 +273,33 @@ describe('Push API', () => { expect(res.status).toBe(200); }); + it('should NOT allow authorization without being logged in', async () => { + await db.writeAudit(TEST_PUSH as any); + + const res = await request(app) + .post(`/api/v1/push/${TEST_PUSH.id}/authorise`) + .set('content-type', 'application/json') + .send({ + params: { + attestation: [ + { + label: 'I am happy for this to be pushed to the upstream repository', + tooltip: { + text: 'Are you happy for this contribution to be pushed upstream?', + links: [], + }, + checked: true, + }, + ], + }, + }); + + expect(res.status).toBe(401); + expect(res.body.message).toBe('Not logged in'); + }); + it('should NOT allow an authorizer to approve if attestation is incomplete', async () => { - // make the approver also the committer - const testPush = { ...TEST_PUSH, user: TEST_USERNAME_1, userEmail: TEST_EMAIL_1 }; - await db.writeAudit(testPush as any); + await db.writeAudit(TEST_PUSH as any); await loginAsApprover(); const res = await request(app) .post(`/api/v1/push/${TEST_PUSH.id}/authorise`) @@ -185,6 +323,33 @@ describe('Push API', () => { expect(res.body.message).toBe('Attestation is not complete'); }); + it('should NOT allow authorization if push is not found', async () => { + await loginAsApprover(); + const fakeId = `${EMPTY_COMMIT_HASH}__9999999999999`; + + const res = await request(app) + .post(`/api/v1/push/${fakeId}/authorise`) + .set('Cookie', `${cookie}`) + .set('content-type', 'application/json') + .send({ + params: { + attestation: [ + { + label: 'I am happy for this to be pushed to the upstream repository', + tooltip: { + text: 'Are you happy for this contribution to be pushed upstream?', + links: [], + }, + checked: true, + }, + ], + }, + }); + + expect(res.status).toBe(404); + expect(res.body.message).toBe('Push request not found'); + }); + it('should NOT allow an authorizer to approve if committer is unknown', async () => { // make the approver also the committer const testPush = { ...TEST_PUSH, user: TEST_USERNAME_3, userEmail: TEST_EMAIL_3 }; @@ -213,153 +378,292 @@ describe('Push API', () => { "No user found with the committer's email address: push-test-3@test.com", ); }); - }); - it('should NOT allow an authorizer to approve their own push', async () => { - // make the approver also the committer - const testPush = { ...TEST_PUSH }; - testPush.user = TEST_USERNAME_1; - testPush.userEmail = TEST_EMAIL_1; - await db.writeAudit(testPush as any); - await loginAsApprover(); - const res = await request(app) - .post(`/api/v1/push/${TEST_PUSH.id}/authorise`) - .set('Cookie', `${cookie}`) - .set('Content-Type', 'application/json') - .send({ - params: { - attestation: [ - { - label: 'I am happy for this to be pushed to the upstream repository', - tooltip: { - text: 'Are you happy for this contribution to be pushed upstream?', - links: [], + it('should NOT allow an authorizer to approve their own push', async () => { + // make the approver the committer + const testPush = { ...TEST_PUSH, user: TEST_USERNAME_1, userEmail: TEST_EMAIL_1 }; + await db.writeAudit(testPush as any); + await loginAsApprover(); + const res = await request(app) + .post(`/api/v1/push/${TEST_PUSH.id}/authorise`) + .set('Cookie', `${cookie}`) + .set('Content-Type', 'application/json') + .send({ + params: { + attestation: [ + { + label: 'I am happy for this to be pushed to the upstream repository', + tooltip: { + text: 'Are you happy for this contribution to be pushed upstream?', + links: [], + }, + checked: true, }, - checked: true, - }, - ], - }, - }); - expect(res.status).toBe(403); - expect(res.body.message).toBe('Cannot approve your own changes'); - }); + ], + }, + }); + expect(res.status).toBe(403); + expect(res.body.message).toBe('Cannot approve your own changes'); + }); + + it('should NOT allow a non-authorizer to approve a push', async () => { + await db.writeAudit(TEST_PUSH as any); + await loginAsCommitter(); + const res = await request(app) + .post(`/api/v1/push/${TEST_PUSH.id}/authorise`) + .set('Cookie', `${cookie}`) + .set('Content-Type', 'application/json') + .send({ + params: { + attestation: [ + { + label: 'I am happy for this to be pushed to the upstream repository', + tooltip: { + text: 'Are you happy for this contribution to be pushed upstream?', + links: [], + }, + checked: true, + }, + ], + }, + }); + expect(res.status).toBe(403); + expect(res.body.message).toBe('Cannot approve your own changes'); + }); + + it('should return 404 if reviewer has no email address', async () => { + // Create a test user without an email address + const testUsername = 'no-email-user'; + const testPassword = 'password123'; + + await db.writeAudit(TEST_PUSH as any); + + await db.createUser(testUsername, testPassword, 'test@test.com', testUsername, false); + await db.updateUser({ username: testUsername, email: '' }); + await db.addUserCanAuthorise(testRepo._id, testUsername); - it('should NOT allow a non-authorizer to approve a push', async () => { - await db.writeAudit(TEST_PUSH as any); - await loginAsCommitter(); - const res = await request(app) - .post(`/api/v1/push/${TEST_PUSH.id}/authorise`) - .set('Cookie', `${cookie}`) - .set('Content-Type', 'application/json') - .send({ - params: { - attestation: [ - { - label: 'I am happy for this to be pushed to the upstream repository', - tooltip: { - text: 'Are you happy for this contribution to be pushed upstream?', - links: [], + await login(testUsername, testPassword); + + const res = await request(app) + .post(`/api/v1/push/${TEST_PUSH.id}/authorise`) + .set('Cookie', `${cookie}`) + .set('Content-Type', 'application/json') + .send({ + params: { + attestation: [ + { + label: 'I am happy for this to be pushed to the upstream repository', + tooltip: { + text: 'Are you happy for this contribution to be pushed upstream?', + links: [], + }, + checked: true, }, - checked: true, - }, - ], - }, - }); - expect(res.status).toBe(403); - expect(res.body.message).toBe('Cannot approve your own changes'); - }); + ], + }, + }); - it('should allow an authorizer to reject a push', async () => { - await db.writeAudit(TEST_PUSH as any); - await loginAsApprover(); - const res = await request(app) - .post(`/api/v1/push/${TEST_PUSH.id}/reject`) - .set('Cookie', `${cookie}`); - expect(res.status).toBe(200); - }); + expect(res.status).toBe(404); + expect(res.body.message).toBe( + `There was no registered email address for the reviewer: ${testUsername}`, + ); - it('should NOT allow an authorizer to reject their own push', async () => { - // make the approver also the committer - const testPush = { ...TEST_PUSH }; - testPush.user = TEST_USERNAME_1; - testPush.userEmail = TEST_EMAIL_1; - await db.writeAudit(testPush as any); - await loginAsApprover(); - const res = await request(app) - .post(`/api/v1/push/${TEST_PUSH.id}/reject`) - .set('Cookie', `${cookie}`); - expect(res.status).toBe(403); - expect(res.body.message).toBe('Cannot reject your own changes'); - }); + // Cleanup + await logout(); + await db.deleteUser(testUsername); + }); - it('should NOT allow a non-authorizer to reject a push', async () => { - const pushWithOtherUser = { ...TEST_PUSH }; - pushWithOtherUser.user = TEST_USERNAME_1; - pushWithOtherUser.userEmail = TEST_EMAIL_1; - - await db.writeAudit(pushWithOtherUser as any); - await loginAsCommitter(); - const res = await request(app) - .post(`/api/v1/push/${pushWithOtherUser.id}/reject`) - .set('Cookie', `${cookie}`); - expect(res.status).toBe(403); - expect(res.body.message).toBe( - 'User push-test-2 is not authorised to reject changes on this project', - ); - }); + it('should return 403 if user is not authorized to approve pushes on the project', async () => { + // Create a test user who is NOT authorized for this repo + const testUsername = 'unauthorized-user'; + const testPassword = 'password123'; + const testEmail = 'unauthorized@test.com'; - it('should fetch all pushes', async () => { - await db.writeAudit(TEST_PUSH as any); - 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); + await db.writeAudit(TEST_PUSH as any); - const push = res.body.find((p: any) => p.id === TEST_PUSH.id); - expect(push).toBeDefined(); - expect(push).toEqual(TEST_PUSH); - expect(push.canceled).toBe(false); + // Create user but DON'T add them as an authorizer for the repo + await db.createUser(testUsername, testPassword, testEmail, testUsername, false); + + await login(testUsername, testPassword); + + const res = await request(app) + .post(`/api/v1/push/${TEST_PUSH.id}/authorise`) + .set('Cookie', `${cookie}`) + .set('Content-Type', 'application/json') + .send({ + params: { + attestation: [ + { + label: 'I am happy for this to be pushed to the upstream repository', + tooltip: { + text: 'Are you happy for this contribution to be pushed upstream?', + links: [], + }, + checked: true, + }, + ], + }, + }); + + expect(res.status).toBe(403); + expect(res.body.message).toBe( + `User ${testUsername} not authorised to approve pushes on this project`, + ); + + // Cleanup + await logout(); + await db.deleteUser(testUsername); + }); }); - it('should allow a committer to cancel a push', async () => { - await db.writeAudit(TEST_PUSH as any); - await loginAsCommitter(); - const res = await request(app) - .post(`/api/v1/push/${TEST_PUSH.id}/cancel`) - .set('Cookie', `${cookie}`); - expect(res.status).toBe(200); + describe('POST /api/v1/push/:id/reject', () => { + afterEach(async () => { + await db.deletePush(TEST_PUSH.id); + if (cookie) await logout(); + }); + + it('should allow an authorizer to reject a push', async () => { + await db.writeAudit(TEST_PUSH as any); + await loginAsApprover(); + const res = await request(app) + .post(`/api/v1/push/${TEST_PUSH.id}/reject`) + .set('Cookie', `${cookie}`); + expect(res.status).toBe(200); + }); + + it('should NOT allow rejection without being logged in', async () => { + await db.writeAudit(TEST_PUSH as any); + + const res = await request(app).post(`/api/v1/push/${TEST_PUSH.id}/reject`); + + expect(res.status).toBe(401); + expect(res.body.message).toBe('Not logged in'); + }); + + it('should NOT allow rejection if push is not found', async () => { + await loginAsApprover(); + const fakeId = `${EMPTY_COMMIT_HASH}__9999999999999`; + + const res = await request(app) + .post(`/api/v1/push/${fakeId}/reject`) + .set('Cookie', `${cookie}`); + + expect(res.status).toBe(404); + expect(res.body.message).toBe('Push request not found'); + }); + + it('should NOT allow rejection if push has no userEmail', async () => { + const testPush = { ...TEST_PUSH, userEmail: null, id: `${EMPTY_COMMIT_HASH}__1744380874112` }; + await db.writeAudit(testPush as any); + await loginAsApprover(); + + const res = await request(app) + .post(`/api/v1/push/${testPush.id}/reject`) + .set('Cookie', `${cookie}`); + + expect(res.status).toBe(400); + expect(res.body.message).toBe('Push request has no user email'); + + await db.deletePush(testPush.id); + }); - const pushes = await request(app).get('/api/v1/push').set('Cookie', `${cookie}`); - const push = pushes.body.find((p: any) => p.id === TEST_PUSH.id); + it('should NOT allow rejection if committer user is not found', async () => { + const testPush = { ...TEST_PUSH, user: TEST_USERNAME_3, userEmail: TEST_EMAIL_3 }; + await db.writeAudit(testPush as any); + await loginAsApprover(); + + const res = await request(app) + .post(`/api/v1/push/${TEST_PUSH.id}/reject`) + .set('Cookie', `${cookie}`); - expect(push).toBeDefined(); - expect(push.canceled).toBe(true); + expect(res.status).toBe(404); + expect(res.body.message).toBe( + "No user found with the committer's email address: push-test-3@test.com", + ); + }); + + it('should NOT allow an authorizer to reject their own push', async () => { + const testPush = { ...TEST_PUSH, user: TEST_USERNAME_1, userEmail: TEST_EMAIL_1 }; + await db.writeAudit(testPush as any); + await loginAsApprover(); + const res = await request(app) + .post(`/api/v1/push/${TEST_PUSH.id}/reject`) + .set('Cookie', `${cookie}`); + expect(res.status).toBe(403); + expect(res.body.message).toBe('Cannot reject your own changes'); + }); + + it('should NOT allow a non-authorizer to reject a push', async () => { + const pushWithOtherUser = { ...TEST_PUSH, user: TEST_USERNAME_1, userEmail: TEST_EMAIL_1 }; + await db.writeAudit(pushWithOtherUser as any); + await loginAsCommitter(); + const res = await request(app) + .post(`/api/v1/push/${pushWithOtherUser.id}/reject`) + .set('Cookie', `${cookie}`); + expect(res.status).toBe(403); + expect(res.body.message).toBe( + 'User push-test-2 is not authorised to reject changes on this project', + ); + }); }); - it('should not allow a non-committer to cancel a push (even if admin)', async () => { - await db.writeAudit(TEST_PUSH as any); - await loginAsAdmin(); - const res = await request(app) - .post(`/api/v1/push/${TEST_PUSH.id}/cancel`) - .set('Cookie', `${cookie}`); - expect(res.status).toBe(403); - expect(res.body.message).toBe( - 'User admin not authorised to cancel push requests on this project', - ); - - const pushes = await request(app).get('/api/v1/push').set('Cookie', `${cookie}`); - const push = pushes.body.find((p: any) => p.id === TEST_PUSH.id); - - expect(push).toBeDefined(); - expect(push.canceled).toBe(false); + describe('POST /api/v1/push/:id/cancel', () => { + afterEach(async () => { + await db.deletePush(TEST_PUSH.id); + if (cookie) await logout(); + }); + + it('should allow a committer to cancel a push', async () => { + await db.writeAudit(TEST_PUSH as any); + await loginAsCommitter(); + const res = await request(app) + .post(`/api/v1/push/${TEST_PUSH.id}/cancel`) + .set('Cookie', `${cookie}`); + expect(res.status).toBe(200); + + const pushes = await request(app).get('/api/v1/push').set('Cookie', `${cookie}`); + const push = pushes.body.find((p: any) => p.id === TEST_PUSH.id); + + expect(push).toBeDefined(); + expect(push.canceled).toBe(true); + }); + + it('should NOT allow cancellation without being logged in', async () => { + await db.writeAudit(TEST_PUSH as any); + + const res = await request(app).post(`/api/v1/push/${TEST_PUSH.id}/cancel`); + + expect(res.status).toBe(401); + expect(res.body.message).toBe('Not logged in'); + }); + + it('should not allow a non-committer to cancel a push (even if admin)', async () => { + await db.writeAudit(TEST_PUSH as any); + await loginAsAdmin(); + const res = await request(app) + .post(`/api/v1/push/${TEST_PUSH.id}/cancel`) + .set('Cookie', `${cookie}`); + expect(res.status).toBe(403); + expect(res.body.message).toBe( + 'User admin not authorised to cancel push requests on this project', + ); + + const pushes = await request(app).get('/api/v1/push').set('Cookie', `${cookie}`); + const push = pushes.body.find((p: any) => p.id === TEST_PUSH.id); + + expect(push).toBeDefined(); + expect(push.canceled).toBe(false); + }); }); afterAll(async () => { - const res = await request(app).post('/api/auth/logout').set('Cookie', `${cookie}`); - expect(res.status).toBe(200); + if (cookie) { + const res = await request(app).post('/api/auth/logout').set('Cookie', `${cookie}`); + expect(res.status).toBe(200); + } await Service.httpServer.close(); - await db.deleteRepo(TEST_REPO); + await db.deleteRepo(testRepo._id); await db.deleteUser(TEST_USERNAME_1); await db.deleteUser(TEST_USERNAME_2); await db.deletePush(TEST_PUSH.id); diff --git a/test/testRepoApi.test.ts b/test/testRepoApi.test.ts index 73886c51a..a050c6b20 100644 --- a/test/testRepoApi.test.ts +++ b/test/testRepoApi.test.ts @@ -404,3 +404,263 @@ describe('add new repo', () => { await cleanupRepo(TEST_REPO_NAKED.url); }); }); + +describe('repo routes - edge cases', () => { + let app: any; + let proxy: any; + let adminCookie: string; + let nonAdminCookie: string; + let repoId: string; + + const setCookie = function (res: any, cookieVar: 'admin' | 'nonAdmin') { + res.headers['set-cookie'].forEach((x: string) => { + if (x.startsWith('connect')) { + const value = x.split(';')[0]; + if (cookieVar === 'admin') { + adminCookie = value; + } else { + nonAdminCookie = value; + } + } + }); + }; + + beforeAll(async () => { + proxy = new Proxy(); + app = await Service.start(proxy); + + await cleanupRepo(TEST_REPO.url); + await db.deleteUser('nonadmin'); + + await db.createUser('nonadmin', 'password', 'nonadmin@test.com', 'Non Admin', false); + + // Login as admin and set cookies + const adminRes = await request(app).post('/api/auth/login').send({ + username: 'admin', + password: 'admin', + }); + setCookie(adminRes, 'admin'); + + // Login as non-admin and set cookies + const nonAdminRes = await request(app).post('/api/auth/login').send({ + username: 'nonadmin', + password: 'password', + }); + setCookie(nonAdminRes, 'nonAdmin'); + + // Create a test repo + await request(app).post('/api/v1/repo').set('Cookie', adminCookie).send(TEST_REPO); + + const repo = await fetchRepoOrThrow(TEST_REPO.url); + repoId = repo._id!; + }); + + it('should return 401 when non-admin user tries to create repo', async () => { + const res = await request(app).post('/api/v1/repo').set('Cookie', nonAdminCookie).send({ + url: 'https://github.com/test/unauthorized-repo.git', + name: 'unauthorized-repo', + project: 'test', + host: 'github.com', + }); + + expect(res.status).toBe(401); + expect(res.body.message).toBe('You are not authorised to perform this action...'); + }); + + it('should return 401 when unauthenticated user tries to create repo', async () => { + const res = await request(app).post('/api/v1/repo').send({ + url: 'https://github.com/test/unauthenticated-repo.git', + name: 'unauthenticated-repo', + project: 'test', + host: 'github.com', + }); + + expect(res.status).toBe(401); + expect(res.body.message).toBe('You are not authorised to perform this action...'); + }); + + it('should return 400 when repo url is missing', async () => { + const res = await request(app).post('/api/v1/repo').set('Cookie', adminCookie).send({ + name: 'no-url-repo', + project: 'test', + host: 'github.com', + }); + + expect(res.status).toBe(400); + expect(res.body.message).toBe('Repository url is required'); + }); + + it('should return 400 when repo url is invalid', async () => { + const res = await request(app).post('/api/v1/repo').set('Cookie', adminCookie).send({ + url: '', + name: 'invalid-repo', + project: 'test', + host: 'github.com', + }); + expect(res.status).toBe(400); + expect(res.body.message).toBe('Repository url is required'); + }); + + it('should return 401 when non-admin user tries to add push user', async () => { + const res = await request(app) + .patch(`/api/v1/repo/${repoId}/user/push`) + .set('Cookie', nonAdminCookie) + .send({ username: 'testuser' }); + + expect(res.status).toBe(401); + expect(res.body.message).toBe('You are not authorised to perform this action...'); + }); + + it('should return 401 when unauthenticated user tries to add push user', async () => { + const res = await request(app) + .patch(`/api/v1/repo/${repoId}/user/push`) + .send({ username: 'testuser' }); + + expect(res.status).toBe(401); + expect(res.body.message).toBe('You are not authorised to perform this action...'); + }); + + 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`) + .set('Cookie', nonAdminCookie) + .send({ username: 'testuser' }); + + expect(res.status).toBe(401); + expect(res.body.message).toBe('You are not authorised to perform this action...'); + }); + + it('should return 401 when unauthenticated user tries to add authorise user', async () => { + const res = await request(app) + .patch(`/api/v1/repo/${repoId}/user/authorise`) + .send({ username: 'testuser' }); + + expect(res.status).toBe(401); + expect(res.body.message).toBe('You are not authorised to perform this action...'); + }); + + describe('DELETE /api/v1/repo/:id/user/push/:username', () => { + beforeAll(async () => { + // Add a user to remove + await db.addUserCanPush(repoId, 'testuser'); + }); + + it('should return 401 when non-admin user tries to remove push user', async () => { + const res = await request(app) + .delete(`/api/v1/repo/${repoId}/user/push/testuser`) + .set('Cookie', nonAdminCookie) + .send(); + + expect(res.status).toBe(401); + expect(res.body.message).toBe('You are not authorised to perform this action...'); + }); + + it('should return 401 when unauthenticated user tries to remove push user', async () => { + const res = await request(app).delete(`/api/v1/repo/${repoId}/user/push/testuser`).send(); + + expect(res.status).toBe(401); + expect(res.body.message).toBe('You are not authorised to perform this action...'); + }); + + it('should return 400 when trying to remove non-existent user', async () => { + const res = await request(app) + .delete(`/api/v1/repo/${repoId}/user/push/nonexistentuser`) + .set('Cookie', adminCookie) + .send(); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('User does not exist'); + }); + }); + + describe('DELETE /api/v1/repo/:id/user/authorise/:username', () => { + beforeAll(async () => { + // Add a user to remove + await db.addUserCanAuthorise(repoId, 'testuser'); + }); + + it('should return 401 when non-admin user tries to remove authorise user', async () => { + const res = await request(app) + .delete(`/api/v1/repo/${repoId}/user/authorise/testuser`) + .set('Cookie', nonAdminCookie) + .send(); + + expect(res.status).toBe(401); + expect(res.body.message).toBe('You are not authorised to perform this action...'); + }); + + it('should return 401 when unauthenticated user tries to remove authorise user', async () => { + const res = await request(app) + .delete(`/api/v1/repo/${repoId}/user/authorise/testuser`) + .send(); + + expect(res.status).toBe(401); + expect(res.body.message).toBe('You are not authorised to perform this action...'); + }); + + it('should return 400 when trying to remove non-existent user', async () => { + const res = await request(app) + .delete(`/api/v1/repo/${repoId}/user/authorise/nonexistentuser`) + .set('Cookie', adminCookie) + .send(); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('User does not exist'); + }); + }); + + describe('DELETE /api/v1/repo/:id/delete', () => { + it('should return 401 when non-admin user tries to delete repo', async () => { + const res = await request(app) + .delete(`/api/v1/repo/${repoId}/delete`) + .set('Cookie', nonAdminCookie) + .send(); + + expect(res.status).toBe(401); + expect(res.body.message).toBe('You are not authorised to perform this action...'); + }); + + it('should return 401 when unauthenticated user tries to delete repo', async () => { + const res = await request(app).delete(`/api/v1/repo/${repoId}/delete`).send(); + + expect(res.status).toBe(401); + expect(res.body.message).toBe('You are not authorised to perform this action...'); + }); + }); + + describe('query params handling', () => { + it('should handle boolean query params correctly', async () => { + const res = await request(app) + .get('/api/v1/repo') + .set('Cookie', adminCookie) + .query({ someFlag: 'true' }); + + expect(res.status).toBe(200); + }); + + it('should ignore limit and skip query params in filtering', async () => { + const res = await request(app) + .get('/api/v1/repo') + .set('Cookie', adminCookie) + .query({ limit: '10', skip: '5' }); + + expect(res.status).toBe(200); + }); + + it('should handle empty query params', async () => { + const res = await request(app) + .get('/api/v1/repo') + .set('Cookie', adminCookie) + .query({ '': 'empty' }); + + expect(res.status).toBe(200); + }); + }); + + afterAll(async () => { + await cleanupRepo(TEST_REPO.url); + await db.deleteUser('testuser'); + await db.deleteUser('nonadmin'); + await Service.httpServer.close(); + }); +});