Updated frontend

This commit is contained in:
2026-07-01 11:10:09 -04:00
parent ee21bdbf22
commit 5644bfa5ec
8104 changed files with 14976 additions and 386337 deletions
+57
View File
@@ -0,0 +1,57 @@
'use strict';
const { TEST_CREDS, login, request, app } = require('./setup');
describe('Auth — POST /api/auth/login', () => {
test('valid credentials return a token', async () => {
const res = await request(app)
.post('/api/auth/login')
.send(TEST_CREDS);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('token');
expect(typeof res.body.token).toBe('string');
expect(res.body.token.length).toBeGreaterThan(0);
});
test('wrong password returns 401', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ uid: TEST_CREDS.uid, password: 'wrongpassword' });
expect(res.status).toBe(401);
});
test('unknown user returns 401', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ uid: 'no_such_user_xyz', password: 'whatever' });
expect(res.status).toBe(401);
});
test('missing body fields returns an error', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({});
expect(res.status).toBeGreaterThanOrEqual(400);
});
});
describe('Auth — POST /api/auth/logout', () => {
test('logout with a valid token returns 200', async () => {
const token = await login();
const res = await request(app)
.post('/api/auth/logout')
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('message');
});
test('logout without a token still returns 200', async () => {
const res = await request(app).post('/api/auth/logout');
expect(res.status).toBe(200);
});
});
+239
View File
@@ -0,0 +1,239 @@
'use strict';
// Explicit cache-behaviour tests.
//
// The goal is NOT to re-test CRUD happy paths (those live in user.test.js and
// group.test.js). The goal is to verify the specific cache invariants:
//
// 1. Repeated reads return consistent data (cache doesn't corrupt results).
// 2. Concurrent requests share one LDAP fetch — no stampede.
// 3. Writes invalidate the user cache (stale data is never served after PUT).
// 4. Writes invalidate the group cache (stale data is never served after
// member/owner add or remove).
// 5. User.clearCache() can be called directly and leaves the next read clean.
const { TEST_CREDS, login, request, app } = require('./setup');
const { User } = require('../models/user_ldap');
const CACHE_TEST_GROUP = 'test_jest_cache_group';
const CACHE_TEST_USER = {
givenName: 'Cache',
sn: 'Tester',
mail: 'ctester@test.example.com',
userPassword: 'CacheTest!1',
};
const CACHE_TEST_UID = `${CACHE_TEST_USER.givenName[0]}${CACHE_TEST_USER.sn}`.toLowerCase(); // 'ctester'
let token;
beforeAll(async () => {
token = await login();
// Clean up any leftover artifacts from a previous failed run
await request(app).delete(`/api/user/${CACHE_TEST_UID}`).set('auth-token', token);
await request(app).delete(`/api/group/${CACHE_TEST_GROUP}`).set('auth-token', token);
});
afterAll(async () => {
await request(app).delete(`/api/user/${CACHE_TEST_UID}`).set('auth-token', token);
await request(app).delete(`/api/group/${CACHE_TEST_GROUP}`).set('auth-token', token);
});
// ---------------------------------------------------------------------------
// 1. Repeated reads are consistent
// ---------------------------------------------------------------------------
// Note: listDetail() on both User and Group uses a shared LDAP client without
// a promise-stampede guard, so these must be sequential — concurrent binds on
// one client cause the connection to stall.
describe('Cache — repeated reads are consistent', () => {
test('two serial GET /api/user/?detail=true calls return the same uid set', async () => {
const r1 = await request(app).get('/api/user/?detail=true').set('auth-token', token);
const r2 = await request(app).get('/api/user/?detail=true').set('auth-token', token);
expect(r1.status).toBe(200);
expect(r2.status).toBe(200);
const uids1 = r1.body.results.map(u => u.uid).sort();
const uids2 = r2.body.results.map(u => u.uid).sort();
expect(uids1).toEqual(uids2);
});
test('two serial GET /api/group/?detail=true calls return the same cn set', async () => {
const r1 = await request(app).get('/api/group/?detail=true').set('auth-token', token);
const r2 = await request(app).get('/api/group/?detail=true').set('auth-token', token);
expect(r1.status).toBe(200);
expect(r2.status).toBe(200);
const cns1 = r1.body.results.map(g => g.cn).sort();
const cns2 = r2.body.results.map(g => g.cn).sort();
expect(cns1).toEqual(cns2);
});
});
// ---------------------------------------------------------------------------
// 2. Concurrent request deduplication (promise-stampede guard in User.get)
// ---------------------------------------------------------------------------
describe('Cache — concurrent requests share one LDAP fetch', () => {
test('two simultaneous GET /api/user/:uid requests both succeed with the same data', async () => {
const [r1, r2] = await Promise.all([
request(app).get(`/api/user/${TEST_CREDS.uid}`).set('auth-token', token),
request(app).get(`/api/user/${TEST_CREDS.uid}`).set('auth-token', token),
]);
expect(r1.status).toBe(200);
expect(r2.status).toBe(200);
expect(r1.body.results.uid).toBe(TEST_CREDS.uid);
expect(r2.body.results.uid).toBe(TEST_CREDS.uid);
// Both responses must be identical
expect(r1.body.results.dn).toBe(r2.body.results.dn);
});
});
// ---------------------------------------------------------------------------
// 3. User cache invalidation after writes
// ---------------------------------------------------------------------------
describe('Cache — user cache is invalidated after writes', () => {
beforeAll(async () => {
await request(app).post('/api/user/').set('auth-token', token).send(CACHE_TEST_USER);
});
afterAll(async () => {
await request(app).delete(`/api/user/${CACHE_TEST_UID}`).set('auth-token', token);
});
test('new user is visible in list immediately (list cache invalidated on create)', async () => {
const res = await request(app)
.get('/api/user/?detail=true')
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body.results.some(u => u.uid === CACHE_TEST_UID)).toBe(true);
});
test('updated field is visible immediately (get cache invalidated on update)', async () => {
await request(app)
.put(`/api/user/${CACHE_TEST_UID}`)
.set('auth-token', token)
.send({ description: 'cache-invalidation-marker' });
const res = await request(app)
.get(`/api/user/${CACHE_TEST_UID}`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body.results.description).toBe('cache-invalidation-marker');
});
test('new password works immediately (cache invalidated on password change)', async () => {
const NEW_PW = 'CacheNew!2';
await request(app)
.put(`/api/user/${CACHE_TEST_UID}/password`)
.set('auth-token', token)
.send({ userPassword: NEW_PW });
const login = await request(app)
.post('/api/auth/login')
.send({ uid: CACHE_TEST_UID, password: NEW_PW });
expect(login.status).toBe(200);
expect(login.body).toHaveProperty('token');
});
test('deleted user is gone from list immediately (cache invalidated on delete)', async () => {
await request(app).delete(`/api/user/${CACHE_TEST_UID}`).set('auth-token', token);
const res = await request(app)
.get('/api/user/?detail=true')
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body.results.some(u => u.uid === CACHE_TEST_UID)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// 4. Group cache invalidation after writes
// ---------------------------------------------------------------------------
describe('Cache — group cache is invalidated after writes', () => {
const MEMBER_UID = 'wmantly';
beforeAll(async () => {
await request(app)
.post('/api/group/')
.set('auth-token', token)
.send({ name: CACHE_TEST_GROUP, description: 'cache test group' });
});
test('new group appears in list immediately (cache invalidated on create)', async () => {
const res = await request(app).get('/api/group/').set('auth-token', token);
expect(res.body.results).toContain(CACHE_TEST_GROUP);
});
test('added member appears in group detail immediately (cache invalidated on member add)', async () => {
await request(app)
.put(`/api/group/${CACHE_TEST_GROUP}/${MEMBER_UID}`)
.set('auth-token', token);
const res = await request(app)
.get(`/api/group/${CACHE_TEST_GROUP}`)
.set('auth-token', token);
expect(res.status).toBe(200);
const members = [].concat(res.body.results.member || []);
expect(members.some(dn => dn.includes(MEMBER_UID))).toBe(true);
});
test('removed member is gone from group detail immediately (cache invalidated on member remove)', async () => {
await request(app)
.delete(`/api/group/${CACHE_TEST_GROUP}/${MEMBER_UID}`)
.set('auth-token', token);
const res = await request(app)
.get(`/api/group/${CACHE_TEST_GROUP}`)
.set('auth-token', token);
expect(res.status).toBe(200);
const members = [].concat(res.body.results.member || []);
expect(members.some(dn => dn.includes(MEMBER_UID))).toBe(false);
});
test('deleted group is gone from list immediately (cache invalidated on delete)', async () => {
await request(app).delete(`/api/group/${CACHE_TEST_GROUP}`).set('auth-token', token);
const res = await request(app).get('/api/group/').set('auth-token', token);
expect(res.body.results).not.toContain(CACHE_TEST_GROUP);
});
});
// ---------------------------------------------------------------------------
// 5. User.clearCache() works via the model directly
// ---------------------------------------------------------------------------
describe('Cache — User.clearCache() flushes stale entries', () => {
test('clearCache() does not throw and subsequent read succeeds', async () => {
expect(() => User.clearCache()).not.toThrow();
const res = await request(app)
.get(`/api/user/${TEST_CREDS.uid}`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body.results.uid).toBe(TEST_CREDS.uid);
});
test('failed get for unknown user evicts promise from cache (no stuck promise)', async () => {
const BAD_UID = 'no_such_cache_user_xyz';
// First attempt — should 404
const r1 = await request(app).get(`/api/user/${BAD_UID}`).set('auth-token', token);
expect(r1.status).toBeGreaterThanOrEqual(400);
// Second attempt — must also 404, not return a stuck rejected promise
const r2 = await request(app).get(`/api/user/${BAD_UID}`).set('auth-token', token);
expect(r2.status).toBeGreaterThanOrEqual(400);
});
});
+20
View File
@@ -0,0 +1,20 @@
'use strict';
// Flush all test-prefix Redis keys before each test run so state is always clean.
// Uses model-redis's own bundled redis client since redis is not a top-level dep.
const { createClient } = require('../node_modules/model-redis/node_modules/redis');
module.exports = async function() {
const client = createClient();
await client.connect();
const keys = await client.keys('sso_manager_test_*');
if (keys.length) {
await client.del(keys);
console.log(`[globalSetup] Flushed ${keys.length} test Redis key(s).`);
} else {
console.log('[globalSetup] No test Redis keys to flush.');
}
await client.quit();
};
+204
View File
@@ -0,0 +1,204 @@
'use strict';
const { TEST_CREDS, login, request, app } = require('./setup');
const TEST_GROUP = {
name: 'test_jest_group',
description: 'Created by automated test suite',
};
let token;
let firstGroupCN;
beforeAll(async () => {
token = await login();
// Clean up any leftover group from a previous failed run
await request(app).delete(`/api/group/${TEST_GROUP.name}`).set('auth-token', token);
});
afterAll(async () => {
await request(app).delete(`/api/group/${TEST_GROUP.name}`).set('auth-token', token);
});
describe('Groups — GET /api/group/', () => {
test('requires auth — 401 without token', async () => {
const res = await request(app).get('/api/group/');
expect(res.status).toBe(401);
});
test('returns group list for authenticated user', async () => {
const res = await request(app)
.get('/api/group/')
.set('auth-token', token);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.results)).toBe(true);
expect(res.body.results.length).toBeGreaterThan(0);
firstGroupCN = res.body.results[0];
});
test('detail=true returns full group objects', async () => {
const res = await request(app)
.get('/api/group/?detail=true')
.set('auth-token', token);
expect(res.status).toBe(200);
const group = res.body.results[0];
expect(group).toHaveProperty('cn');
expect(group).toHaveProperty('dn');
expect(group).toHaveProperty('description');
});
});
describe('Groups — GET /api/group/:cn', () => {
test('returns a single group by cn', async () => {
const res = await request(app)
.get(`/api/group/${firstGroupCN}`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body.results).toHaveProperty('cn', firstGroupCN);
});
test('unknown cn returns an error', async () => {
const res = await request(app)
.get('/api/group/no_such_group_xyz')
.set('auth-token', token);
expect(res.status).toBeGreaterThanOrEqual(400);
});
});
describe('Groups — POST /api/group/ (create)', () => {
test('creates a new group', async () => {
const res = await request(app)
.post('/api/group/')
.set('auth-token', token)
.send(TEST_GROUP);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('message');
expect(res.body.message).toMatch(TEST_GROUP.name);
});
test('new group appears in list', async () => {
const res = await request(app)
.get('/api/group/')
.set('auth-token', token);
expect(res.body.results).toContain(TEST_GROUP.name);
});
test('requires admin — 401 without token', async () => {
const res = await request(app)
.post('/api/group/')
.send(TEST_GROUP);
expect(res.status).toBe(401);
});
});
// groupOfNames requires at least one member. The creator (test) is auto-added.
// We test add/remove with a second known LDAP user to avoid the last-member constraint.
const MEMBER_UID = 'wmantly';
describe('Groups — member management', () => {
test('creator is already a member after group creation', async () => {
const res = await request(app)
.get(`/api/group/${TEST_GROUP.name}`)
.set('auth-token', token);
const group = res.body.results;
const members = Array.isArray(group.member) ? group.member : [group.member];
expect(members.some(dn => dn && dn.includes(TEST_CREDS.uid))).toBe(true);
});
test('PUT /:group/:uid — add second member', async () => {
const res = await request(app)
.put(`/api/group/${TEST_GROUP.name}/${MEMBER_UID}`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('message');
});
test('second member appears in group detail', async () => {
const res = await request(app)
.get(`/api/group/${TEST_GROUP.name}`)
.set('auth-token', token);
const group = res.body.results;
const members = Array.isArray(group.member) ? group.member : [group.member];
expect(members.some(dn => dn && dn.includes(MEMBER_UID))).toBe(true);
});
test('DELETE /:group/:uid — remove second member', async () => {
const res = await request(app)
.delete(`/api/group/${TEST_GROUP.name}/${MEMBER_UID}`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('message');
});
test('second member no longer in group after removal', async () => {
const res = await request(app)
.get(`/api/group/${TEST_GROUP.name}`)
.set('auth-token', token);
const group = res.body.results;
const members = Array.isArray(group.member) ? group.member : [group.member];
expect(members.some(dn => dn && dn.includes(MEMBER_UID))).toBe(false);
});
});
describe('Groups — owner management', () => {
test('PUT /owner/:group/:uid — add second owner', async () => {
const res = await request(app)
.put(`/api/group/owner/${TEST_GROUP.name}/${MEMBER_UID}`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('message');
});
test('second owner appears in group detail', async () => {
const res = await request(app)
.get(`/api/group/${TEST_GROUP.name}`)
.set('auth-token', token);
const group = res.body.results;
const owners = Array.isArray(group.owner) ? group.owner : [group.owner];
expect(owners.some(dn => dn && dn.includes(MEMBER_UID))).toBe(true);
});
test('DELETE /owner/:group/:uid — remove second owner', async () => {
const res = await request(app)
.delete(`/api/group/owner/${TEST_GROUP.name}/${MEMBER_UID}`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('message');
});
});
describe('Groups — DELETE /api/group/:cn', () => {
test('deletes the test group', async () => {
const res = await request(app)
.delete(`/api/group/${TEST_GROUP.name}`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('message');
expect(res.body.message).toMatch(TEST_GROUP.name);
});
test('group no longer appears in list', async () => {
const res = await request(app)
.get('/api/group/')
.set('auth-token', token);
expect(res.body.results).not.toContain(TEST_GROUP.name);
});
});
+91
View File
@@ -0,0 +1,91 @@
'use strict';
const { TEST_CREDS, login, request, app } = require('./setup');
// Target an existing user that is NOT the admin (wmantly is always present in the test LDAP)
const TARGET_UID = 'wmantly';
let token;
beforeAll(async () => {
token = await login();
// Clean up any leftover impersonation from a previous run
await request(app).delete(`/api/auth/impersonate/${TARGET_UID}`).set('auth-token', token);
});
afterAll(async () => {
await request(app).delete(`/api/auth/impersonate/${TARGET_UID}`).set('auth-token', token);
});
describe('Impersonation — POST /api/auth/impersonate/:uid', () => {
test('requires auth — 401 without token', async () => {
const res = await request(app).post(`/api/auth/impersonate/${TARGET_UID}`);
expect(res.status).toBe(401);
});
test('admin can create impersonation and receives temp credentials', async () => {
const res = await request(app)
.post(`/api/auth/impersonate/${TARGET_UID}`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('uid', TARGET_UID);
expect(res.body).toHaveProperty('temp_password');
expect(res.body).toHaveProperty('expires_at');
expect(typeof res.body.temp_password).toBe('string');
expect(res.body.temp_password.length).toBeGreaterThan(8);
});
test('temp password works for LDAP login', async () => {
// Create fresh impersonation to get the temp password
const impRes = await request(app)
.post(`/api/auth/impersonate/${TARGET_UID}`)
.set('auth-token', token);
const tempPassword = impRes.body.temp_password;
const loginRes = await request(app)
.post('/api/auth/login')
.send({ uid: TARGET_UID, password: tempPassword });
expect(loginRes.status).toBe(200);
expect(loginRes.body).toHaveProperty('token');
});
test('rejects unknown uid', async () => {
const res = await request(app)
.post('/api/auth/impersonate/no_such_user_xyz')
.set('auth-token', token);
expect(res.status).toBeGreaterThanOrEqual(400);
});
});
describe('Impersonation — DELETE /api/auth/impersonate/:uid', () => {
test('admin can revoke impersonation', async () => {
const res = await request(app)
.delete(`/api/auth/impersonate/${TARGET_UID}`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('message');
});
test('temp password no longer works after revocation', async () => {
// Create a fresh one, capture the password, revoke, then try login
const impRes = await request(app)
.post(`/api/auth/impersonate/${TARGET_UID}`)
.set('auth-token', token);
const tempPassword = impRes.body.temp_password;
await request(app)
.delete(`/api/auth/impersonate/${TARGET_UID}`)
.set('auth-token', token);
const loginRes = await request(app)
.post('/api/auth/login')
.send({ uid: TARGET_UID, password: tempPassword });
expect(loginRes.status).toBe(401);
});
});
+139
View File
@@ -0,0 +1,139 @@
'use strict';
const { TEST_CREDS, login, request, app } = require('./setup');
let token;
let inviteTokenId; // the invite token string returned by POST /api/user/invite
beforeAll(async () => {
token = await login();
});
// Clean up any invite tokens we created — there's no bulk delete API so we
// rely on the DELETE endpoint tests and the fact that Redis keys are flushed
// by globalSetup before every run.
describe('Invite — POST /api/user/invite (create)', () => {
test('requires auth — 401 without token', async () => {
const res = await request(app)
.post('/api/user/invite')
.send({ mail: 'nobody@example.com', groups: [] });
expect(res.status).toBe(401);
});
test('admin can create an invite with no mail and no groups', async () => {
const res = await request(app)
.post('/api/user/invite')
.set('auth-token', token)
.send({});
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('token');
expect(typeof res.body.token).toBe('string');
expect(res.body.token.length).toBeGreaterThan(0);
expect(res.body).toHaveProperty('link');
// mail was not provided, so mail_sent should be false
expect(res.body.mail_sent).toBe(false);
inviteTokenId = res.body.token;
});
test('admin can create an invite with groups', async () => {
const res = await request(app)
.post('/api/user/invite')
.set('auth-token', token)
.send({ groups: ['app_sso_admin'] });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('token');
});
});
describe('Invite — GET /api/user/invite (list)', () => {
test('requires auth — 401 without token', async () => {
const res = await request(app).get('/api/user/invite');
expect(res.status).toBe(401);
});
test('admin can list invite tokens', async () => {
const res = await request(app)
.get('/api/user/invite')
.set('auth-token', token);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.results)).toBe(true);
});
test('list includes the token created above', async () => {
expect(inviteTokenId).toBeDefined();
const res = await request(app)
.get('/api/user/invite')
.set('auth-token', token);
const found = res.body.results.find(t => t.token === inviteTokenId);
expect(found).toBeDefined();
// isPrivate field on the base token class must be exposed in the list response
expect(found).toHaveProperty('token', inviteTokenId);
});
});
describe('Invite — PUT /api/user/invite/:token (update)', () => {
test('requires auth — 401 without token', async () => {
expect(inviteTokenId).toBeDefined();
const res = await request(app)
.put(`/api/user/invite/${inviteTokenId}`)
.send({ groups: ['app_sso_users'] });
expect(res.status).toBe(401);
});
test('admin can update groups on an invite token', async () => {
expect(inviteTokenId).toBeDefined();
const res = await request(app)
.put(`/api/user/invite/${inviteTokenId}`)
.set('auth-token', token)
.send({ groups: ['app_sso_users'] });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('results');
expect(res.body.results).toHaveProperty('token', inviteTokenId);
});
test('updating a nonexistent token returns an error', async () => {
const res = await request(app)
.put('/api/user/invite/00000000-0000-0000-0000-000000000000')
.set('auth-token', token)
.send({ groups: [] });
expect(res.status).toBeGreaterThanOrEqual(400);
});
});
describe('Invite — DELETE /api/user/invite/:token (invalidate)', () => {
test('requires auth — 401 without token', async () => {
expect(inviteTokenId).toBeDefined();
const res = await request(app)
.delete(`/api/user/invite/${inviteTokenId}`);
expect(res.status).toBe(401);
});
test('admin can invalidate an invite token', async () => {
expect(inviteTokenId).toBeDefined();
const res = await request(app)
.delete(`/api/user/invite/${inviteTokenId}`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('results', true);
});
test('invalidated token can no longer be updated', async () => {
expect(inviteTokenId).toBeDefined();
const res = await request(app)
.put(`/api/user/invite/${inviteTokenId}`)
.set('auth-token', token)
.send({ groups: [] });
// Should be 400 (token is no longer valid)
expect(res.status).toBe(400);
});
});
+503
View File
@@ -0,0 +1,503 @@
'use strict';
// Tests for endpoints not covered by other test files:
// GET /api/auth/username-suggestions
// POST /api/auth/resetpassword
// POST /api/auth/resetpassword/:token
// POST /api/auth/invite/:token (email-verify step of invite flow)
// POST /api/auth/invite/:token/:mailToken (accept invite, create account)
// POST /api/user/accept-tos
// POST /api/user/key
// GET /api/user/:uid/verification
// GET /api/token/
// GET /api/token/:name
// GET /api/token/:name/:token
const { TEST_CREDS, login, request, app } = require('./setup');
const { PasswordResetToken, InviteToken } = require('../models/token');
// Dedicated test user — created in beforeAll, removed in afterAll.
const TEST_USER = {
givenName: 'Misc',
sn: 'Tester',
mail: 'mtester@test.example.com',
mobile: '5555550099',
userPassword: 'MiscTest!77',
};
const TEST_UID = 'mtester'; // givenName[0] + sn lowercase
// The uid assigned to the invite-accept user (filled in by beforeAll for that describe block).
let createdInviteUid;
let token;
beforeAll(async () => {
token = await login();
// Remove any leftovers from a previous failed run.
await request(app).delete(`/api/user/${TEST_UID}`).set('auth-token', token);
// Clean up potential invite-accept user leftovers (givenName=Invite, sn=Acceptor).
for (const uid of ['iacceptor', 'iacceptor2', 'iacceptor3', 'inviteacceptor', 'invite_acceptor', 'invitea', 'acceptori']) {
await request(app).delete(`/api/user/${uid}`).set('auth-token', token);
}
// Create the test user used by most describes below.
await request(app).post('/api/user/').set('auth-token', token).send(TEST_USER);
});
afterAll(async () => {
await request(app).delete(`/api/user/${TEST_UID}`).set('auth-token', token);
// Clean up any user created by the invite-accept test.
if (createdInviteUid) {
await request(app).delete(`/api/user/${createdInviteUid}`).set('auth-token', token);
}
});
// ---------------------------------------------------------------------------
// GET /api/auth/username-suggestions
// ---------------------------------------------------------------------------
describe('Auth — GET /api/auth/username-suggestions', () => {
test('missing params returns empty suggestions array', async () => {
const res = await request(app).get('/api/auth/username-suggestions');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('suggestions');
expect(res.body.suggestions).toEqual([]);
});
test('only sn (no givenName) returns empty suggestions', async () => {
const res = await request(app)
.get('/api/auth/username-suggestions')
.query({ sn: 'Uniqueish' });
expect(res.status).toBe(200);
expect(res.body.suggestions).toEqual([]);
});
test('givenName + sn returns at least one suggestion', async () => {
const res = await request(app)
.get('/api/auth/username-suggestions')
.query({ givenName: 'Unique', sn: 'Xyzzyabc' });
expect(res.status).toBe(200);
expect(Array.isArray(res.body.suggestions)).toBe(true);
expect(res.body.suggestions.length).toBeGreaterThan(0);
// Primary suggestion should be first-initial + last-name
expect(res.body.suggestions[0]).toMatch(/^uxyzzyabc/);
});
test('dob adds year-suffixed suggestions', async () => {
const res = await request(app)
.get('/api/auth/username-suggestions')
.query({ givenName: 'Dob', sn: 'Testerly', dob: '1990-06-15' });
expect(res.status).toBe(200);
expect(res.body.suggestions.length).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------------
// POST /api/auth/resetpassword
// ---------------------------------------------------------------------------
describe('Auth — POST /api/auth/resetpassword', () => {
test('unknown email returns error status', async () => {
const res = await request(app)
.post('/api/auth/resetpassword')
.send({ mail: 'nobody_at_all@noreply.example.com' });
expect(res.status).toBeGreaterThanOrEqual(400);
});
test('known email returns 200 with message (SMTP failure is non-fatal)', async () => {
const res = await request(app)
.post('/api/auth/resetpassword')
.send({ mail: TEST_USER.mail });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('message');
});
});
// ---------------------------------------------------------------------------
// POST /api/auth/resetpassword/:token
// ---------------------------------------------------------------------------
describe('Auth — POST /api/auth/resetpassword/:token', () => {
const RESET_PASSWORD = 'ResetPass!44';
test('invalid / unknown token returns error', async () => {
const res = await request(app)
.post('/api/auth/resetpassword/00000000-0000-0000-0000-000000000000')
.send({ userPassword: RESET_PASSWORD });
expect(res.status).toBeGreaterThanOrEqual(400);
});
test('valid token resets the password and login succeeds', async () => {
const resetToken = await PasswordResetToken.create({ created_by: TEST_UID });
const res = await request(app)
.post(`/api/auth/resetpassword/${resetToken.token}`)
.send({ userPassword: RESET_PASSWORD });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('message');
// Verify the new password works.
const loginRes = await request(app)
.post('/api/auth/login')
.send({ uid: TEST_UID, password: RESET_PASSWORD });
expect(loginRes.status).toBe(200);
expect(loginRes.body).toHaveProperty('token');
// Restore the original password so later tests that reuse this user still work.
await request(app)
.put(`/api/user/${TEST_UID}/password`)
.set('auth-token', token)
.send({ userPassword: TEST_USER.userPassword });
});
test('token can only be used once', async () => {
const resetToken = await PasswordResetToken.create({ created_by: TEST_UID });
// First use should succeed.
const first = await request(app)
.post(`/api/auth/resetpassword/${resetToken.token}`)
.send({ userPassword: RESET_PASSWORD });
expect(first.status).toBe(200);
// Restore password before second attempt.
await request(app)
.put(`/api/user/${TEST_UID}/password`)
.set('auth-token', token)
.send({ userPassword: TEST_USER.userPassword });
// Second use of the same token must be rejected.
const second = await request(app)
.post(`/api/auth/resetpassword/${resetToken.token}`)
.send({ userPassword: RESET_PASSWORD });
expect(second.status).toBeGreaterThanOrEqual(400);
});
});
// ---------------------------------------------------------------------------
// POST /api/auth/invite/:token (email-verify step)
// ---------------------------------------------------------------------------
describe('Auth — POST /api/auth/invite/:token (email verification for invite)', () => {
test('invalid token returns error', async () => {
const res = await request(app)
.post('/api/auth/invite/00000000-0000-0000-0000-000000000000')
.send({ mail: 'nobody@test.example.com' });
expect(res.status).toBeGreaterThanOrEqual(400);
});
test('valid token updates token mail and responds with sent (SMTP failure is non-fatal)', async () => {
const invToken = await InviteToken.create({ created_by: TEST_CREDS.uid });
const res = await request(app)
.post(`/api/auth/invite/${invToken.token}`)
.send({ mail: 'verifyinvite@test.example.com' });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('message', 'sent');
// Invalidate the token so it doesn't pollute other tests.
await invToken.update({ is_valid: false });
});
test('email already in use returns error', async () => {
const invToken = await InviteToken.create({ created_by: TEST_CREDS.uid });
// TEST_USER.mail already belongs to mtester — it is "in use".
const res = await request(app)
.post(`/api/auth/invite/${invToken.token}`)
.send({ mail: TEST_USER.mail });
expect(res.status).toBeGreaterThanOrEqual(400);
await invToken.update({ is_valid: false });
});
});
// ---------------------------------------------------------------------------
// POST /api/auth/invite/:token/:mailToken (accept invite, create account)
// ---------------------------------------------------------------------------
describe('Auth — POST /api/auth/invite/:token/:mailToken (accept invite)', () => {
let inviteTokenId;
const INVITE_MAIL_TOKEN = 'misc-suite-mailtoken-12345678901234';
const INVITE_MAIL = 'iacceptor@test.example.com';
const INVITE_GIVENNAME = 'Invite';
const INVITE_SN = 'Acceptor';
const INVITE_PASSWORD = 'InviteAcc!88';
beforeAll(async () => {
// Get a valid username suggestion for this user so we can pass it in the request.
const suggestRes = await request(app)
.get('/api/auth/username-suggestions')
.query({ givenName: INVITE_GIVENNAME, sn: INVITE_SN });
createdInviteUid = suggestRes.body.suggestions[0];
// Build the invite token directly in Redis (avoids needing real SMTP).
const invToken = await InviteToken.create({ created_by: TEST_CREDS.uid });
inviteTokenId = invToken.token;
await invToken.update({ mail: INVITE_MAIL, mail_token: INVITE_MAIL_TOKEN });
});
test('unknown token returns error', async () => {
const res = await request(app)
.post('/api/auth/invite/00000000-0000-0000-0000-000000000000/anytoken')
.send({ givenName: INVITE_GIVENNAME, sn: INVITE_SN, uid: 'nobody', userPassword: INVITE_PASSWORD });
expect(res.status).toBeGreaterThanOrEqual(400);
});
test('invalid uid (not in suggestions) returns 400', async () => {
const res = await request(app)
.post(`/api/auth/invite/${inviteTokenId}/${INVITE_MAIL_TOKEN}`)
.send({ givenName: INVITE_GIVENNAME, sn: INVITE_SN, uid: 'definitely_not_suggested', userPassword: INVITE_PASSWORD });
expect(res.status).toBe(400);
});
test('valid invite creates a new user account and returns an auth token', async () => {
expect(createdInviteUid).toBeDefined();
const res = await request(app)
.post(`/api/auth/invite/${inviteTokenId}/${INVITE_MAIL_TOKEN}`)
.send({
givenName: INVITE_GIVENNAME,
sn: INVITE_SN,
uid: createdInviteUid,
userPassword: INVITE_PASSWORD,
});
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('token');
expect(res.body).toHaveProperty('user', createdInviteUid);
});
test('consumed token cannot be reused', async () => {
const res = await request(app)
.post(`/api/auth/invite/${inviteTokenId}/${INVITE_MAIL_TOKEN}`)
.send({
givenName: INVITE_GIVENNAME,
sn: INVITE_SN,
uid: createdInviteUid,
userPassword: INVITE_PASSWORD,
});
expect(res.status).toBeGreaterThanOrEqual(400);
});
});
// ---------------------------------------------------------------------------
// POST /api/user/accept-tos
// ---------------------------------------------------------------------------
describe('Users — POST /api/user/accept-tos', () => {
test('requires auth — 401 without token', async () => {
const res = await request(app).post('/api/user/accept-tos');
expect(res.status).toBe(401);
});
test('marks TOS accepted for the authenticated user', async () => {
const userToken = (await request(app)
.post('/api/auth/login')
.send({ uid: TEST_UID, password: TEST_USER.userPassword })).body.token;
const res = await request(app)
.post('/api/user/accept-tos')
.set('auth-token', userToken);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('success', true);
});
test('TOS acceptance is reflected in the verification record', async () => {
const res = await request(app)
.get(`/api/user/${TEST_UID}/verification`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body.tosAccepted).toBe(true);
});
});
// ---------------------------------------------------------------------------
// POST /api/user/key (add SSH public key)
// ---------------------------------------------------------------------------
describe('Users — POST /api/user/key', () => {
// A syntactically valid OpenSSH public key.
const TEST_SSH_KEY = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7Jmtest0123456789abcdefghijklmno test@misc-suite';
test('requires auth — 401 without token', async () => {
const res = await request(app)
.post('/api/user/key')
.send({ key: TEST_SSH_KEY });
expect(res.status).toBe(401);
});
test('authenticated user can add an SSH key', async () => {
const userToken = (await request(app)
.post('/api/auth/login')
.send({ uid: TEST_UID, password: TEST_USER.userPassword })).body.token;
const res = await request(app)
.post('/api/user/key')
.set('auth-token', userToken)
.send({ key: TEST_SSH_KEY });
// 200 = added, 400 = already added (both are valid outcomes)
expect([200, 400]).toContain(res.status);
expect(res.body).toHaveProperty('message');
});
});
// ---------------------------------------------------------------------------
// GET /api/user/:uid/verification
// ---------------------------------------------------------------------------
describe('Users — GET /api/user/:uid/verification', () => {
test('requires auth — 401 without token', async () => {
const res = await request(app).get(`/api/user/${TEST_UID}/verification`);
expect(res.status).toBe(401);
});
test('admin can retrieve verification status for a user', async () => {
const res = await request(app)
.get(`/api/user/${TEST_UID}/verification`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('uid', TEST_UID);
expect(res.body).toHaveProperty('emailVerified');
expect(res.body).toHaveProperty('phoneVerified');
expect(res.body).toHaveProperty('tosAccepted');
expect(res.body).toHaveProperty('tosAcceptedAt');
});
test('non-admin without admin group returns 401', async () => {
const userToken = (await request(app)
.post('/api/auth/login')
.send({ uid: TEST_UID, password: TEST_USER.userPassword })).body.token;
const res = await request(app)
.get(`/api/user/${TEST_UID}/verification`)
.set('auth-token', userToken);
expect(res.status).toBe(401);
});
test('unknown uid returns error', async () => {
const res = await request(app)
.get('/api/user/no_such_user_xyz/verification')
.set('auth-token', token);
// UserVerification.getOrCreate creates a record even for unknowns in some
// implementations; accept 200 or 4xx as long as it responds.
expect(res.status).toBeGreaterThanOrEqual(200);
});
});
// ---------------------------------------------------------------------------
// GET /api/token/
// ---------------------------------------------------------------------------
describe('Tokens — GET /api/token/', () => {
test('requires auth — 401 without token', async () => {
const res = await request(app).get('/api/token/');
expect(res.status).toBe(401);
});
test('returns list of token-type names', async () => {
const res = await request(app)
.get('/api/token/')
.set('auth-token', token);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.results)).toBe(true);
expect(res.body.results.length).toBeGreaterThan(0);
// Base Token class is deleted; known types should include InviteToken.
expect(res.body.results).toContain('InviteToken');
});
});
// ---------------------------------------------------------------------------
// GET /api/token/:name
// ---------------------------------------------------------------------------
describe('Tokens — GET /api/token/:name', () => {
test('requires auth — 401 without token', async () => {
const res = await request(app).get('/api/token/InviteToken');
expect(res.status).toBe(401);
});
test('returns list of token ids for InviteToken', async () => {
const res = await request(app)
.get('/api/token/InviteToken')
.set('auth-token', token);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.results)).toBe(true);
});
test('detail=true returns full token objects', async () => {
const res = await request(app)
.get('/api/token/InviteToken')
.query({ detail: true })
.set('auth-token', token);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.results)).toBe(true);
// The 'token' key is marked isPrivate in the model, so it is excluded from results.
// Check for other known fields instead.
if (res.body.results.length > 0) {
expect(res.body.results[0]).toHaveProperty('is_valid');
expect(res.body.results[0]).toHaveProperty('created_by');
}
});
test('unknown token type returns error', async () => {
const res = await request(app)
.get('/api/token/NoSuchTokenType')
.set('auth-token', token);
// Token route does tokens[name].listDetail() — undefined.listDetail() throws.
expect(res.status).toBeGreaterThanOrEqual(400);
});
});
// ---------------------------------------------------------------------------
// GET /api/token/:name/:token
// ---------------------------------------------------------------------------
describe('Tokens — GET /api/token/:name/:token', () => {
let knownTokenId;
beforeAll(async () => {
// Create a fresh invite token so we have a known id to fetch.
const res = await request(app)
.post('/api/user/invite')
.set('auth-token', token)
.send({});
knownTokenId = res.body.token;
});
test('requires auth — 401 without token', async () => {
expect(knownTokenId).toBeDefined();
const res = await request(app).get(`/api/token/InviteToken/${knownTokenId}`);
expect(res.status).toBe(401);
});
test('returns the specific token object', async () => {
expect(knownTokenId).toBeDefined();
const res = await request(app)
.get(`/api/token/InviteToken/${knownTokenId}`)
.set('auth-token', token);
expect(res.status).toBe(200);
// The 'token' key is marked isPrivate in the model and is excluded from the serialised object.
// Verify other well-known fields instead.
expect(res.body.results).toHaveProperty('is_valid');
expect(res.body.results).toHaveProperty('created_by', TEST_CREDS.uid);
});
test('unknown token id returns error', async () => {
const res = await request(app)
.get('/api/token/InviteToken/00000000-0000-0000-0000-000000000000')
.set('auth-token', token);
expect(res.status).toBeGreaterThanOrEqual(400);
});
});
+106
View File
@@ -0,0 +1,106 @@
'use strict';
// Tests for the notification API:
// POST /api/notification/ — create & send notification
// GET /api/notification/ — list notifications
// GET /api/notification/:id — get single notification
const { login, request, app } = require('./setup');
let token;
let notificationId;
beforeAll(async () => {
token = await login();
});
describe('Notifications — POST /api/notification/ (create)', () => {
test('requires auth — 401 without token', async () => {
const res = await request(app)
.post('/api/notification/')
.send({ subject: 'Test', message: 'Hello', filter_type: 'all' });
expect(res.status).toBe(401);
});
test('missing required fields returns 400', async () => {
const res = await request(app)
.post('/api/notification/')
.set('auth-token', token)
.send({ subject: 'No message or filter_type' });
expect(res.status).toBe(400);
});
test('admin can create a notification targeting an empty user list (no emails sent)', async () => {
const res = await request(app)
.post('/api/notification/')
.set('auth-token', token)
.send({
subject: 'Jest test notification',
message: 'This notification was created by the automated test suite.',
filter_type: 'users',
filter_value: '[]', // empty recipient list — no emails sent
});
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('results');
expect(res.body.results).toHaveProperty('subject', 'Jest test notification');
notificationId = res.body.results.token || res.body.results.id;
});
});
describe('Notifications — GET /api/notification/ (list)', () => {
test('requires auth — 401 without token', async () => {
const res = await request(app).get('/api/notification/');
expect(res.status).toBe(401);
});
test('admin can list notifications', async () => {
const res = await request(app)
.get('/api/notification/')
.set('auth-token', token);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.results)).toBe(true);
});
test('list includes the notification created above', async () => {
// notificationId may be undefined if the create test above was skipped on error
if (!notificationId) return;
const res = await request(app)
.get('/api/notification/')
.set('auth-token', token);
const found = res.body.results.find(n => (n.token || n.id) === notificationId);
expect(found).toBeDefined();
expect(found.subject).toBe('Jest test notification');
});
});
describe('Notifications — GET /api/notification/:id (single)', () => {
test('requires auth — 401 without token', async () => {
if (!notificationId) return;
const res = await request(app).get(`/api/notification/${notificationId}`);
expect(res.status).toBe(401);
});
test('admin can retrieve a notification by id', async () => {
if (!notificationId) return;
const res = await request(app)
.get(`/api/notification/${notificationId}`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('results');
expect(res.body.results.subject).toBe('Jest test notification');
});
test('unknown id returns an error', async () => {
const res = await request(app)
.get('/api/notification/00000000-0000-0000-0000-000000000000')
.set('auth-token', token);
expect(res.status).toBeGreaterThanOrEqual(400);
});
});
+356
View File
@@ -0,0 +1,356 @@
'use strict';
const { login, generatePKCE, request, app } = require('./setup');
const REDIRECT_URI = 'https://test.example.com/callback';
let token;
let clientId;
let clientSecret;
// Full OIDC Authorization Code + PKCE flow state
let authCode;
let accessToken;
let refreshToken;
beforeAll(async () => {
token = await login();
// Create a dedicated test OAuth client for the flow
const res = await request(app)
.post('/api/oauth/client/')
.set('auth-token', token)
.send({
name: 'OAuth Flow Test',
redirect_uris: REDIRECT_URI,
scopes: 'openid profile email',
token_lifetime: { access_token: 3600, refresh_token: 86400 },
});
if (res.status !== 200) {
throw new Error('Could not create test OAuth client. Is test user in app_sso_oauth_admin? ' + JSON.stringify(res.body));
}
clientId = res.body.results.client_id;
clientSecret = res.body.client_secret;
});
afterAll(async () => {
if (clientId) {
await request(app)
.delete(`/api/oauth/client/${clientId}`)
.set('auth-token', token);
}
});
describe('OIDC Discovery', () => {
test('GET /.well-known/openid-configuration returns required fields', async () => {
const res = await request(app).get('/.well-known/openid-configuration');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('issuer');
expect(res.body).toHaveProperty('authorization_endpoint');
expect(res.body).toHaveProperty('token_endpoint');
expect(res.body).toHaveProperty('userinfo_endpoint');
expect(res.body.response_types_supported).toContain('code');
expect(res.body.grant_types_supported).toContain('authorization_code');
expect(res.body.grant_types_supported).toContain('refresh_token');
expect(res.body.code_challenge_methods_supported).toContain('S256');
});
});
describe('OAuth — GET /oauth/authorize (consent page validation)', () => {
test('rejects unknown client_id', async () => {
const { challenge } = generatePKCE();
const res = await request(app).get('/oauth/authorize').query({
response_type: 'code',
client_id: '00000000-0000-0000-0000-000000000000',
redirect_uri: REDIRECT_URI,
scope: 'openid',
code_challenge: challenge,
code_challenge_method: 'S256',
});
expect(res.status).toBeGreaterThanOrEqual(400);
});
test('rejects unregistered redirect_uri', async () => {
const { challenge } = generatePKCE();
const res = await request(app).get('/oauth/authorize').query({
response_type: 'code',
client_id: clientId,
redirect_uri: 'https://evil.example.com/callback',
scope: 'openid',
code_challenge: challenge,
code_challenge_method: 'S256',
});
expect(res.status).toBeGreaterThanOrEqual(400);
});
test('rejects missing code_challenge (PKCE required)', async () => {
const res = await request(app).get('/oauth/authorize').query({
response_type: 'code',
client_id: clientId,
redirect_uri: REDIRECT_URI,
scope: 'openid',
});
expect(res.status).toBeGreaterThanOrEqual(400);
});
test('valid params render the consent page', async () => {
const { challenge } = generatePKCE();
const res = await request(app).get('/oauth/authorize').query({
response_type: 'code',
client_id: clientId,
redirect_uri: REDIRECT_URI,
scope: 'openid profile email',
state: 'teststate',
code_challenge: challenge,
code_challenge_method: 'S256',
});
// Returns HTML (the EJS consent page), not an error
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/html/);
});
});
describe('OAuth — POST /api/oauth/authorize (code issuance)', () => {
test('issues an authorization code for an authenticated user', async () => {
const { challenge, verifier } = generatePKCE();
const res = await request(app)
.post('/api/oauth/authorize')
.set('auth-token', token)
.send({
response_type: 'code',
client_id: clientId,
redirect_uri: REDIRECT_URI,
scope: 'openid profile email',
state: 'teststate',
code_challenge: challenge,
code_challenge_method: 'S256',
});
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('redirect_url');
const redirectUrl = new URL(res.body.redirect_url);
expect(redirectUrl.searchParams.get('code')).toBeTruthy();
expect(redirectUrl.searchParams.get('state')).toBe('teststate');
// Save for token exchange tests
authCode = redirectUrl.searchParams.get('code');
// Also save the verifier so token exchange works
res._pkceVerifier = verifier;
// Store verifier on the module scope for next describe block
global.__testPkceVerifier = verifier;
});
test('requires auth — 401 without token', async () => {
const { challenge } = generatePKCE();
const res = await request(app)
.post('/api/oauth/authorize')
.send({
response_type: 'code',
client_id: clientId,
redirect_uri: REDIRECT_URI,
scope: 'openid',
code_challenge: challenge,
});
expect(res.status).toBe(401);
});
});
describe('OAuth — POST /oauth/token (authorization_code grant)', () => {
test('exchanges auth code + PKCE verifier for tokens', async () => {
expect(authCode).toBeDefined();
const res = await request(app)
.post('/oauth/token')
.type('form')
.send({
grant_type: 'authorization_code',
code: authCode,
redirect_uri: REDIRECT_URI,
client_id: clientId,
client_secret: clientSecret,
code_verifier: global.__testPkceVerifier,
});
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('access_token');
expect(res.body).toHaveProperty('refresh_token');
expect(res.body).toHaveProperty('id_token');
expect(res.body.token_type).toBe('Bearer');
expect(res.body.expires_in).toBe(3600);
accessToken = res.body.access_token;
refreshToken = res.body.refresh_token;
});
test('rejects reuse of the same auth code', async () => {
const res = await request(app)
.post('/oauth/token')
.type('form')
.send({
grant_type: 'authorization_code',
code: authCode,
redirect_uri: REDIRECT_URI,
client_id: clientId,
client_secret: clientSecret,
code_verifier: global.__testPkceVerifier,
});
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid_grant');
});
test('rejects wrong code_verifier (PKCE mismatch)', async () => {
// Get a fresh code first
const { challenge, verifier } = generatePKCE();
const codeRes = await request(app)
.post('/api/oauth/authorize')
.set('auth-token', token)
.send({
response_type: 'code',
client_id: clientId,
redirect_uri: REDIRECT_URI,
scope: 'openid',
code_challenge: challenge,
code_challenge_method: 'S256',
});
const freshCode = new URL(codeRes.body.redirect_url).searchParams.get('code');
const res = await request(app)
.post('/oauth/token')
.type('form')
.send({
grant_type: 'authorization_code',
code: freshCode,
redirect_uri: REDIRECT_URI,
client_id: clientId,
client_secret: clientSecret,
code_verifier: 'wrong-verifier-that-does-not-match',
});
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid_grant');
});
test('rejects wrong client_secret', async () => {
const { challenge } = generatePKCE();
const res = await request(app)
.post('/oauth/token')
.type('form')
.send({
grant_type: 'authorization_code',
code: 'doesnotmatter',
redirect_uri: REDIRECT_URI,
client_id: clientId,
client_secret: 'wrong-secret',
code_verifier: 'doesnotmatter',
});
expect(res.status).toBe(401);
expect(res.body.error).toBe('invalid_client');
});
});
describe('OAuth — GET /oauth/userinfo', () => {
test('returns user claims for valid access token', async () => {
expect(accessToken).toBeDefined();
const res = await request(app)
.get('/oauth/userinfo')
.set('Authorization', `Bearer ${accessToken}`);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('sub');
// profile scope
expect(res.body).toHaveProperty('name');
expect(res.body).toHaveProperty('preferred_username');
// email scope
expect(res.body).toHaveProperty('email');
});
test('rejects missing Bearer token with 401', async () => {
const res = await request(app).get('/oauth/userinfo');
expect(res.status).toBe(401);
});
test('rejects invalid Bearer token with 401', async () => {
const res = await request(app)
.get('/oauth/userinfo')
.set('Authorization', 'Bearer not-a-real-token');
expect(res.status).toBe(401);
});
});
describe('OAuth — POST /oauth/token (refresh_token grant)', () => {
test('exchanges refresh token for new access + refresh tokens', async () => {
expect(refreshToken).toBeDefined();
const res = await request(app)
.post('/oauth/token')
.type('form')
.send({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: clientId,
client_secret: clientSecret,
});
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('access_token');
expect(res.body).toHaveProperty('refresh_token');
// New tokens should be different (rotation)
expect(res.body.access_token).not.toBe(accessToken);
expect(res.body.refresh_token).not.toBe(refreshToken);
accessToken = res.body.access_token;
refreshToken = res.body.refresh_token;
});
test('rejects reuse of the old refresh token after rotation', async () => {
// Capture the pre-rotation refresh token — it was rotated in the previous test
// We need a fresh sequence for this test
const { challenge, verifier } = generatePKCE();
const codeRes = await request(app)
.post('/api/oauth/authorize')
.set('auth-token', token)
.send({
response_type: 'code',
client_id: clientId,
redirect_uri: REDIRECT_URI,
scope: 'openid',
code_challenge: challenge,
code_challenge_method: 'S256',
});
const freshCode = new URL(codeRes.body.redirect_url).searchParams.get('code');
const tokenRes = await request(app)
.post('/oauth/token')
.type('form')
.send({
grant_type: 'authorization_code',
code: freshCode,
redirect_uri: REDIRECT_URI,
client_id: clientId,
client_secret: clientSecret,
code_verifier: verifier,
});
const oldRefresh = tokenRes.body.refresh_token;
// Rotate it once
await request(app)
.post('/oauth/token')
.type('form')
.send({ grant_type: 'refresh_token', refresh_token: oldRefresh, client_id: clientId, client_secret: clientSecret });
// Try to reuse the old one
const reuseRes = await request(app)
.post('/oauth/token')
.type('form')
.send({ grant_type: 'refresh_token', refresh_token: oldRefresh, client_id: clientId, client_secret: clientSecret });
expect(reuseRes.status).toBe(400);
expect(reuseRes.body.error).toBe('invalid_grant');
});
});
+112
View File
@@ -0,0 +1,112 @@
'use strict';
const { login, request, app } = require('./setup');
const TEST_CLIENT = {
name: 'Test Client',
description: 'Created by automated tests',
redirect_uris: 'https://test.example.com/callback',
scopes: 'openid profile email',
token_lifetime: { access_token: 3600, refresh_token: 86400 },
};
let token;
let clientId;
let clientSecret;
beforeAll(async () => {
token = await login();
});
afterAll(async () => {
if (clientId) {
await request(app)
.delete(`/api/oauth/client/${clientId}`)
.set('auth-token', token);
}
});
describe('OAuth Clients — POST /api/oauth/client/', () => {
test('creates a new client and returns one-time secret', async () => {
const res = await request(app)
.post('/api/oauth/client/')
.set('auth-token', token)
.send(TEST_CLIENT);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('results');
expect(res.body).toHaveProperty('client_secret');
expect(res.body.results).toHaveProperty('client_id');
expect(res.body.results).toHaveProperty('name', TEST_CLIENT.name);
expect(res.body.results.client_id.length).toBeGreaterThan(0);
clientId = res.body.results.client_id;
clientSecret = res.body.client_secret;
});
test('requires oauth_admin group — 401 not shown here (see group membership)', () => {
// If test user is not in app_sso_oauth_admin, the test above will fail with 401.
// That itself is the correct behavior to verify.
expect(clientId).toBeDefined();
});
});
describe('OAuth Clients — GET /api/oauth/client/', () => {
test('lists clients including the test client', async () => {
const res = await request(app)
.get('/api/oauth/client/')
.set('auth-token', token);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.results)).toBe(true);
const found = res.body.results.find(c => c.client_id === clientId);
expect(found).toBeDefined();
});
});
describe('OAuth Clients — GET /api/oauth/client/:id', () => {
test('returns the test client by id', async () => {
const res = await request(app)
.get(`/api/oauth/client/${clientId}`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body.results).toHaveProperty('client_id', clientId);
expect(res.body.results).toHaveProperty('name', TEST_CLIENT.name);
});
test('unknown client_id returns 404 or error', async () => {
const res = await request(app)
.get('/api/oauth/client/00000000-0000-0000-0000-000000000000')
.set('auth-token', token);
expect(res.status).toBeGreaterThanOrEqual(400);
});
});
describe('OAuth Clients — PUT /api/oauth/client/:id', () => {
test('updates the client description', async () => {
const res = await request(app)
.put(`/api/oauth/client/${clientId}`)
.set('auth-token', token)
.send({ description: 'Updated by test' });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('message');
});
});
describe('OAuth Clients — POST /api/oauth/client/:id/rotate', () => {
test('rotates the client secret and returns a new one', async () => {
const res = await request(app)
.post(`/api/oauth/client/${clientId}/rotate`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('client_secret');
expect(typeof res.body.client_secret).toBe('string');
expect(res.body.client_secret).not.toBe(clientSecret);
clientSecret = res.body.client_secret;
});
});
+108
View File
@@ -0,0 +1,108 @@
'use strict';
const { TEST_CREDS, login, request, app } = require('./setup');
const { OtpToken } = require('../models/token');
// Use an existing LDAP user — wmantly is always present
const TARGET_UID = 'wmantly';
describe('OTP — POST /api/auth/otp/request', () => {
test('missing body fields returns 400', async () => {
const res = await request(app).post('/api/auth/otp/request').send({});
expect(res.status).toBe(400);
});
test('unknown user returns 4xx', async () => {
const res = await request(app)
.post('/api/auth/otp/request')
.send({ login: 'no_such_user_xyz', method: 'email' });
expect(res.status).toBeGreaterThanOrEqual(400);
});
test('sms method returns error when SMS cannot be delivered', async () => {
// test user has a mobile but VoIP.ms credentials are not set in test env
const res = await request(app)
.post('/api/auth/otp/request')
.send({ login: TEST_CREDS.uid, method: 'sms' });
expect(res.status).toBeGreaterThanOrEqual(400);
});
test('email method returns 200 with expires_at', async () => {
const res = await request(app)
.post('/api/auth/otp/request')
.send({ login: TARGET_UID, method: 'email' });
// May fail if SMTP not configured in test env — treat 500 as skip
if (res.status === 500) return;
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('expires_at');
expect(res.body.method).toBe('email');
});
});
describe('OTP — POST /api/auth/otp/verify', () => {
test('wrong code returns 401', async () => {
// Seed a real OTP so the user exists in token store
await OtpToken.issue(TARGET_UID, 'email');
const res = await request(app)
.post('/api/auth/otp/verify')
.send({ login: TARGET_UID, code: '000000' });
expect(res.status).toBe(401);
});
test('valid code returns auth token', async () => {
const otp = await OtpToken.issue(TARGET_UID, 'email');
const res = await request(app)
.post('/api/auth/otp/verify')
.send({ login: TARGET_UID, code: otp.code });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('token');
expect(res.body.login).toBe(true);
});
test('same code is single-use — second attempt returns 401', async () => {
const otp = await OtpToken.issue(TARGET_UID, 'email');
const code = otp.code;
// First use should succeed
const first = await request(app)
.post('/api/auth/otp/verify')
.send({ login: TARGET_UID, code });
expect(first.status).toBe(200);
// Second use must fail
const second = await request(app)
.post('/api/auth/otp/verify')
.send({ login: TARGET_UID, code });
expect(second.status).toBe(401);
});
test('login by email address also works', async () => {
const otp = await OtpToken.issue(TARGET_UID, 'email');
// Get the user's email so we can test login-by-email path
const userRes = await request(app)
.get(`/api/user/${TARGET_UID}`)
.set('auth-token', await login());
const email = userRes.body.results.mail;
const res = await request(app)
.post('/api/auth/otp/verify')
.send({ login: email, code: otp.code });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('token');
});
test('missing fields returns 400', async () => {
const res = await request(app)
.post('/api/auth/otp/verify')
.send({ login: TARGET_UID });
expect(res.status).toBe(400);
});
});
+27
View File
@@ -0,0 +1,27 @@
'use strict';
const crypto = require('crypto');
const request = require('supertest');
const app = require('../app');
const TEST_CREDS = { uid: 'test', password: 'ZxAsQw!2' };
async function login() {
const res = await request(app)
.post('/api/auth/login')
.send(TEST_CREDS);
if (!res.body.token) throw new Error('Login failed: ' + JSON.stringify(res.body));
return res.body.token;
}
function generatePKCE() {
const verifier = crypto.randomBytes(32)
.toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
const challenge = crypto.createHash('sha256')
.update(verifier).digest('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
return { verifier, challenge };
}
module.exports = { TEST_CREDS, login, generatePKCE, request, app };
+201
View File
@@ -0,0 +1,201 @@
'use strict';
const { TEST_CREDS, login, request, app } = require('./setup');
// Derived uid: givenName[0] + sn, lowercased → 'jrunner'
const TEST_USER = {
givenName: 'Jest',
sn: 'Runner',
mail: 'jrunner@test.example.com',
mobile: '5555551234',
userPassword: 'TestPass!99',
};
const TEST_UID = `${TEST_USER.givenName[0]}${TEST_USER.sn}`.toLowerCase(); // 'jrunner'
let token;
beforeAll(async () => {
token = await login();
// Clean up any leftover test user from a previous failed run
await request(app).delete(`/api/user/${TEST_UID}`).set('auth-token', token);
});
afterAll(async () => {
await request(app).delete(`/api/user/${TEST_UID}`).set('auth-token', token);
});
describe('Users — GET /api/user/', () => {
test('requires auth — 401 without token', async () => {
const res = await request(app).get('/api/user/');
expect(res.status).toBe(401);
});
test('returns user list for admin', async () => {
const res = await request(app)
.get('/api/user/?detail=true')
.set('auth-token', token);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.results)).toBe(true);
});
test('result entries have expected LDAP fields', async () => {
const res = await request(app)
.get('/api/user/?detail=true')
.set('auth-token', token);
const user = res.body.results.find(u => u.uid === TEST_CREDS.uid);
expect(user).toBeDefined();
expect(user).toHaveProperty('uid', TEST_CREDS.uid);
expect(user).toHaveProperty('dn');
expect(user).toHaveProperty('mail');
});
});
describe('Users — GET /api/user/:uid', () => {
test('returns a single user', async () => {
const res = await request(app)
.get(`/api/user/${TEST_CREDS.uid}`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body.results).toHaveProperty('uid', TEST_CREDS.uid);
});
test('unknown uid returns error', async () => {
const res = await request(app)
.get('/api/user/no_such_user_xyz')
.set('auth-token', token);
expect(res.status).toBeGreaterThanOrEqual(400);
});
});
describe('Users — POST /api/user/ (create)', () => {
test('creates a new user', async () => {
const res = await request(app)
.post('/api/user/')
.set('auth-token', token)
.send(TEST_USER);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('results');
expect(res.body.results).toHaveProperty('uid', TEST_UID);
});
test('new user appears in list', async () => {
const res = await request(app)
.get('/api/user/?detail=true')
.set('auth-token', token);
const found = res.body.results.find(u => u.uid === TEST_UID);
expect(found).toBeDefined();
});
test('requires admin — 401 without membership', async () => {
// Verify the endpoint is gated (tested implicitly: no-token case)
const res = await request(app)
.post('/api/user/')
.send(TEST_USER);
expect(res.status).toBe(401);
});
});
describe('Users — PUT /api/user/:uid (update)', () => {
test('admin can update another user', async () => {
const res = await request(app)
.put(`/api/user/${TEST_UID}`)
.set('auth-token', token)
.send({ description: 'Updated by test suite' });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('message');
});
test('user can update their own profile', async () => {
// Log in as the test user created above
const userToken = (await request(app)
.post('/api/auth/login')
.send({ uid: TEST_UID, password: TEST_USER.userPassword })).body.token;
const res = await request(app)
.put(`/api/user/${TEST_UID}`)
.set('auth-token', userToken)
.send({ mobile: '5555559999' });
expect(res.status).toBe(200);
});
});
describe('Users — password self-service', () => {
const NEW_PASSWORD = 'NewPass!88';
test('PUT /api/user/:uid/password — admin changes another user\'s password', async () => {
const res = await request(app)
.put(`/api/user/${TEST_UID}/password`)
.set('auth-token', token)
.send({ userPassword: NEW_PASSWORD });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('message');
});
test('new password works for login', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ uid: TEST_UID, password: NEW_PASSWORD });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('token');
});
test('PUT /api/user/password — user changes own password', async () => {
const userToken = (await request(app)
.post('/api/auth/login')
.send({ uid: TEST_UID, password: NEW_PASSWORD })).body.token;
const res = await request(app)
.put('/api/user/password')
.set('auth-token', userToken)
.send({ userPassword: 'SelfSet!77' });
expect(res.status).toBe(200);
});
test('self-set password works for login', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ uid: TEST_UID, password: 'SelfSet!77' });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('token');
});
});
describe('Users — DELETE /api/user/:uid', () => {
test('admin can delete a user', async () => {
const res = await request(app)
.delete(`/api/user/${TEST_UID}`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('uid', TEST_UID);
});
test('deleted user no longer appears in list', async () => {
const res = await request(app)
.get('/api/user/?detail=true')
.set('auth-token', token);
const found = res.body.results.find(u => u.uid === TEST_UID);
expect(found).toBeUndefined();
});
test('deleted user cannot log in', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ uid: TEST_UID, password: 'SelfSet!77' });
expect(res.status).toBe(401);
});
});
+197
View File
@@ -0,0 +1,197 @@
'use strict';
// Tests for admin-only user endpoints:
// GET /api/user/stats
// GET /api/user/export
// GET /api/user/me
// PUT /api/user/:uid/active
const { TEST_CREDS, login, request, app } = require('./setup');
// TEST_USER mirrors the user created in user.test.js — use a different uid so
// this suite is independent.
const TEST_USER = {
givenName: 'Admin',
sn: 'Tester',
mail: 'atester@test.example.com',
mobile: '5555550001',
userPassword: 'AdminTest!55',
};
const TEST_UID = `${TEST_USER.givenName[0]}${TEST_USER.sn}`.toLowerCase(); // 'atester'
let token;
beforeAll(async () => {
token = await login();
// Clean up any leftover from a previous run
await request(app).delete(`/api/user/${TEST_UID}`).set('auth-token', token);
// Create the test user
await request(app).post('/api/user/').set('auth-token', token).send(TEST_USER);
});
afterAll(async () => {
await request(app).delete(`/api/user/${TEST_UID}`).set('auth-token', token);
});
describe('Users — GET /api/user/stats (admin only)', () => {
test('requires auth — 401 without token', async () => {
const res = await request(app).get('/api/user/stats');
expect(res.status).toBe(401);
});
test('returns aggregated counts for admin', async () => {
const res = await request(app)
.get('/api/user/stats')
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('totalUsers');
expect(res.body).toHaveProperty('activeUsers');
expect(res.body).toHaveProperty('inactiveUsers');
expect(res.body).toHaveProperty('totalGroups');
expect(res.body).toHaveProperty('recentSignups');
expect(res.body).toHaveProperty('inactiveList');
expect(typeof res.body.totalUsers).toBe('number');
expect(typeof res.body.totalGroups).toBe('number');
expect(Array.isArray(res.body.recentSignups)).toBe(true);
expect(Array.isArray(res.body.inactiveList)).toBe(true);
});
test('totalUsers is positive (at least the test user and admin exist)', async () => {
const res = await request(app)
.get('/api/user/stats')
.set('auth-token', token);
expect(res.body.totalUsers).toBeGreaterThan(0);
});
test('activeUsers + inactiveUsers equals totalUsers', async () => {
const res = await request(app)
.get('/api/user/stats')
.set('auth-token', token);
expect(res.body.activeUsers + res.body.inactiveUsers).toBe(res.body.totalUsers);
});
});
describe('Users — GET /api/user/export (admin only)', () => {
test('requires auth — 401 without token', async () => {
const res = await request(app).get('/api/user/export');
expect(res.status).toBe(401);
});
test('returns CSV content for admin', async () => {
const res = await request(app)
.get('/api/user/export')
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/text\/csv/);
expect(res.headers['content-disposition']).toMatch(/users\.csv/);
});
test('CSV has header row with expected columns', async () => {
const res = await request(app)
.get('/api/user/export')
.set('auth-token', token);
const lines = res.text.split('\n');
const header = lines[0];
expect(header).toContain('uid');
expect(header).toContain('mail');
expect(header).toContain('givenName');
expect(header).toContain('sn');
});
test('CSV contains at least one data row', async () => {
const res = await request(app)
.get('/api/user/export')
.set('auth-token', token);
const lines = res.text.split('\n').filter(l => l.trim());
// header + at least one user row
expect(lines.length).toBeGreaterThan(1);
});
});
describe('Users — GET /api/user/me', () => {
test('returns the authenticated user\'s own profile', async () => {
const res = await request(app)
.get('/api/user/me')
.set('auth-token', token);
expect(res.status).toBe(200);
// The /me route returns User.get() directly (no results wrapper in some implementations)
// Accept either shape
const user = res.body.results || res.body;
expect(user).toHaveProperty('uid', TEST_CREDS.uid);
});
test('requires auth — 401 without token', async () => {
const res = await request(app).get('/api/user/me');
expect(res.status).toBe(401);
});
});
describe('Users — PUT /api/user/:uid/active (activate/deactivate)', () => {
test('requires auth — 401 without token', async () => {
const res = await request(app)
.put(`/api/user/${TEST_UID}/active`)
.send({ active: false });
expect(res.status).toBe(401);
});
test('admin can deactivate a user (skipped if ppolicy overlay not configured)', async () => {
const res = await request(app)
.put(`/api/user/${TEST_UID}/active`)
.set('auth-token', token)
.send({ active: false });
// 503 means the OpenLDAP ppolicy overlay is not set up in this environment
if (res.status === 503) return;
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('uid', TEST_UID);
expect(res.body).toHaveProperty('active', false);
expect(res.body).toHaveProperty('message');
});
test('deactivated user cannot log in (skipped if ppolicy overlay not configured)', async () => {
// Check whether deactivation works in this environment first
const checkRes = await request(app)
.put(`/api/user/${TEST_UID}/active`)
.set('auth-token', token)
.send({ active: false });
if (checkRes.status === 503) return;
const res = await request(app)
.post('/api/auth/login')
.send({ uid: TEST_UID, password: TEST_USER.userPassword });
// LDAP may return 401 or 403 for locked accounts
expect(res.status).toBeGreaterThanOrEqual(400);
// Re-activate so cleanup works
await request(app)
.put(`/api/user/${TEST_UID}/active`)
.set('auth-token', token)
.send({ active: true });
});
test('admin can reactivate a user (skipped if ppolicy overlay not configured)', async () => {
// First deactivate (may not be supported)
const deactivateRes = await request(app)
.put(`/api/user/${TEST_UID}/active`)
.set('auth-token', token)
.send({ active: false });
if (deactivateRes.status === 503) return;
const res = await request(app)
.put(`/api/user/${TEST_UID}/active`)
.set('auth-token', token)
.send({ active: true });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('active', true);
});
});