diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8a21526..a9b40cd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,6 +23,18 @@ jobs: with: version: 8.1.0 + - name: Build Redis + uses: docker/build-push-action@v2 + with: + context: . + file: ./docker/redis/Dockerfile + load: true + tags: my-redis:latest + + - name: Run Redis + run: | + docker run -d --name redis -p 6379:6379 --env REDIS_PASSWORD=redis my-redis:latest + - name: Setup Node Environment uses: actions/setup-node@v3 with: @@ -35,5 +47,10 @@ jobs: - name: Build Packages run: pnpm run build + - name: Wait for Redis + uses: jakejarvis/wait-action@master + with: + time: '30s' + - name: Test Packages and Template run: pnpm run test diff --git a/jest.config.ts b/jest.config.ts index 91aa868..5e3e9ec 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -6,7 +6,7 @@ const config: Config = { transform: { '^.+\\.tsx?$': 'ts-jest', }, - testRegex: '(/tests/.*|(\\.|/)test)\\.tsx?$', + testRegex: '/tests/.*\\.test\\.tsx?$', testPathIgnorePatterns: ['/node_modules/', '/__fixtures__/', '/dist/'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], } diff --git a/package.json b/package.json index 73924e6..3fd2d3a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,11 @@ { "name": "authsessions", "version": "0.0.1", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], "scripts": { "track": "changeset add", "version": "changeset version", diff --git a/redis.test.ts b/redis.test.ts new file mode 100644 index 0000000..4464d07 --- /dev/null +++ b/redis.test.ts @@ -0,0 +1,154 @@ +import { RedisClientType, createClient } from 'redis' + +import { RedisSessions } from '../../src/stores/redis' +import { SessionUtils } from '../../src/utils' +import { filter } from '../utils' + +// RedisSessions (best for production environment). +const redis: RedisClientType = createClient({ + socket: { + host: 'localhost', + port: 6379, + }, + password: 'redis', +}) + +describe('reids', () => { + test('correctly finds user from a given session id', async () => { + const sessions = new RedisSessions({ redis }) + + const validSessionId = await sessions.createSession({ + userId: 'user-id', + label: 'test', + meta: {}, + }) + + await expect(sessions.getUserIdFromSession(validSessionId)).resolves.not.toBeNull() + + const invalidSessionId = SessionUtils.toSessionId('invalid-session-id') + + await expect(sessions.getUserIdFromSession(invalidSessionId)).resolves.toBeNull() + }) + + test('correctly parses userId from a given session id', async () => { + const sessions = new RedisSessions({ redis }) + + const userId = 'user-id' + const sessionId = await sessions.createSession({ + userId, + label: 'test', + meta: {}, + }) + + await expect(sessions.getUserIdFromSession(sessionId)).resolves.toEqual(userId) + }) + + test('correctly parses meta from a given session id', async () => { + // NOTE: We want to test all serializable types here. + + const sessions = new RedisSessions< + string, + { + string: string + number: number + boolean: boolean + array: string[] + object: { [key: string]: string } + } + >({ redis }) + + const meta = { + string: 'string', + number: 1, + boolean: true, + array: ['string', 'string'], + object: { + string: 'string', + }, + } + + const sessionId = await sessions.createSession({ + userId: 'user-id', + label: 'test', + meta, + }) + + await expect(sessions.getSessionMeta(sessionId)).resolves.toEqual(meta) + }) + + test('correctly destroys a session', async () => { + const sessions = new RedisSessions({ redis }) + + const sessionId = await sessions.createSession({ + userId: 'user-id', + label: 'test', + meta: {}, + }) + + await sessions.destroySession(sessionId) + + await expect(sessions.getUserIdFromSession(sessionId)).resolves.toBeNull() + }) + + test('correctly lists all sessions', async () => { + const sessions = new RedisSessions({ redis }) + + await sessions.createSession({ userId: 'user-id', label: '#1', meta: {} }) + await sessions.createSession({ userId: 'user-id', label: '#2', meta: {} }) + await sessions.createSession({ userId: 'user-id', label: '#3', meta: {} }) + + await expect( + // We filter out random values and dates. + sessions.listSessions().then((r) => r.map((o) => filter(o, ['label', 'userId']))), + ).resolves.toMatchInlineSnapshot(` + [ + { + "label": "#1", + "userId": "user-id", + }, + { + "label": "#2", + "userId": "user-id", + }, + { + "label": "#3", + "userId": "user-id", + }, + ] + `) + }) + + test('correclty lists sessions for a given user', async () => { + const sessions = new RedisSessions({ redis }) + + const userId = 'user-id' + + await sessions.createSession({ userId, label: '#1', meta: {} }) + await sessions.createSession({ userId, label: '#2', meta: {} }) + await sessions.createSession({ userId, label: '#3', meta: {} }) + + const otherUserId = 'other-user-id' + await sessions.createSession({ userId: otherUserId, label: '#1', meta: {} }) + await sessions.createSession({ userId: otherUserId, label: '#2', meta: {} }) + + await expect( + // We filter out random values and dates. + sessions.getSessionsForUser(userId).then((r) => r.map((o) => filter(o, ['label', 'userId']))), + ).resolves.toMatchInlineSnapshot(` + [ + { + "label": "#1", + "userId": "user-id", + }, + { + "label": "#2", + "userId": "user-id", + }, + { + "label": "#3", + "userId": "user-id", + }, + ] + `) + }) +}) diff --git a/src/stores/inmemory.ts b/src/stores/inmemory.ts index 21bb898..3216db4 100644 --- a/src/stores/inmemory.ts +++ b/src/stores/inmemory.ts @@ -73,11 +73,12 @@ export class InMemorySessions implements ISessions (a.lastUsedAt < b.lastUsedAt ? 1 : -1)) } public async listSessions(): Promise[]> { - return Array.from(this.sessions.values()) + // NOTE: The last accessed session needs to be at the top of the list. + return Array.from(this.sessions.values()).sort((a, b) => (a.lastUsedAt < b.lastUsedAt ? 1 : -1)) } private getSessionId(): SessionId { diff --git a/src/stores/redis.ts b/src/stores/redis.ts index d630ff8..7ca9768 100644 --- a/src/stores/redis.ts +++ b/src/stores/redis.ts @@ -137,7 +137,7 @@ export class RedisSessions implements }) } - return sessions + return sessions.sort((a, b) => b.lastUsedAt.toMillis() - a.lastUsedAt.toMillis()) } /** diff --git a/src/types.ts b/src/types.ts index 0afc70f..6440a96 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,7 +53,7 @@ export interface ISessions { destroySession(session: SessionId): Promise /** - * Lists all sessions in the system. + * Lists all sessions in the system starting with the least recently accessed one. */ listSessions(): Promise[]> } diff --git a/tests/store.test.ts b/tests/store.test.ts index f3e8378..44a6714 100644 --- a/tests/store.test.ts +++ b/tests/store.test.ts @@ -1,143 +1,172 @@ +import { RedisClientType, createClient } from 'redis' + +// RedisSessions (best for production environment). +const redis: RedisClientType = createClient({ + socket: { + host: 'localhost', + port: 6379, + }, + password: 'redis', +}) + import { InMemorySessions } from '../src/stores/inmemory' +import { RedisSessions } from '../src/stores/redis' +import { ISessions } from '../src/types' + import { SessionUtils } from '../src/utils' import { filter } from './utils' -describe('inmemory', () => { - test('correctly finds user from a given session id', async () => { - const sessions = new InMemorySessions() +type SessionId = string +type Meta = + | { + string: string + number: number + boolean: boolean + array: string[] + object: { [key: string]: string } + } + | {} + +const STORES: { label: string; sessions: () => Promise>; cleanup: () => Promise }[] = [ + { label: 'inmemory', sessions: async () => new InMemorySessions(), cleanup: async () => {} }, + { + label: 'redis', + sessions: async () => { + if (!redis.isReady) { + await redis.connect() + } - const validSessionId = await sessions.createSession({ - userId: 'user-id', - label: 'test', - meta: {}, + await redis.flushDb() + return new RedisSessions({ redis }) + }, + cleanup: async () => { + await redis.flushDb() + await redis.quit() + }, + }, +] + +for (const { label, sessions, cleanup } of STORES) { + describe(`${label} store`, () => { + let store: ISessions + + beforeEach(async () => { + store = await sessions() }) - await expect(sessions.getUserIdFromSession(validSessionId)).resolves.not.toBeNull() + afterAll(async () => { + await cleanup() + }) - const invalidSessionId = SessionUtils.toSessionId('invalid-session-id') + test('correctly finds user from a given session id', async () => { + const validSessionId = await store.createSession({ + userId: 'user-id', + label: 'test', + meta: {}, + }) - await expect(sessions.getUserIdFromSession(invalidSessionId)).resolves.toBeNull() - }) + await expect(store.getUserIdFromSession(validSessionId)).resolves.not.toBeNull() - test('correctly parses userId from a given session id', async () => { - const sessions = new InMemorySessions() + const invalidSessionId = SessionUtils.toSessionId('invalid-session-id') - const userId = 'user-id' - const sessionId = await sessions.createSession({ - userId, - label: 'test', - meta: {}, + await expect(store.getUserIdFromSession(invalidSessionId)).resolves.toBeNull() }) - await expect(sessions.getUserIdFromSession(sessionId)).resolves.toEqual(userId) - }) - - test('correctly parses meta from a given session id', async () => { - // NOTE: We want to test all serializable types here. - - const sessions = new InMemorySessions< - string, - { - string: string - number: number - boolean: boolean - array: string[] - object: { [key: string]: string } - } - >() - - const meta = { - string: 'string', - number: 1, - boolean: true, - array: ['string', 'string'], - object: { - string: 'string', - }, - } + test('correctly parses userId from a given session id', async () => { + const userId = 'user-id' + const sessionId = await store.createSession({ + userId, + label: 'test', + meta: {}, + }) - const sessionId = await sessions.createSession({ - userId: 'user-id', - label: 'test', - meta, + await expect(store.getUserIdFromSession(sessionId)).resolves.toEqual(userId) }) - await expect(sessions.getSessionMeta(sessionId)).resolves.toEqual(meta) - }) + test('correctly parses meta from a given session id', async () => { + const meta = { + string: 'string', + number: 1, + boolean: true, + array: ['string', 'string'], + object: { + string: 'string', + }, + } - test('correctly destroys a session', async () => { - const sessions = new InMemorySessions() + const sessionId = await store.createSession({ + userId: 'user-id', + label: 'test', + meta, + }) - const sessionId = await sessions.createSession({ - userId: 'user-id', - label: 'test', - meta: {}, + await expect(store.getSessionMeta(sessionId)).resolves.toEqual(meta) }) - await sessions.destroySession(sessionId) + test('correctly destroys a session', async () => { + const sessionId = await store.createSession({ + userId: 'user-id', + label: 'test', + meta: {}, + }) - await expect(sessions.getUserIdFromSession(sessionId)).resolves.toBeNull() - }) + await store.destroySession(sessionId) - test('correctly lists all sessions', async () => { - const sessions = new InMemorySessions() + await expect(store.getUserIdFromSession(sessionId)).resolves.toBeNull() + }) - await sessions.createSession({ userId: 'user-id', label: '#1', meta: {} }) - await sessions.createSession({ userId: 'user-id', label: '#2', meta: {} }) - await sessions.createSession({ userId: 'user-id', label: '#3', meta: {} }) + test('correctly lists all sessions', async () => { + await store.createSession({ userId: 'user-id', label: '#1', meta: {} }) + await store.createSession({ userId: 'user-id', label: '#2', meta: {} }) + await store.createSession({ userId: 'user-id', label: '#3', meta: {} }) - await expect( - // We filter out random values and dates. - sessions.listSessions().then((r) => r.map((o) => filter(o, ['label', 'userId']))), - ).resolves.toMatchInlineSnapshot(` - [ + await expect( + // We filter out random values and dates. + store.listSessions().then((r) => r.map((o) => filter(o, ['label', 'userId']))), + ).resolves.toEqual([ { - "label": "#1", - "userId": "user-id", + label: '#3', + userId: 'user-id', }, { - "label": "#2", - "userId": "user-id", + label: '#2', + userId: 'user-id', }, { - "label": "#3", - "userId": "user-id", + label: '#1', + userId: 'user-id', }, - ] - `) - }) - - test('correclty lists sessions for a given user', async () => { - const sessions = new InMemorySessions() + ]) + }) - const userId = 'user-id' + test('correclty lists sessions for a given user', async () => { + const userId = 'user-id' - await sessions.createSession({ userId, label: '#1', meta: {} }) - await sessions.createSession({ userId, label: '#2', meta: {} }) - await sessions.createSession({ userId, label: '#3', meta: {} }) + await store.createSession({ userId, label: '#1', meta: {} }) + await store.createSession({ userId, label: '#2', meta: {} }) + await store.createSession({ userId, label: '#3', meta: {} }) - const otherUserId = 'other-user-id' - await sessions.createSession({ userId: otherUserId, label: '#1', meta: {} }) - await sessions.createSession({ userId: otherUserId, label: '#2', meta: {} }) + const otherUserId = 'other-user-id' + await store.createSession({ userId: otherUserId, label: '#1', meta: {} }) + await store.createSession({ userId: otherUserId, label: '#2', meta: {} }) - await expect( - // We filter out random values and dates. - sessions.getSessionsForUser(userId).then((r) => r.map((o) => filter(o, ['label', 'userId']))), - ).resolves.toMatchInlineSnapshot(` - [ + await expect( + // We filter out random values and dates. + store.getSessionsForUser(userId).then((r) => r.map((o) => filter(o, ['label', 'userId']))), + ).resolves.toEqual([ { - "label": "#1", - "userId": "user-id", + label: '#3', + userId: 'user-id', }, { - "label": "#2", - "userId": "user-id", + label: '#2', + userId: 'user-id', }, { - "label": "#3", - "userId": "user-id", + label: '#1', + userId: 'user-id', }, - ] - `) + ]) + }) }) -}) +} diff --git a/tsconfig.json b/tsconfig.json index 8198751..5063ad3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,6 @@ "compilerOptions": { "target": "ES2020", "lib": ["dom", "dom.iterable", "esnext"], - "declaration": false, "module": "CommonJS", "moduleResolution": "node", @@ -17,6 +16,7 @@ "skipLibCheck": true, "strict": true, + "declaration": true, "sourceMap": true, "rootDir": "./src",