'use strict'; import { createStrapiInstance } from 'api-tests/strapi'; import { createUtils } from 'api-tests/utils'; import jwt from 'jsonwebtoken'; const contentTypeUID = 'admin::session'; describe('SessionManager API Integration', () => { let strapi: any; let utils: any; beforeAll(async () => { strapi = await createStrapiInstance(); utils = createUtils(strapi); }); afterAll(async () => { await strapi.db.query(contentTypeUID).deleteMany({}); await strapi.destroy(); }); describe('SessionManager Service Integration', () => { const testUserId = 'test-user-123'; const testDeviceId = 'test-device-456'; const testOrigin = 'admin'; afterEach(async () => { await strapi.db.query(contentTypeUID).deleteMany({ where: { userId: testUserId }, }); }); describe('strapi.sessionManager access', () => { it('should be accessible via strapi.sessionManager', () => { expect(strapi.sessionManager).toBeDefined(); expect(typeof strapi.sessionManager.generateSessionId).toBe('function'); expect(typeof strapi.sessionManager.defineOrigin).toBe('function'); expect(typeof strapi.sessionManager.hasOrigin).toBe('function'); expect(typeof strapi.sessionManager('admin')).toBe('object'); expect(typeof strapi.sessionManager('admin').generateRefreshToken).toBe('function'); expect(typeof strapi.sessionManager('admin').validateRefreshToken).toBe('function'); expect(typeof strapi.sessionManager('admin').generateAccessToken).toBe('function'); expect(typeof strapi.sessionManager('admin').rotateRefreshToken).toBe('function'); expect(typeof strapi.sessionManager('admin').invalidateRefreshToken).toBe('function'); expect(typeof strapi.sessionManager('admin').isSessionActive).toBe('function'); }); }); describe('generateRefreshToken', () => { it('should create a session in the database', async () => { const result = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, testDeviceId); expect(result).toMatchObject({ token: expect.any(String), sessionId: expect.any(String), }); // Verify session was created in database const session = await strapi.db.query(contentTypeUID).findOne({ where: { sessionId: result.sessionId }, }); expect(session).toMatchObject({ userId: testUserId, sessionId: result.sessionId, deviceId: testDeviceId, origin: testOrigin, expiresAt: expect.any(String), }); }); it('should generate a valid JWT token', async () => { const result = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, testDeviceId); expect(result.token).toBeTruthy(); expect(typeof result.token).toBe('string'); }); it('should include correct claims in the JWT and match DB/sessionId', async () => { const result = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, testDeviceId); const { secret } = strapi.config.get('admin.auth', { secret: '' }); const decoded = jwt.verify(result.token, secret, { algorithms: ['HS256'] }) as { userId: string; sessionId: string; type: string; iat: number; exp: number; }; expect(decoded.userId).toBe(testUserId); expect(decoded.sessionId).toBe(result.sessionId); expect(decoded.type).toBe('refresh'); const session = await strapi.db.query(contentTypeUID).findOne({ where: { sessionId: result.sessionId }, }); expect(session?.sessionId).toBe(result.sessionId); }); it('should clean up families past absolute expiration before creating new ones', async () => { const expiredSessionId = 'expired-session-absolute-123'; await strapi.db.query(contentTypeUID).create({ data: { userId: testUserId, sessionId: expiredSessionId, deviceId: 'old-device', origin: testOrigin, expiresAt: new Date(Date.now() + 60 * 60 * 1000), absoluteExpiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000), }, }); // Trigger cleanup by performing enough calls to reach the threshold const threshold = strapi.sessionManager.cleanupThreshold; for (let i = 0; i < threshold; i += 1) { await strapi.sessionManager('admin').generateRefreshToken(testUserId, testDeviceId); } const after = await strapi.db.query(contentTypeUID).findOne({ where: { sessionId: expiredSessionId }, }); expect(after).toBeNull(); }); it('should clean up all families with absolute expiration in the past', async () => { const expiredIds = ['fam-exp-1', 'fam-exp-2', 'fam-exp-3']; await Promise.all( expiredIds.map((id) => strapi.db.query(contentTypeUID).create({ data: { userId: testUserId, sessionId: id, deviceId: 'old-device', origin: testOrigin, expiresAt: new Date(Date.now() + 60 * 60 * 1000), absoluteExpiresAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), }, }) ) ); // Trigger cleanup by performing enough calls to reach the threshold const threshold = strapi.sessionManager.cleanupThreshold; for (let i = 0; i < threshold; i += 1) { await strapi.sessionManager('admin').generateRefreshToken(testUserId, testDeviceId); } const remaining = await strapi.db.query(contentTypeUID).findMany({ where: { userId: testUserId, absoluteExpiresAt: { $lt: new Date() } }, }); expect(remaining).toHaveLength(0); }); it('should allow multiple active sessions for different devices', async () => { const device1 = 'device-1'; const device2 = 'device-2'; const result1 = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, device1); const result2 = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, device2); expect(result1.sessionId).not.toBe(result2.sessionId); const sessions = await strapi.db.query(contentTypeUID).findMany({ where: { userId: testUserId }, }); expect(sessions).toHaveLength(2); expect(sessions.map((s) => s.deviceId)).toEqual(expect.arrayContaining([device1, device2])); }); it('should allow multiple active sessions for different origins', async () => { const origin1 = 'admin'; const origin2 = 'users-permissions'; const result1 = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, testDeviceId); const result2 = await strapi .sessionManager('users-permissions') .generateRefreshToken(testUserId, testDeviceId); expect(result1.sessionId).not.toBe(result2.sessionId); const sessions = await strapi.db.query(contentTypeUID).findMany({ where: { userId: testUserId }, }); expect(sessions).toHaveLength(2); expect(sessions.map((s) => s.origin)).toEqual(expect.arrayContaining([origin1, origin2])); }); it('should set refresh idle expiration (default 14 days) for refresh family', async () => { const startTime = Date.now(); const result = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, testDeviceId); const session = await strapi.db.query(contentTypeUID).findOne({ where: { sessionId: result.sessionId }, }); const expectedExpiration = startTime + 14 * 24 * 60 * 60 * 1000; const actualExpiration = new Date(session.expiresAt).getTime(); expect(Math.abs(actualExpiration - expectedExpiration)).toBeLessThan(1_000); }); it.skip('should have JWT exp aligned with idle lifespan and match DB expiresAt', async () => { const startTimeSec = Math.floor(Date.now() / 1000); const result = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, testDeviceId); const { secret } = strapi.config.get('admin.auth', { secret: '' }); const decoded = jwt.verify(result.token, secret, { algorithms: ['HS256'] }) as { iat: number; exp: number; }; const ttlSeconds = 7 * 24 * 60 * 60; expect(Math.abs(decoded.exp - decoded.iat - ttlSeconds)).toBeLessThan(2); const session = await strapi.db.query(contentTypeUID).findOne({ where: { sessionId: result.sessionId }, }); const expMs = decoded.exp * 1000; const dbExpiresMs = new Date(session.expiresAt).getTime(); expect(Math.abs(expMs - dbExpiresMs)).toBeLessThan(1_000); expect(decoded.iat).toBeGreaterThanOrEqual(startTimeSec - 2); }); }); describe('generateSessionId', () => { it('should generate unique session IDs', () => { const sessionId1 = strapi.sessionManager.generateSessionId(); const sessionId2 = strapi.sessionManager.generateSessionId(); expect(sessionId1).toBeTruthy(); expect(sessionId2).toBeTruthy(); expect(sessionId1).not.toBe(sessionId2); expect(typeof sessionId1).toBe('string'); expect(typeof sessionId2).toBe('string'); }); it('should generate a 32-character lowercase hex session ID', () => { const sessionId = strapi.sessionManager.generateSessionId(); expect(sessionId).toMatch(/^[a-f0-9]{32}$/); }); }); describe('validateRefreshToken', () => { it('should validate a valid refresh token', async () => { const tokenResult = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, testDeviceId); const validationResult = await strapi .sessionManager('admin') .validateRefreshToken(tokenResult.token); expect(validationResult).toEqual({ isValid: true, userId: testUserId, sessionId: tokenResult.sessionId, }); const session = await strapi.db.query(contentTypeUID).findOne({ where: { sessionId: tokenResult.sessionId }, }); expect(session).toBeTruthy(); expect(session.userId).toBe(testUserId); }); it('should reject malformed tokens', async () => { const result = await strapi .sessionManager('admin') .validateRefreshToken('invalid-jwt-token'); expect(result).toEqual({ isValid: false, }); }); it('should reject token when session not found in database', async () => { const tokenResult = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, testDeviceId); await strapi.db.query(contentTypeUID).delete({ where: { sessionId: tokenResult.sessionId }, }); const result = await strapi.sessionManager('admin').validateRefreshToken(tokenResult.token); expect(result).toEqual({ isValid: false, }); }); it('should reject expired session', async () => { const tokenResult = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, testDeviceId); // Update the session to be expired const pastDate = new Date(Date.now() - 60 * 60 * 1000); // Expired 1 hour ago await strapi.db.query(contentTypeUID).update({ where: { sessionId: tokenResult.sessionId }, data: { expiresAt: pastDate }, }); const updatedSession = await strapi.db.query(contentTypeUID).findOne({ where: { sessionId: tokenResult.sessionId }, }); expect(new Date(updatedSession.expiresAt)).toEqual(pastDate); const result = await strapi.sessionManager('admin').validateRefreshToken(tokenResult.token); expect(result).toEqual({ isValid: false, }); }); it('should reject token when user ID mismatch', async () => { // Generate token for one user const tokenResult = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, testDeviceId); // Manually modify session in database to have different user await strapi.db.query(contentTypeUID).update({ where: { sessionId: tokenResult.sessionId }, data: { userId: 'different-user-id' }, }); const result = await strapi.sessionManager('admin').validateRefreshToken(tokenResult.token); expect(result).toEqual({ isValid: false, }); }); }); describe('generateAccessToken', () => { it('should rotate refresh token and return same child on reuse', async () => { const { token: parentToken } = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, testDeviceId); // First rotation const r1 = await strapi.sessionManager('admin').rotateRefreshToken(parentToken); expect('token' in r1).toBe(true); if ('token' in r1) { const childToken1 = r1.token; const childSession1 = r1.sessionId; // Second rotation with the same parent should return the same child const r2 = await strapi.sessionManager('admin').rotateRefreshToken(parentToken); expect('token' in r2).toBe(true); if ('token' in r2) { expect(r2.sessionId).toBe(childSession1); expect(r2.token).toBe(childToken1); } } }); it('should generate access token for valid refresh token', async () => { const refreshTokenResult = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, testDeviceId); const accessTokenResult = await strapi .sessionManager('admin') .generateAccessToken(refreshTokenResult.token); expect(accessTokenResult).toHaveProperty('token'); expect(accessTokenResult).not.toHaveProperty('error'); expect(typeof accessTokenResult.token).toBe('string'); }); it('should generate access token with correct JWT payload', async () => { const refreshTokenResult = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, testDeviceId); const accessTokenResult = await strapi .sessionManager('admin') .generateAccessToken(refreshTokenResult.token); expect(accessTokenResult).toHaveProperty('token'); // Verify the JWT payload structure const jwt = require('jsonwebtoken'); const jwtSecret = strapi.config.get('admin.auth.secret'); const decodedPayload = jwt.verify(accessTokenResult.token, jwtSecret); expect(decodedPayload).toMatchObject({ userId: testUserId, sessionId: refreshTokenResult.sessionId, type: 'access', exp: expect.any(Number), iat: expect.any(Number), }); }); it('should return error for invalid refresh token', async () => { const result = await strapi .sessionManager('admin') .generateAccessToken('invalid-jwt-token'); expect(result).toEqual({ error: 'invalid_refresh_token', }); }); it('should return error for expired refresh token', async () => { const refreshTokenResult = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, testDeviceId); // Update the session to be expired await strapi.db.query(contentTypeUID).update({ where: { sessionId: refreshTokenResult.sessionId }, data: { expiresAt: new Date(Date.now() - 60 * 60 * 1000) }, // 1 hour ago }); const result = await strapi .sessionManager('admin') .generateAccessToken(refreshTokenResult.token); expect(result).toEqual({ error: 'invalid_refresh_token', }); }); it('should return error when refresh token session not found', async () => { const refreshTokenResult = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, testDeviceId); // Delete the session from database await strapi.db.query(contentTypeUID).delete({ where: { sessionId: refreshTokenResult.sessionId }, }); const result = await strapi .sessionManager('admin') .generateAccessToken(refreshTokenResult.token); expect(result).toEqual({ error: 'invalid_refresh_token', }); }); it('should return error for access token passed as refresh token', async () => { // First generate a refresh token and then an access token const refreshTokenResult = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, testDeviceId); const accessTokenResult = await strapi .sessionManager('admin') .generateAccessToken(refreshTokenResult.token); // Ensure we got a token, not an error expect(accessTokenResult).toHaveProperty('token'); // Try to use the access token to generate another access token (should fail) const result = await strapi .sessionManager('admin') .generateAccessToken((accessTokenResult as { token: string }).token); expect(result).toEqual({ error: 'invalid_refresh_token', }); }); it('should return error when user ID mismatch in session', async () => { const refreshTokenResult = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, testDeviceId); // Manually modify session in database to have different user await strapi.db.query(contentTypeUID).update({ where: { sessionId: refreshTokenResult.sessionId }, data: { userId: 'different-user-id' }, }); const result = await strapi .sessionManager('admin') .generateAccessToken(refreshTokenResult.token); expect(result).toEqual({ error: 'invalid_refresh_token', }); }); it('should work with multiple valid refresh tokens', async () => { const refreshToken1 = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, 'device-1'); const refreshToken2 = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, 'device-2'); const accessToken1 = await strapi .sessionManager('admin') .generateAccessToken(refreshToken1.token); const accessToken2 = await strapi .sessionManager('admin') .generateAccessToken(refreshToken2.token); expect(accessToken1).toHaveProperty('token'); expect(accessToken2).toHaveProperty('token'); expect(accessToken1.token).not.toBe(accessToken2.token); }); }); describe('rotateRefreshToken', () => { it('enforces idle window (returns idle_window_elapsed)', async () => { const r = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, testDeviceId); // Make createdAt older than idleRefreshTokenLifespan (14d) by 1 minute const past = new Date(Date.now() - (14 * 24 * 60 * 60 * 1000 + 60 * 1000)); await strapi.db.query(contentTypeUID).update({ where: { sessionId: r.sessionId }, data: { createdAt: past }, }); const rotation = await strapi.sessionManager('admin').rotateRefreshToken(r.token); expect(rotation).toEqual({ error: 'idle_window_elapsed' }); }); it('enforces max family window (returns max_window_elapsed)', async () => { const r = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, testDeviceId); // Force absoluteExpiresAt in the past await strapi.db.query(contentTypeUID).update({ where: { sessionId: r.sessionId }, data: { absoluteExpiresAt: new Date(Date.now() - 1000) }, }); const rotation = await strapi.sessionManager('admin').rotateRefreshToken(r.token); expect(rotation).toEqual({ error: 'max_window_elapsed' }); }); it('marks parent as rotated and sets childId', async () => { const r = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, testDeviceId); const rotation = await strapi.sessionManager('admin').rotateRefreshToken(r.token); expect('token' in rotation).toBe(true); if ('token' in rotation) { const parent = await strapi.db.query(contentTypeUID).findOne({ where: { sessionId: r.sessionId }, }); expect(parent?.status).toBe('rotated'); expect(parent?.childId).toBe(rotation.sessionId); } }); }); describe('multiple sessions with same device and origin', () => { it('should allow multiple active sessions with same deviceId and same origin', async () => { const result1 = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, testDeviceId); const result2 = await strapi .sessionManager('admin') .generateRefreshToken(testUserId, testDeviceId); expect(result1.sessionId).not.toBe(result2.sessionId); const sessions = await strapi.db.query(contentTypeUID).findMany({ where: { userId: testUserId }, }); expect(sessions).toHaveLength(2); expect( sessions.every((s: any) => s.deviceId === testDeviceId && s.origin === testOrigin) ).toBe(true); }); }); }); });