Updated frontend
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user