diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 703e32da7..3ea7ce5e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,11 @@ jobs: npm run test-coverage-ci npm run test-coverage-ci --workspaces --if-present + - name: MongoDB Integration Tests + env: + GIT_PROXY_MONGO_CONNECTION_STRING: mongodb://localhost:27017/git-proxy-test + run: npm run test:integration + - name: Upload test coverage report uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # ratchet:codecov/codecov-action@v5.5.1 with: diff --git a/package.json b/package.json index 84966d499..e1fbf1c5b 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "test:e2e:watch": "vitest --config vitest.config.e2e.ts", "test-coverage": "NODE_ENV=test vitest --run --dir ./test --coverage", "test-coverage-ci": "NODE_ENV=test vitest --run --dir ./test --coverage.enabled=true --coverage.reporter=lcovonly --coverage.reporter=text", + "test:integration": "NODE_ENV=test vitest --run --config vitest.config.integration.ts", "test-watch": "NODE_ENV=test vitest --dir ./test --watch", "prepare": "node ./scripts/prepare.js", "lint": "eslint", diff --git a/src/db/mongo/helper.ts b/src/db/mongo/helper.ts index 9bdf40493..3fb00df3f 100644 --- a/src/db/mongo/helper.ts +++ b/src/db/mongo/helper.ts @@ -4,6 +4,19 @@ import MongoDBStore from 'connect-mongo'; import { fromNodeProviderChain } from '@aws-sdk/credential-providers'; let _db: Db | null = null; +let _client: MongoClient | null = null; + +// Reset connection - useful for testing +export const resetConnection = async (): Promise => { + if (_client) { + await _client.close(); + _client = null; + _db = null; + } +}; + +// Get the database instance - useful for testing +export const getDb = (): Db | null => _db; export const connect = async (collectionName: string): Promise => { //retrieve config at point of use (rather than import) @@ -21,9 +34,9 @@ export const connect = async (collectionName: string): Promise => { (options.authMechanismProperties.AWS_CREDENTIAL_PROVIDER as any) = fromNodeProviderChain(); } - const client = new MongoClient(connectionString, options); - await client.connect(); - _db = client.db(); + _client = new MongoClient(connectionString, options); + await _client.connect(); + _db = _client.db(); } return _db.collection(collectionName); diff --git a/test-integration.proxy.config.json b/test-integration.proxy.config.json new file mode 100644 index 000000000..99d1ff7f7 --- /dev/null +++ b/test-integration.proxy.config.json @@ -0,0 +1,25 @@ +{ + "cookieSecret": "integration-test-cookie-secret", + "sessionMaxAgeHours": 12, + "sink": [ + { + "type": "fs", + "enabled": false + }, + { + "type": "mongo", + "connectionString": "mongodb://localhost:27017/git-proxy-test", + "options": { + "useNewUrlParser": true, + "useUnifiedTopology": true + }, + "enabled": true + } + ], + "authentication": [ + { + "type": "local", + "enabled": true + } + ] +} diff --git a/test/db/mongo/pushes.integration.test.ts b/test/db/mongo/pushes.integration.test.ts new file mode 100644 index 000000000..a7fdafbd2 --- /dev/null +++ b/test/db/mongo/pushes.integration.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + writeAudit, + getPush, + getPushes, + deletePush, + authorise, + reject, + cancel, +} from '../../../src/db/mongo/pushes'; +import { Action } from '../../../src/proxy/actions'; + +// Only run in CI where MongoDB is available, or when explicitly enabled locally +const shouldRunMongoTests = process.env.CI === 'true' || process.env.RUN_MONGO_TESTS === 'true'; + +describe.runIf(shouldRunMongoTests)('MongoDB Pushes Integration Tests', () => { + const createTestAction = (overrides: Partial = {}): Action => { + const timestamp = Date.now(); + const action = new Action( + overrides.id || `test-push-${timestamp}`, + overrides.type || 'push', + overrides.method || 'POST', + overrides.timestamp || timestamp, + overrides.url || 'https://github.com/test/repo.git', + ); + + // Set default values for query-relevant fields + action.error = overrides.error ?? false; + action.blocked = overrides.blocked ?? true; + action.allowPush = overrides.allowPush ?? false; + action.authorised = overrides.authorised ?? false; + action.canceled = overrides.canceled ?? false; + action.rejected = overrides.rejected ?? false; + + return action; + }; + + describe('writeAudit', () => { + it('should write an action to the database', async () => { + const action = createTestAction({ id: 'write-audit-test' }); + + await writeAudit(action); + + const retrieved = await getPush('write-audit-test'); + expect(retrieved).not.toBeNull(); + expect(retrieved?.id).toBe('write-audit-test'); + }); + + it('should upsert an existing action', async () => { + const action = createTestAction({ id: 'upsert-test' }); + await writeAudit(action); + + action.blocked = false; + action.allowPush = true; + await writeAudit(action); + + const retrieved = await getPush('upsert-test'); + expect(retrieved?.blocked).toBe(false); + expect(retrieved?.allowPush).toBe(true); + }); + + it('should throw error for invalid id', async () => { + const action = createTestAction(); + (action as any).id = 123; // Invalid: should be string + + await expect(writeAudit(action)).rejects.toThrow('Invalid id'); + }); + + it('should strip _id from action before saving', async () => { + const action = createTestAction({ id: 'strip-id-test' }); + (action as any)._id = 'should-be-removed'; + + await writeAudit(action); + + const retrieved = await getPush('strip-id-test'); + expect(retrieved).not.toBeNull(); + expect(retrieved?.id).toBe('strip-id-test'); + }); + }); + + describe('getPush', () => { + it('should retrieve a push by id', async () => { + const action = createTestAction({ id: 'get-push-test' }); + await writeAudit(action); + + const result = await getPush('get-push-test'); + + expect(result).not.toBeNull(); + expect(result?.id).toBe('get-push-test'); + expect(result?.type).toBe('push'); + }); + + it('should return null for non-existent push', async () => { + const result = await getPush('non-existent-push'); + + expect(result).toBeNull(); + }); + + it('should return an Action instance', async () => { + const action = createTestAction({ id: 'action-instance-test' }); + await writeAudit(action); + + const result = await getPush('action-instance-test'); + + expect(Object.getPrototypeOf(result)).toBe(Action.prototype); + }); + }); + + describe('getPushes', () => { + beforeEach(async () => { + // Create test data with default query-matching values + await writeAudit( + createTestAction({ + id: 'push-list-1', + blocked: true, + allowPush: false, + authorised: false, + error: false, + }), + ); + await writeAudit( + createTestAction({ + id: 'push-list-2', + blocked: true, + allowPush: false, + authorised: false, + error: false, + }), + ); + await writeAudit( + createTestAction({ + id: 'push-authorised', + blocked: true, + allowPush: false, + authorised: true, + error: false, + }), + ); + }); + + it('should retrieve pushes matching default query', async () => { + const result = await getPushes(); + + // Default query: error=false, blocked=true, allowPush=false, authorised=false, type=push + const matchingPushes = result.filter((p) => ['push-list-1', 'push-list-2'].includes(p.id)); + expect(matchingPushes.length).toBe(2); + }); + + it('should filter pushes by custom query', async () => { + const result = await getPushes({ authorised: true }); + + const authorisedPush = result.find((p) => p.id === 'push-authorised'); + expect(authorisedPush).toBeDefined(); + }); + + it('should return projected fields only', async () => { + const result = await getPushes(); + + // Check that projection is applied (no _id, but has id) + result.forEach((push) => { + expect((push as any)._id).toBeUndefined(); + expect(push.id).toBeDefined(); + }); + }); + }); + + describe('deletePush', () => { + it('should delete a push by id', async () => { + const action = createTestAction({ id: 'delete-test' }); + await writeAudit(action); + + await deletePush('delete-test'); + + const result = await getPush('delete-test'); + expect(result).toBeNull(); + }); + + it('should not throw when deleting non-existent push', async () => { + await expect(deletePush('non-existent')).resolves.not.toThrow(); + }); + }); + + describe('authorise', () => { + it('should authorise a push and update flags', async () => { + const action = createTestAction({ + id: 'authorise-test', + authorised: false, + canceled: true, + rejected: true, + }); + await writeAudit(action); + + const result = await authorise('authorise-test', { note: 'approved' }); + + expect(result.message).toBe('authorised authorise-test'); + + const updated = await getPush('authorise-test'); + expect(updated?.authorised).toBe(true); + expect(updated?.canceled).toBe(false); + expect(updated?.rejected).toBe(false); + expect(updated?.attestation).toEqual({ note: 'approved' }); + }); + + it('should throw error for non-existent push', async () => { + await expect(authorise('non-existent', {})).rejects.toThrow('push non-existent not found'); + }); + }); + + describe('reject', () => { + it('should reject a push and update flags', async () => { + const action = createTestAction({ + id: 'reject-test', + authorised: true, + canceled: true, + rejected: false, + }); + await writeAudit(action); + + const result = await reject('reject-test', { reason: 'policy violation' }); + + expect(result.message).toBe('reject reject-test'); + + const updated = await getPush('reject-test'); + expect(updated?.authorised).toBe(false); + expect(updated?.canceled).toBe(false); + expect(updated?.rejected).toBe(true); + expect(updated?.attestation).toEqual({ reason: 'policy violation' }); + }); + + it('should throw error for non-existent push', async () => { + await expect(reject('non-existent', {})).rejects.toThrow('push non-existent not found'); + }); + }); + + describe('cancel', () => { + it('should cancel a push and update flags', async () => { + const action = createTestAction({ + id: 'cancel-test', + authorised: true, + canceled: false, + rejected: true, + }); + await writeAudit(action); + + const result = await cancel('cancel-test'); + + expect(result.message).toBe('canceled cancel-test'); + + const updated = await getPush('cancel-test'); + expect(updated?.authorised).toBe(false); + expect(updated?.canceled).toBe(true); + expect(updated?.rejected).toBe(false); + }); + + it('should throw error for non-existent push', async () => { + await expect(cancel('non-existent')).rejects.toThrow('push non-existent not found'); + }); + }); +}); diff --git a/test/db/mongo/repo.integration.test.ts b/test/db/mongo/repo.integration.test.ts new file mode 100644 index 000000000..a84bf7388 --- /dev/null +++ b/test/db/mongo/repo.integration.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + createRepo, + getRepo, + getRepos, + getRepoById, + getRepoByUrl, + addUserCanPush, + addUserCanAuthorise, + removeUserCanPush, + removeUserCanAuthorise, + deleteRepo, +} from '../../../src/db/mongo/repo'; +import { Repo } from '../../../src/db/types'; + +// Only run in CI where MongoDB is available, or when explicitly enabled locally +const shouldRunMongoTests = process.env.CI === 'true' || process.env.RUN_MONGO_TESTS === 'true'; + +describe.runIf(shouldRunMongoTests)('MongoDB Repo Integration Tests', () => { + const createTestRepo = (overrides: Partial = {}): Repo => { + return new Repo( + overrides.project || 'test-project', + overrides.name || `test-repo-${Date.now()}`, + overrides.url || `https://github.com/test/repo-${Date.now()}.git`, + overrides.users || { canPush: [], canAuthorise: [] }, + ); + }; + + describe('createRepo', () => { + it('should create a new repo and return it with an _id', async () => { + const repo = createTestRepo({ name: 'create-test-repo' }); + + const result = await createRepo(repo); + + expect(result._id).toBeDefined(); + expect(result.name).toBe('create-test-repo'); + expect(result.project).toBe('test-project'); + }); + + it('should persist the repo to the database', async () => { + const repo = createTestRepo({ name: 'persist-test-repo' }); + + const created = await createRepo(repo); + const fetched = await getRepoById(created._id!); + + expect(fetched).not.toBeNull(); + expect(fetched?.name).toBe('persist-test-repo'); + }); + }); + + describe('getRepo', () => { + it('should retrieve a repo by name (case-insensitive)', async () => { + const repo = createTestRepo({ name: 'case-test-repo' }); + await createRepo(repo); + + const result = await getRepo('CASE-TEST-REPO'); + + expect(result).not.toBeNull(); + expect(result?.name).toBe('case-test-repo'); + }); + + it('should return null for non-existent repo', async () => { + const result = await getRepo('non-existent-repo'); + + expect(result).toBeNull(); + }); + }); + + describe('getRepoByUrl', () => { + it('should retrieve a repo by URL', async () => { + const url = 'https://github.com/test/url-test.git'; + const repo = createTestRepo({ url }); + await createRepo(repo); + + const result = await getRepoByUrl(url); + + expect(result).not.toBeNull(); + expect(result?.url).toBe(url); + }); + + it('should return null for non-existent URL', async () => { + const result = await getRepoByUrl('https://github.com/non-existent/repo.git'); + + expect(result).toBeNull(); + }); + }); + + describe('getRepos', () => { + it('should retrieve all repos', async () => { + await createRepo(createTestRepo({ name: 'list-repo-1' })); + await createRepo(createTestRepo({ name: 'list-repo-2' })); + + const result = await getRepos(); + + expect(result.length).toBeGreaterThanOrEqual(2); + const names = result.map((r) => r.name); + expect(names).toContain('list-repo-1'); + expect(names).toContain('list-repo-2'); + }); + + it('should filter repos by query', async () => { + await createRepo(createTestRepo({ name: 'filter-repo', project: 'filter-project' })); + await createRepo(createTestRepo({ name: 'other-repo', project: 'other-project' })); + + const result = await getRepos({ project: 'filter-project' }); + + expect(result.length).toBe(1); + expect(result[0].name).toBe('filter-repo'); + }); + }); + + describe('user management', () => { + let testRepoId: string; + + beforeEach(async () => { + const repo = createTestRepo({ name: 'user-mgmt-repo' }); + const created = await createRepo(repo); + testRepoId = created._id!; + }); + + it('should add a user to canPush (lowercased)', async () => { + await addUserCanPush(testRepoId, 'TestUser'); + + const repo = await getRepoById(testRepoId); + expect(repo?.users.canPush).toContain('testuser'); + }); + + it('should add a user to canAuthorise (lowercased)', async () => { + await addUserCanAuthorise(testRepoId, 'AuthUser'); + + const repo = await getRepoById(testRepoId); + expect(repo?.users.canAuthorise).toContain('authuser'); + }); + + it('should remove a user from canPush', async () => { + await addUserCanPush(testRepoId, 'removeuser'); + await removeUserCanPush(testRepoId, 'RemoveUser'); + + const repo = await getRepoById(testRepoId); + expect(repo?.users.canPush).not.toContain('removeuser'); + }); + + it('should remove a user from canAuthorise', async () => { + await addUserCanAuthorise(testRepoId, 'removeauth'); + await removeUserCanAuthorise(testRepoId, 'RemoveAuth'); + + const repo = await getRepoById(testRepoId); + expect(repo?.users.canAuthorise).not.toContain('removeauth'); + }); + }); + + describe('deleteRepo', () => { + it('should delete a repo by id', async () => { + const repo = createTestRepo({ name: 'delete-test-repo' }); + const created = await createRepo(repo); + + await deleteRepo(created._id!); + + const result = await getRepoById(created._id!); + expect(result).toBeNull(); + }); + }); +}); diff --git a/test/db/mongo/users.integration.test.ts b/test/db/mongo/users.integration.test.ts new file mode 100644 index 000000000..ba7ea747b --- /dev/null +++ b/test/db/mongo/users.integration.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect } from 'vitest'; +import { + createUser, + findUser, + findUserByEmail, + findUserByOIDC, + getUsers, + updateUser, + deleteUser, +} from '../../../src/db/mongo/users'; +import { User } from '../../../src/db/types'; + +// Only run in CI where MongoDB is available, or when explicitly enabled locally +const shouldRunMongoTests = process.env.CI === 'true' || process.env.RUN_MONGO_TESTS === 'true'; + +describe.runIf(shouldRunMongoTests)('MongoDB Users Integration Tests', () => { + const createTestUser = (overrides: Partial = {}): User => { + const timestamp = Date.now(); + return new User( + overrides.username || `testuser-${timestamp}`, + overrides.password || 'hashedpassword123', + overrides.gitAccount || `git-${timestamp}`, + overrides.email || `test-${timestamp}@example.com`, + overrides.admin ?? false, + overrides.oidcId || null, + ); + }; + + describe('createUser', () => { + it('should create a user with lowercased username and email', async () => { + const user = createTestUser({ + username: 'CreateUser', + email: 'Create@Example.COM', + }); + + await createUser(user); + + const found = await findUser('createuser'); + expect(found).not.toBeNull(); + expect(found?.username).toBe('createuser'); + expect(found?.email).toBe('create@example.com'); + }); + }); + + describe('findUser', () => { + it('should find a user by username (case-insensitive)', async () => { + const user = createTestUser({ username: 'findme' }); + await createUser(user); + + const result = await findUser('FINDME'); + + expect(result).not.toBeNull(); + expect(result?.username).toBe('findme'); + }); + + it('should return null for non-existent user', async () => { + const result = await findUser('non-existent-user'); + + expect(result).toBeNull(); + }); + }); + + describe('findUserByEmail', () => { + it('should find a user by email (case-insensitive)', async () => { + const user = createTestUser({ email: 'findbyemail@test.com' }); + await createUser(user); + + const result = await findUserByEmail('FindByEmail@TEST.com'); + + expect(result).not.toBeNull(); + expect(result?.email).toBe('findbyemail@test.com'); + }); + + it('should return null for non-existent email', async () => { + const result = await findUserByEmail('nonexistent@test.com'); + + expect(result).toBeNull(); + }); + }); + + describe('findUserByOIDC', () => { + it('should find a user by OIDC ID', async () => { + const oidcId = `oidc-${Date.now()}`; + const user = createTestUser({ oidcId }); + await createUser(user); + + const result = await findUserByOIDC(oidcId); + + expect(result).not.toBeNull(); + expect(result?.oidcId).toBe(oidcId); + }); + + it('should return null for non-existent OIDC ID', async () => { + const result = await findUserByOIDC('non-existent-oidc'); + + expect(result).toBeNull(); + }); + }); + + describe('getUsers', () => { + it('should retrieve all users without passwords', async () => { + await createUser(createTestUser({ username: 'getusers1' })); + await createUser(createTestUser({ username: 'getusers2' })); + + const result = await getUsers(); + + expect(result.length).toBeGreaterThanOrEqual(2); + // Verify passwords are excluded + result.forEach((user) => { + expect(user.password).toBeUndefined(); + }); + }); + + it('should filter users by query (lowercased)', async () => { + await createUser(createTestUser({ username: 'filteruser', email: 'filter@test.com' })); + await createUser(createTestUser({ username: 'otheruser', email: 'other@test.com' })); + + const result = await getUsers({ username: 'FilterUser' }); + + expect(result.length).toBe(1); + expect(result[0].username).toBe('filteruser'); + }); + + it('should filter by email (lowercased)', async () => { + await createUser(createTestUser({ username: 'emailfilter', email: 'unique-email@test.com' })); + + const result = await getUsers({ email: 'Unique-Email@TEST.com' }); + + expect(result.length).toBe(1); + expect(result[0].email).toBe('unique-email@test.com'); + }); + }); + + describe('updateUser', () => { + it('should update user by username', async () => { + const user = createTestUser({ username: 'updateme', admin: false }); + await createUser(user); + + await updateUser({ username: 'UpdateMe', admin: true }); + + const updated = await findUser('updateme'); + expect(updated?.admin).toBe(true); + }); + + it('should update user by _id when provided', async () => { + const user = createTestUser({ username: 'updatebyid' }); + await createUser(user); + + const created = await findUser('updatebyid'); + await updateUser({ _id: (created as any)._id.toString(), gitAccount: 'new-git-account' }); + + const updated = await findUser('updatebyid'); + expect(updated?.gitAccount).toBe('new-git-account'); + }); + + it('should lowercase username and email during update', async () => { + const user = createTestUser({ username: 'lowercaseupdate' }); + await createUser(user); + + await updateUser({ username: 'LowerCaseUpdate', email: 'NEW@EMAIL.COM' }); + + const updated = await findUser('lowercaseupdate'); + expect(updated?.email).toBe('new@email.com'); + }); + }); + + describe('deleteUser', () => { + it('should delete a user by username (case-insensitive)', async () => { + const user = createTestUser({ username: 'deleteme' }); + await createUser(user); + + await deleteUser('DeleteMe'); + + const result = await findUser('deleteme'); + expect(result).toBeNull(); + }); + }); +}); diff --git a/test/db/mongoHelper.test.ts b/test/db/mongoHelper.test.ts new file mode 100644 index 000000000..c594df0ae --- /dev/null +++ b/test/db/mongoHelper.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockMongo = vi.hoisted(() => ({ + connect: vi.fn(), + db: { collection: vi.fn() }, +})); + +vi.mock('mongodb', async () => { + const actual = await vi.importActual('mongodb'); + + class MongoClient { + connectionString: string; + options: any; + constructor(connectionString: string, options: any) { + this.connectionString = connectionString; + this.options = options; + } + async connect() { + mockMongo.connect(); + return this; + } + db() { + return mockMongo.db; + } + } + + return { ...actual, MongoClient }; +}); + +const mockAws = vi.hoisted(() => ({ + fromNodeProviderChain: vi.fn(() => 'mock-credentials'), +})); +vi.mock('@aws-sdk/credential-providers', () => ({ + fromNodeProviderChain: mockAws.fromNodeProviderChain, +})); + +const mockMongoStore = vi.hoisted(() => ({ + lastOpts: null as unknown, + MockStore: vi.fn(), +})); +vi.mock('connect-mongo', () => ({ + default: class MockStore { + opts: unknown; + constructor(opts: unknown) { + this.opts = opts; + mockMongoStore.lastOpts = opts; + mockMongoStore.MockStore(opts); + } + }, +})); + +const mockConfig = vi.hoisted(() => ({ + getDatabase: vi.fn(), +})); +vi.mock('../../src/config', () => ({ + getDatabase: mockConfig.getDatabase, +})); + +import * as helper from '../../src/db/mongo/helper'; + +describe('mongo helper', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockMongo.db.collection.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('connect throws when connection string is missing', async () => { + mockConfig.getDatabase.mockReturnValue({ connectionString: '', options: {} }); + await expect(helper.connect('repos')).rejects.toThrow( + 'MongoDB connection string is not provided', + ); + }); + + it('connect uses AWS provider chain when configured', async () => { + const options = { authMechanismProperties: { AWS_CREDENTIAL_PROVIDER: true } }; + mockConfig.getDatabase.mockReturnValue({ connectionString: 'mongodb://example', options }); + mockMongo.db.collection.mockReturnValue({ name: 'repos' }); + + const collection = await helper.connect('repos'); + + expect(collection).toEqual({ name: 'repos' }); + expect(mockMongo.connect).toHaveBeenCalledTimes(1); + expect(mockAws.fromNodeProviderChain).toHaveBeenCalledTimes(1); + expect(options.authMechanismProperties.AWS_CREDENTIAL_PROVIDER).toBe('mock-credentials'); + }); + + it('findDocuments uses connect and returns results', async () => { + const docs = [{ id: 1 }]; + const toArray = vi.fn().mockResolvedValue(docs); + const find = vi.fn().mockReturnValue({ toArray }); + mockMongo.db.collection.mockReturnValue({ find }); + mockConfig.getDatabase.mockReturnValue({ connectionString: 'mongodb://example', options: {} }); + + const result = await helper.findDocuments('repos', { id: 1 }); + + expect(result).toEqual(docs); + expect(find).toHaveBeenCalledWith({ id: 1 }, {}); + }); + + it('findOneDocument uses connect and returns the record', async () => { + const findOne = vi.fn().mockResolvedValue({ id: 2 }); + mockMongo.db.collection.mockReturnValue({ findOne }); + mockConfig.getDatabase.mockReturnValue({ connectionString: 'mongodb://example', options: {} }); + + const result = await helper.findOneDocument('repos', { id: 2 }); + + expect(result).toEqual({ id: 2 }); + expect(findOne).toHaveBeenCalledWith({ id: 2 }, {}); + }); + + it('getSessionStore uses database config', () => { + mockConfig.getDatabase.mockReturnValue({ + connectionString: 'mongodb://example', + options: { tls: true }, + }); + + const store = helper.getSessionStore(); + + expect(store).toEqual({ + opts: { + mongoUrl: 'mongodb://example', + collectionName: 'user_session', + mongoOptions: { tls: true }, + }, + }); + expect(mockMongoStore.MockStore).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/db/mongoModules.test.ts b/test/db/mongoModules.test.ts new file mode 100644 index 000000000..a282f615a --- /dev/null +++ b/test/db/mongoModules.test.ts @@ -0,0 +1,334 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ObjectId } from 'mongodb'; + +const mockHelper = vi.hoisted(() => ({ + connect: vi.fn(), + findDocuments: vi.fn(), + findOneDocument: vi.fn(), +})); + +vi.mock('../../src/db/mongo/helper', () => ({ + connect: mockHelper.connect, + findDocuments: mockHelper.findDocuments, + findOneDocument: mockHelper.findOneDocument, +})); + +import { Action } from '../../src/proxy/actions'; +import * as pushes from '../../src/db/mongo/pushes'; +import * as repo from '../../src/db/mongo/repo'; +import * as users from '../../src/db/mongo/users'; + +describe('mongo module functions', () => { + const collection = { + find: vi.fn(), + findOne: vi.fn(), + insertOne: vi.fn(), + updateOne: vi.fn(), + deleteOne: vi.fn(), + deleteMany: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockHelper.connect.mockResolvedValue(collection); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('pushes', () => { + it('getPushes uses default query and projection', async () => { + mockHelper.findDocuments.mockResolvedValue([{ id: '1' }]); + + const result = await pushes.getPushes(); + + expect(result).toEqual([{ id: '1' }]); + expect(mockHelper.findDocuments).toHaveBeenCalledWith( + 'pushes', + { + error: false, + blocked: true, + allowPush: false, + authorised: false, + type: 'push', + }, + expect.objectContaining({ + projection: expect.objectContaining({ + id: 1, + allowPush: 1, + authorised: 1, + blocked: 1, + blockedMessage: 1, + }), + }), + ); + }); + + it('getPush returns null when not found', async () => { + mockHelper.findOneDocument.mockResolvedValue(null); + const result = await pushes.getPush('missing'); + expect(result).toBeNull(); + }); + + it('getPush returns Action when found', async () => { + mockHelper.findOneDocument.mockResolvedValue({ id: '123' }); + const result = await pushes.getPush('123'); + expect(result?.id).toBe('123'); + expect(Object.getPrototypeOf(result)).toBe(Action.prototype); + }); + + it('writeAudit throws for invalid id', async () => { + await expect(pushes.writeAudit({ id: 123 } as any)).rejects.toThrow('Invalid id'); + }); + + it('writeAudit upserts action and strips _id', async () => { + const action = { id: 'abc', _id: 'ignored', repo: 'r1' } as any; + + await pushes.writeAudit(action); + + expect(collection.updateOne).toHaveBeenCalledWith( + { id: 'abc' }, + { $set: { id: 'abc', repo: 'r1' } }, + { upsert: true }, + ); + }); + + it('authorise updates flags and writes audit', async () => { + mockHelper.findOneDocument.mockResolvedValue({ + id: 'p1', + authorised: false, + canceled: true, + rejected: true, + }); + + const result = await pushes.authorise('p1', { note: 'ok' }); + + expect(result).toEqual({ message: 'authorised p1' }); + expect(collection.updateOne).toHaveBeenCalledWith( + { id: 'p1' }, + { + $set: expect.objectContaining({ + authorised: true, + canceled: false, + rejected: false, + attestation: { note: 'ok' }, + }), + }, + { upsert: true }, + ); + }); + + it('reject updates flags and writes audit', async () => { + mockHelper.findOneDocument.mockResolvedValue({ + id: 'p2', + authorised: true, + canceled: true, + rejected: false, + }); + + const result = await pushes.reject('p2', { reason: 'no' }); + + expect(result).toEqual({ message: 'reject p2' }); + expect(collection.updateOne).toHaveBeenCalledWith( + { id: 'p2' }, + { + $set: expect.objectContaining({ + authorised: false, + canceled: false, + rejected: true, + attestation: { reason: 'no' }, + }), + }, + { upsert: true }, + ); + }); + + it('cancel updates flags and writes audit', async () => { + mockHelper.findOneDocument.mockResolvedValue({ + id: 'p3', + authorised: true, + canceled: false, + rejected: true, + }); + + const result = await pushes.cancel('p3'); + + expect(result).toEqual({ message: 'canceled p3' }); + expect(collection.updateOne).toHaveBeenCalledWith( + { id: 'p3' }, + { + $set: expect.objectContaining({ + authorised: false, + canceled: true, + rejected: false, + }), + }, + { upsert: true }, + ); + }); + + it('deletePush removes action by id', async () => { + await pushes.deletePush('p4'); + expect(collection.deleteOne).toHaveBeenCalledWith({ id: 'p4' }); + }); + }); + + describe('repo', () => { + it('getRepos maps docs to Repo instances', async () => { + const toArray = vi.fn().mockResolvedValue([{ name: 'one' }]); + collection.find.mockReturnValue({ toArray }); + + const result = await repo.getRepos(); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('one'); + }); + + it('getRepo lowercases name in query', async () => { + collection.findOne.mockResolvedValue({ name: 'demo' }); + + const result = await repo.getRepo('DeMo'); + + expect(collection.findOne).toHaveBeenCalledWith({ name: { $eq: 'demo' } }); + expect(result?.name).toBe('demo'); + }); + + it('getRepoByUrl queries by url', async () => { + collection.findOne.mockResolvedValue({ url: 'https://example.com' }); + + const result = await repo.getRepoByUrl('https://example.com'); + + expect(collection.findOne).toHaveBeenCalledWith({ url: { $eq: 'https://example.com' } }); + expect(result?.url).toBe('https://example.com'); + }); + + it('getRepoById uses ObjectId', async () => { + const oid = new ObjectId(); + collection.findOne.mockResolvedValue({ _id: oid, name: 'demo' }); + + const result = await repo.getRepoById(oid.toString()); + + expect(collection.findOne).toHaveBeenCalledWith({ _id: new ObjectId(oid.toString()) }); + expect(result?.name).toBe('demo'); + }); + + it('createRepo assigns inserted id', async () => { + const oid = new ObjectId(); + collection.insertOne.mockResolvedValue({ insertedId: oid }); + const record = { name: 'demo', users: { canPush: [], canAuthorise: [] } } as any; + + const result = await repo.createRepo(record); + + expect(result._id).toBe(oid.toString()); + expect(collection.insertOne).toHaveBeenCalled(); + }); + + it('addUserCanPush lowercases user', async () => { + const oid = new ObjectId(); + await repo.addUserCanPush(oid.toString(), 'UserA'); + expect(collection.updateOne).toHaveBeenCalledWith( + { _id: new ObjectId(oid.toString()) }, + { $push: { 'users.canPush': 'usera' } }, + ); + }); + + it('addUserCanAuthorise lowercases user', async () => { + const oid = new ObjectId(); + await repo.addUserCanAuthorise(oid.toString(), 'UserB'); + expect(collection.updateOne).toHaveBeenCalledWith( + { _id: new ObjectId(oid.toString()) }, + { $push: { 'users.canAuthorise': 'userb' } }, + ); + }); + + it('removeUserCanPush lowercases user', async () => { + const oid = new ObjectId(); + await repo.removeUserCanPush(oid.toString(), 'UserC'); + expect(collection.updateOne).toHaveBeenCalledWith( + { _id: new ObjectId(oid.toString()) }, + { $pull: { 'users.canPush': 'userc' } }, + ); + }); + + it('removeUserCanAuthorise lowercases user', async () => { + const oid = new ObjectId(); + await repo.removeUserCanAuthorise(oid.toString(), 'UserD'); + expect(collection.updateOne).toHaveBeenCalledWith( + { _id: new ObjectId(oid.toString()) }, + { $pull: { 'users.canAuthorise': 'userd' } }, + ); + }); + + it('deleteRepo deletes by id', async () => { + const oid = new ObjectId(); + await repo.deleteRepo(oid.toString()); + expect(collection.deleteMany).toHaveBeenCalledWith({ _id: new ObjectId(oid.toString()) }); + }); + }); + + describe('users', () => { + it('findUser lowercases username', async () => { + collection.findOne.mockResolvedValue({ username: 'user' }); + const result = await users.findUser('User'); + expect(collection.findOne).toHaveBeenCalledWith({ username: { $eq: 'user' } }); + expect(result?.username).toBe('user'); + }); + + it('findUserByEmail lowercases email', async () => { + collection.findOne.mockResolvedValue({ email: 'user@example.com' }); + const result = await users.findUserByEmail('User@Example.com'); + expect(collection.findOne).toHaveBeenCalledWith({ email: { $eq: 'user@example.com' } }); + expect(result?.email).toBe('user@example.com'); + }); + + it('findUserByOIDC uses oidcId', async () => { + collection.findOne.mockResolvedValue({ oidcId: 'oidc-1' }); + const result = await users.findUserByOIDC('oidc-1'); + expect(collection.findOne).toHaveBeenCalledWith({ oidcId: { $eq: 'oidc-1' } }); + expect(result?.oidcId).toBe('oidc-1'); + }); + + it('getUsers lowercases query and excludes passwords', async () => { + const toArray = vi.fn().mockResolvedValue([{ username: 'u1' }]); + const project = vi.fn().mockReturnValue({ toArray }); + collection.find.mockReturnValue({ project }); + + const result = await users.getUsers({ username: 'U1', email: 'A@B.com' }); + + expect(collection.find).toHaveBeenCalledWith({ username: 'u1', email: 'a@b.com' }); + expect(project).toHaveBeenCalledWith({ password: 0 }); + expect(result).toHaveLength(1); + }); + + it('deleteUser lowercases username', async () => { + await users.deleteUser('UserX'); + expect(collection.deleteOne).toHaveBeenCalledWith({ username: 'userx' }); + }); + + it('createUser lowercases username and email', async () => { + const user = { username: 'UserY', email: 'A@B.com' } as any; + await users.createUser(user); + expect(collection.insertOne).toHaveBeenCalledWith({ username: 'usery', email: 'a@b.com' }); + }); + + it('updateUser uses _id when provided', async () => { + const oid = new ObjectId(); + await users.updateUser({ _id: oid.toString(), username: 'UserZ' }); + expect(collection.updateOne).toHaveBeenCalledWith( + { _id: new ObjectId(oid.toString()) }, + { $set: { username: 'userz' } }, + { upsert: true }, + ); + }); + + it('updateUser uses username when _id not provided', async () => { + await users.updateUser({ username: 'UserQ', email: 'Q@E.com' }); + expect(collection.updateOne).toHaveBeenCalledWith( + { username: 'userq' }, + { $set: { username: 'userq', email: 'q@e.com' } }, + { upsert: true }, + ); + }); + }); +}); diff --git a/test/setup-integration.ts b/test/setup-integration.ts new file mode 100644 index 000000000..47903de69 --- /dev/null +++ b/test/setup-integration.ts @@ -0,0 +1,97 @@ +import { beforeAll, afterAll, afterEach } from 'vitest'; +import { MongoClient } from 'mongodb'; +import { resetConnection } from '../src/db/mongo/helper'; +import { invalidateCache } from '../src/config'; + +const DEFAULT_TEST_DB_NAME = 'git-proxy-test'; +const COLLECTIONS = ['repos', 'users', 'pushes', 'user_session']; + +let client: MongoClient | null = null; + +const getTestDbName = (connectionString: string): string => { + try { + const url = new URL(connectionString); + const dbName = url.pathname.replace(/^\//, ''); + return dbName || DEFAULT_TEST_DB_NAME; + } catch { + return DEFAULT_TEST_DB_NAME; + } +}; + +beforeAll(async () => { + const connectionString = + process.env.GIT_PROXY_MONGO_CONNECTION_STRING || + `mongodb://localhost:27017/${DEFAULT_TEST_DB_NAME}`; + const testDbName = getTestDbName(connectionString); + + // Connect to MongoDB for test cleanup + // In CI, MongoDB should always be available + // Locally, tests are skipped unless RUN_MONGO_TESTS=true + const shouldConnect = process.env.CI === 'true' || process.env.RUN_MONGO_TESTS === 'true'; + + if (shouldConnect) { + try { + client = new MongoClient(connectionString, { + serverSelectionTimeoutMS: 5000, + connectTimeoutMS: 5000, + }); + await client.connect(); + console.log(`MongoDB connection established for integration tests (${testDbName})`); + } catch (error) { + console.error('Failed to connect to MongoDB:', error); + throw error; // Fail fast in CI if MongoDB isn't available + } + } +}); + +afterEach(async () => { + // Clean up test data after each test + if (client) { + const dbName = getTestDbName( + process.env.GIT_PROXY_MONGO_CONNECTION_STRING || + `mongodb://localhost:27017/${DEFAULT_TEST_DB_NAME}`, + ); + const db = client.db(dbName); + for (const collection of COLLECTIONS) { + try { + await db.collection(collection).deleteMany({}); + } catch { + // Collection might not exist yet, ignore + } + } + } + + // Reset the helper's cached connection so each test starts fresh + try { + await resetConnection(); + } catch { + // Ignore if connection wasn't established + } + invalidateCache(); +}); + +afterAll(async () => { + // Clean up and close connections + try { + await resetConnection(); + } catch { + // Ignore if connection wasn't established + } + + if (client) { + // Drop test database + try { + const dbName = getTestDbName( + process.env.GIT_PROXY_MONGO_CONNECTION_STRING || + `mongodb://localhost:27017/${DEFAULT_TEST_DB_NAME}`, + ); + await client.db(dbName).dropDatabase(); + } catch { + // Ignore if database doesn't exist + } + await client.close(); + client = null; + } + + console.log('MongoDB integration test cleanup complete'); +}); diff --git a/vitest.config.integration.ts b/vitest.config.integration.ts new file mode 100644 index 000000000..5187054a8 --- /dev/null +++ b/vitest.config.integration.ts @@ -0,0 +1,23 @@ +import path from 'path'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.integration.test.ts'], + testTimeout: 30000, + hookTimeout: 10000, + setupFiles: ['test/setup-integration.ts'], + pool: 'forks', + poolOptions: { + forks: { + singleFork: true, + }, + }, + env: { + NODE_ENV: 'test', + // Use integration config with MongoDB enabled + CONFIG_FILE: path.resolve(__dirname, 'test-integration.proxy.config.json'), + GIT_PROXY_MONGO_CONNECTION_STRING: 'mongodb://localhost:27017/git-proxy-test', + }, + }, +}); diff --git a/website/docs/development/testing.mdx b/website/docs/development/testing.mdx index 2741c003f..bad11a078 100644 --- a/website/docs/development/testing.mdx +++ b/website/docs/development/testing.mdx @@ -4,7 +4,67 @@ title: Testing ## Testing -As of v1.19.2, GitProxy uses [Mocha](https://mochajs.org/) (`ts-mocha`) as the test runner, and [Chai](https://www.chaijs.com/) for unit test assertions. User interface tests are written in [Cypress](https://docs.cypress.io), and some fuzz testing is done with [`fast-check`](https://fast-check.dev/). +GitProxy uses [Vitest](https://vitest.dev/) as the test runner. User interface tests are written in [Cypress](https://docs.cypress.io). + +### Running Tests + +```bash +# Run unit tests +npm test + +# Run tests with coverage +npm run test-coverage + +# Run MongoDB integration tests (requires MongoDB) +RUN_MONGO_TESTS=true npm run test:integration +``` + +### Running Integration Tests with MongoDB + +The CI pipeline runs integration tests against a real MongoDB instance. To replicate this locally: + +#### 1. Start MongoDB with Docker + +Run MongoDB: + +```bash +docker run -d --name mongodb-test -p 27017:27017 mongo:7 +``` + +#### 2. Run the Integration Tests + +```bash +RUN_MONGO_TESTS=true npm run test:integration +``` + +The `RUN_MONGO_TESTS=true` environment variable is required to enable the MongoDB-specific tests locally. In CI, this is handled automatically via the `CI=true` environment variable. + +#### 3. Cleanup + +```bash +docker stop mongodb-test && docker rm mongodb-test +``` + +### Full CI-like Test Run + +To replicate the full CI test sequence locally: + +```bash +# Start MongoDB +docker run -d --name mongodb-test -p 27017:27017 mongo:7 + +# Build TypeScript (required for plugin tests) +npm run build-ts + +# Run coverage tests +npm run test-coverage + +# Run MongoDB integration tests +RUN_MONGO_TESTS=true npm run test:integration + +# Cleanup +docker stop mongodb-test && docker rm mongodb-test +``` ### Unit testing with Mocha and Chai