Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion test/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ test/
├── models.test.js # Database model tests - core unit test
├── morgan.test.js # Morgan logger tests - core unit test
├── nodemailer.test.js # Email tests - core unit test
├── passport.test.js # Auth tests - core unit test
├── passport.test.js # Auth & middleware tests - core unit test
├── session.test.js # Session model tests - core unit test
├── token-revocation.test.js # Token revocation tests - core unit test
├── user.test.js # User controller tests - core unit test
├── webauthn.test.js # WebAuthn controller tests - core unit test
└── playwright.config.js # Playwright configuration
```

Expand Down
2 changes: 1 addition & 1 deletion test/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ before(async () => {
process.env.MONGODB_URI = mockMongoDBUri;
// If we require the app at the beginning of this file
// it will try to connect to the database before the
// MongoMemoryServer is started which can cause the testes to fail
// MongoMemoryServer is started which can cause the tests to fail
// Hence we are making an exception for linting this require statement
/* eslint-disable global-require */
app = require('../app');
Expand Down
117 changes: 0 additions & 117 deletions test/auth.opt.test.js

This file was deleted.

12 changes: 11 additions & 1 deletion test/contact.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,17 @@ describe('Contact Controller', () => {
});

after(() => {
process.env = OLD_ENV;
// Restore only the specific env vars set in before() rather than replacing
// the entire process.env object. Replacing process.env loses variables set
// by process.loadEnvFile() in other test files (e.g. BASE_URL), which
// breaks any test that loads a module using those vars after this suite runs.
for (const key of ['SITE_CONTACT_EMAIL', 'GOOGLE_RECAPTCHA_SITE_KEY', 'GOOGLE_API_KEY', 'GOOGLE_PROJECT_ID']) {
if (key in OLD_ENV) {
process.env[key] = OLD_ENV[key];
} else {
delete process.env[key];
}
}
});

describe('GET /contact', () => {
Expand Down
173 changes: 172 additions & 1 deletion test/passport.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,181 @@ const mongoose = require('mongoose');
const validator = require('validator');
process.loadEnvFile(path.join(__dirname, '.env.test'));
const passportModule = require('../config/passport');
const { isAuthorized, _saveOAuth2UserTokens, _handleAuthLogin } = passportModule;
const { isAuthenticated, isAuthorized, _saveOAuth2UserTokens, _handleAuthLogin } = passportModule;
const User = require('../models/User');
// Load passport after config so strategies are registered
const passport = require('passport');

describe('Passport Config', () => {
// =========================================================================
// isAuthenticated middleware
// =========================================================================
describe('isAuthenticated Middleware', () => {
let req;
let res;
let next;

beforeEach(() => {
req = {
isAuthenticated: sinon.stub(),
flash: sinon.spy(),
};
res = { redirect: sinon.spy() };
next = sinon.spy();
});

afterEach(() => {
sinon.restore();
});

it('should call next() when authenticated', () => {
req.isAuthenticated.returns(true);
isAuthenticated(req, res, next);
expect(next.calledOnce).to.be.true;
expect(res.redirect.called).to.be.false;
});

it('should set an error flash and redirect /login when not authenticated', () => {
req.isAuthenticated.returns(false);
isAuthenticated(req, res, next);
expect(req.flash.calledWith('errors')).to.be.true;
expect(res.redirect.calledWith('/login')).to.be.true;
expect(next.called).to.be.false;
});
});

// =========================================================================
// serializeUser / deserializeUser
// =========================================================================
describe('serializeUser / deserializeUser', () => {
let findByIdStub;

beforeEach(() => {
findByIdStub = sinon.stub(User, 'findById');
});

afterEach(() => {
findByIdStub.restore();
});

it('serializeUser stores user.id', (done) => {
const fakeUser = new User({ _id: new mongoose.Types.ObjectId(), email: 'a@b.com' });
passport.serializeUser(fakeUser, (err, id) => {
expect(err).to.be.null;
expect(id).to.equal(fakeUser.id);
done();
});
});

it('deserializeUser returns user for a valid id', (done) => {
const fakeUser = new User({ _id: new mongoose.Types.ObjectId(), email: 'a@b.com' });
findByIdStub.resolves(fakeUser);
passport.deserializeUser(fakeUser.id, (err, user) => {
expect(err).to.be.null;
expect(user).to.equal(fakeUser);
expect(findByIdStub.calledWith(fakeUser.id)).to.be.true;
done();
});
});

it('deserializeUser calls done with error when findById rejects', (done) => {
const dbError = new Error('db failure');
findByIdStub.rejects(dbError);
passport.deserializeUser('some-id', (err, user) => {
expect(err).to.equal(dbError);
expect(user).to.be.undefined;
done();
});
});
});

// =========================================================================
// LocalStrategy
// =========================================================================
describe('LocalStrategy (local)', () => {
let findOneStub;

beforeEach(() => {
findOneStub = sinon.stub(User, 'findOne');
});

afterEach(() => {
sinon.restore();
});

function invokeLocal(email, password, done) {
// Use passport's public authenticate API rather than accessing private
// _strategies internals, which could change across passport versions.
const req = { body: { email, password }, session: {} };
passport.authenticate('local', (err, user, info) => {
done(err, user, info);
})(req, {}, (err) => done(err));
}

it('should return false with message when user not found', (done) => {
findOneStub.resolves(null);
invokeLocal('missing@example.com', 'pass', (err, user, info) => {
expect(err).to.be.null;
expect(user).to.be.false;
expect(info.msg).to.include('missing@example.com');
done();
});
});

it('should return false when user has no password (OAuth-only account)', (done) => {
const fakeUser = new User({ email: 'oauth@example.com', password: undefined });
findOneStub.resolves(fakeUser);
invokeLocal('oauth@example.com', 'whatever', (err, user, info) => {
expect(err).to.be.null;
expect(user).to.be.false;
expect(info.msg).to.include('sign-in provider');
done();
});
});

it('should return false with invalid message when password is wrong', (done) => {
const fakeUser = new User({ email: 'user@example.com', password: 'hashedpass' });
sinon.stub(fakeUser, 'comparePassword').callsFake((_pw, cb) => cb(null, false));
findOneStub.resolves(fakeUser);
invokeLocal('user@example.com', 'wrongpass', (err, user, info) => {
expect(err).to.be.null;
expect(user).to.be.false;
expect(info.msg).to.include('Invalid');
done();
});
});

it('should return the user when credentials are correct', (done) => {
const fakeUser = new User({ email: 'user@example.com', password: 'correcthash' });
sinon.stub(fakeUser, 'comparePassword').callsFake((_pw, cb) => cb(null, true));
findOneStub.resolves(fakeUser);
invokeLocal('user@example.com', 'correctpass', (err, user) => {
expect(err).to.be.null;
expect(user).to.equal(fakeUser);
done();
});
});

it('should normalize the email to lower-case when looking up user', (done) => {
findOneStub.resolves(null);
invokeLocal('UPPER@EXAMPLE.COM', 'pass', () => {
expect(findOneStub.firstCall.args[0]).to.deep.equal({ email: { $eq: 'upper@example.com' } });
done();
});
});

it('should propagate comparePassword errors through done', (done) => {
const fakeUser = new User({ email: 'user@example.com', password: 'hash' });
const compareError = new Error('bcrypt failure');
sinon.stub(fakeUser, 'comparePassword').callsFake((_pw, cb) => cb(compareError));
findOneStub.resolves(fakeUser);
invokeLocal('user@example.com', 'pass', (err) => {
expect(err).to.equal(compareError);
done();
});
});
});

describe('isAuthorized Middleware', () => {
let req;
let res;
Expand Down
1 change: 0 additions & 1 deletion test/tools/simple-link-image-check.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
- Scans Pug views under views/ai and views/api for href and img src attributes
- Scans README.md and PROD_CHECKLIST.md for http(s) links
- Only checks images used in button areas for ai/index and api/index (heuristic: img tags in those files)
- Performs HEAD, falls back to GET if needed
- Bounded concurrency
- Prints a readable report of where each URL was found and status
*/
Expand Down
Loading
Loading