From 690fcaaf5d9b06c2fc6ab62489197ecbffcf42fd Mon Sep 17 00:00:00 2001 From: Matic Zavadlal Date: Sat, 10 Jun 2023 13:15:45 +0200 Subject: [PATCH] add tests --- README.md | 61 +++++++++------ docker-compose.yml | 18 +++++ docker/redis/Dockerfile | 6 ++ docker/redis/redis.conf | 18 +++++ docker/redis/start.sh | 17 +++++ package.json | 1 - src/index.ts | 5 +- src/{ => stores}/inmemory.ts | 2 +- src/{ => stores}/redis.ts | 2 +- src/utils.ts | 2 +- tests/inmemory.test.ts | 0 tests/redis.test.ts | 0 tests/store.test.ts | 143 +++++++++++++++++++++++++++++++++++ tests/utils.test.ts | 13 ++++ tests/utils.ts | 10 +++ 15 files changed, 268 insertions(+), 30 deletions(-) create mode 100644 docker-compose.yml create mode 100644 docker/redis/Dockerfile create mode 100644 docker/redis/redis.conf create mode 100755 docker/redis/start.sh rename src/{ => stores}/inmemory.ts (97%) rename src/{ => stores}/redis.ts (98%) delete mode 100644 tests/inmemory.test.ts delete mode 100644 tests/redis.test.ts create mode 100644 tests/store.test.ts create mode 100644 tests/utils.test.ts create mode 100644 tests/utils.ts diff --git a/README.md b/README.md index 475b74f..2c4af62 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,18 @@ Each sessions variant implements a general `ISessions` interface and may be swap > NOTE: Examples below use `trpc` and `fastify`, but the API is completely framework agnostic and should be easy to port to any other server framework. -````ts +#### Defining Types +```ts +type SessionId = number type SessionMeta = { - // anything that's serializable + // anything that's serializable } +``` + +#### Creating Session Stores +```ts // InMemorySessions (best for local development). const sessions = new InMemorySessions() @@ -31,46 +37,50 @@ const redis: RedisClientType = createClient({ }) const sessions = new RedisSessions({ redis }) +``` + +#### Creating Context +By creating a context, you let the routes in your GraphQL or REST server access the same store. +> I usually incldue the parsed session information in the context as well, so that I don't need to repeat it in each route separately. + +```ts export type Context = { - /** - * The session ID of the authenticated user if there exists one. - */ session: SessionId | null - - /** - * Access to session related services. - */ sessions: ISessions } - // Create a context with a shared sessions instance. await server.register(fastifyTRPCPlugin, { - prefix: '/trpc', - logLevel: 'debug', - trpcOptions: { - router: root, - createContext: ({ req, res }: CreateFastifyContextOptions): Context => { - const session = SessionUtils.getSessionIdFromAuthToken(req.headers.authorization) - - return { sessions, session } - }, - }, + prefix: '/trpc', + logLevel: 'debug', + trpcOptions: { + router: root, + createContext: ({ req, res }: CreateFastifyContextOptions): Context => { + const session = SessionUtils.getSessionIdFromAuthToken(req.headers.authorization) + + return { sessions, session } + }, + }, }) +``` +#### Manipulating Sessions + +```ts // Getting session from request header. const session = SessionUtils.getSessionIdFromAuthToken(req.headers.authorization) +// Getting Session Information const userId = await ctx.sessions.getUserIdFromSession(session) const meta = await ctx.sessions.getSessionMeta(session) // Creating sessions and getting tokens. const session = await ctx.sessions.createSession({ - userId: user.id, - label: input.label, - meta: {}, + userId: user.id, + label: input.label, + meta: {}, }) const token = SessionUtils.getAuthTokenForSessionId(session) @@ -88,4 +98,7 @@ const allExistingSessions = await sessions.listSessions() ### License MIT @ Matic Zavadlal -```` + +``` + +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..29a7f79 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +version: '3.1' +services: + + redis: + build: + context: . + dockerfile: ./docker/redis/Dockerfile + restart: always + ports: + - "6379:6379" + environment: + REDIS_PASSWORD: redis + volumes: + - redis:/data + +volumes: + postgres: + redis: diff --git a/docker/redis/Dockerfile b/docker/redis/Dockerfile new file mode 100644 index 0000000..098b3bf --- /dev/null +++ b/docker/redis/Dockerfile @@ -0,0 +1,6 @@ +FROM redis:7-alpine + +COPY docker/redis/redis.conf /usr/local/etc/redis/redis.conf +COPY docker/redis/start.sh /usr/bin/start.sh + +CMD ["/usr/bin/start.sh"] diff --git a/docker/redis/redis.conf b/docker/redis/redis.conf new file mode 100644 index 0000000..2c231a9 --- /dev/null +++ b/docker/redis/redis.conf @@ -0,0 +1,18 @@ +# Important Redis defaults (for reference): +# ----------------------------------------- +# bind 127.0.0.1 -::1 +# port 6379 +# save 3600 1 +# save 300 100 +# save 60 10000 +# appendonly no +# dir /var/lib/redis +# maxmemory +# maxmemory-policy noeviction + +bind 0.0.0.0 -::1 + +dir /data +maxmemory-policy noeviction +maxmemory 460mb +appendonly no \ No newline at end of file diff --git a/docker/redis/start.sh b/docker/redis/start.sh new file mode 100755 index 0000000..12fe218 --- /dev/null +++ b/docker/redis/start.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +# This script is inspired by https://github.com/mra-clubhouse/fly-redis/tree/custom-redis. + +# Clear environment variables. +set -e + +sysctl vm.overcommit_memory=1 || true +sysctl net.core.somaxconn=1024 || true + +if [[ -z "${REDIS_PASSWORD}" ]]; then + echo "The REDIS_PASSWORD environment variable is required" + exit 1 +fi + + +redis-server /usr/local/etc/redis/redis.conf --requirepass $REDIS_PASSWORD diff --git a/package.json b/package.json index 1abaf45..73924e6 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "test:lib": "jest" }, "dependencies": { - "ioredis": "^5.3.1", "luxon": "^3.2.1", "redis": "^4.6.5", "uuid": "^9.0.0" diff --git a/src/index.ts b/src/index.ts index c49082e..e5482fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ -export { InMemorySessions } from './inmemory' -export { RedisSessions } from './redis' +export { InMemorySessions } from './stores/inmemory' +export { RedisSessions } from './stores/redis' + export { SessionUtils } from './utils' export type { ISessions, Session, SessionId } from './types' diff --git a/src/inmemory.ts b/src/stores/inmemory.ts similarity index 97% rename from src/inmemory.ts rename to src/stores/inmemory.ts index be08a2d..21bb898 100644 --- a/src/inmemory.ts +++ b/src/stores/inmemory.ts @@ -1,7 +1,7 @@ import * as crypto from 'crypto' import { DateTime } from 'luxon' -import { Session, SessionId, ISessions } from './types' +import { Session, SessionId, ISessions } from '../types' /** * Utility class that manages user sessions in memory. diff --git a/src/redis.ts b/src/stores/redis.ts similarity index 98% rename from src/redis.ts rename to src/stores/redis.ts index 8fcb0c7..d630ff8 100644 --- a/src/redis.ts +++ b/src/stores/redis.ts @@ -2,7 +2,7 @@ import * as crypto from 'crypto' import { DateTime } from 'luxon' import { RedisClientType } from 'redis' -import { Session, SessionId, ISessions } from './types' +import { Session, SessionId, ISessions } from '../types' /** * Key containing all active session identifiers. diff --git a/src/utils.ts b/src/utils.ts index 73733b8..b13dc41 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -19,7 +19,7 @@ export namespace SessionUtils { * Returns the HTTP Authorization header that should be used to associate a given session. */ export function getAuthTokenForSessionId(sessionId: SessionId): string { - return sessionId as string + return `Bearer ${sessionId}` } /** diff --git a/tests/inmemory.test.ts b/tests/inmemory.test.ts deleted file mode 100644 index e69de29..0000000 diff --git a/tests/redis.test.ts b/tests/redis.test.ts deleted file mode 100644 index e69de29..0000000 diff --git a/tests/store.test.ts b/tests/store.test.ts new file mode 100644 index 0000000..f3e8378 --- /dev/null +++ b/tests/store.test.ts @@ -0,0 +1,143 @@ +import { InMemorySessions } from '../src/stores/inmemory' +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() + + 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 InMemorySessions() + + 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 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', + }, + } + + 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 InMemorySessions() + + 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 InMemorySessions() + + 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 InMemorySessions() + + 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/tests/utils.test.ts b/tests/utils.test.ts new file mode 100644 index 0000000..d086f05 --- /dev/null +++ b/tests/utils.test.ts @@ -0,0 +1,13 @@ +import { SessionUtils } from '../src/utils' + +describe('utils', () => { + test('correctly parses and formats session auth token', () => { + const random = Math.random().toString(16) + const sessionId = SessionUtils.toSessionId(random) + + const authToken = SessionUtils.getAuthTokenForSessionId(sessionId) + const parsedSessionId = SessionUtils.getSessionIdFromAuthToken(authToken) + + expect(parsedSessionId).toEqual(sessionId) + }) +}) diff --git a/tests/utils.ts b/tests/utils.ts new file mode 100644 index 0000000..8bbab02 --- /dev/null +++ b/tests/utils.ts @@ -0,0 +1,10 @@ +/** + * Filters out desired keys from an object. + */ +export function filter(obj: T, keys: KS[]): Pick { + const result: any = {} + for (const key of keys) { + result[key] = obj[key] + } + return result +}