240 lines
8.9 KiB
JavaScript
240 lines
8.9 KiB
JavaScript
'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);
|
|
});
|
|
});
|