diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 2a879e8..a200e4a 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -15,6 +15,7 @@ jobs: strategy: matrix: node-version: [latest] + db: [sqlite, postgres, mysql] services: redis: image: redis @@ -25,6 +26,31 @@ jobs: --health-retries 5 ports: - 6379:6379 + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: session_test + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + mysql: + image: mysql:8 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: session_test + options: >- + --health-cmd "mysqladmin ping -h localhost" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 3306:3306 dynamodb-local: image: amazon/dynamodb-local ports: @@ -61,6 +87,17 @@ jobs: env: REDIS_HOST: 0.0.0.0 REDIS_PORT: 6379 + DB_CONNECTION: ${{ matrix.db }} + PG_HOST: 0.0.0.0 + PG_PORT: 5432 + PG_USER: postgres + PG_PASSWORD: postgres + PG_DATABASE: session_test + MYSQL_HOST: 0.0.0.0 + MYSQL_PORT: 3306 + MYSQL_USER: root + MYSQL_PASSWORD: root + MYSQL_DATABASE: session_test test_windows: runs-on: windows-latest @@ -82,3 +119,4 @@ jobs: env: NO_REDIS: true NO_DYNAMODB: true + NO_DATABASE: true diff --git a/commands/make_session_table.ts b/commands/make_session_table.ts new file mode 100644 index 0000000..d4d4bf5 --- /dev/null +++ b/commands/make_session_table.ts @@ -0,0 +1,26 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { stubsRoot } from '../stubs/main.ts' +import { BaseCommand } from '@adonisjs/core/ace' + +/** + * Command to create the sessions table migration + */ +export default class MakeSessionTable extends BaseCommand { + static commandName = 'make:session-table' + static description = 'Create a migration for the sessions database table' + + async run() { + const codemods = await this.createCodemods() + await codemods.makeUsingStub(stubsRoot, 'make/migration/sessions.stub', { + migration: { tableName: 'sessions', prefix: Date.now() }, + }) + } +} diff --git a/docker-compose.yml b/compose.yml similarity index 52% rename from docker-compose.yml rename to compose.yml index c37e36a..abb18d2 100644 --- a/docker-compose.yml +++ b/compose.yml @@ -1,5 +1,3 @@ -version: '3.4' - services: redis: image: redis:alpine @@ -14,16 +12,43 @@ services: environment: - REDIS_URI=redis://redis:6379 + postgres: + image: postgres:16-alpine + ports: + - 5432:5432 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: session_test + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + mysql: + image: mysql:8 + ports: + - 3306:3306 + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: session_test + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 5 + dynamodb-local: image: amazon/dynamodb-local ports: - 8000:8000 - command: '-jar DynamoDBLocal.jar -inMemory -sharedDb' + command: "-jar DynamoDBLocal.jar -inMemory -sharedDb" working_dir: /home/dynamodblocal healthcheck: test: [ - 'CMD-SHELL', + "CMD-SHELL", '[ "$(curl -s -o /dev/null -I -w ''%{http_code}'' http://localhost:8000)" == "400" ]', ] interval: 10s @@ -36,11 +61,11 @@ services: condition: service_healthy image: amazon/aws-cli volumes: - - './tests_helpers/dynamodb_schemas:/tmp/dynamo' + - "./tests_helpers/dynamodb_schemas:/tmp/dynamo" environment: - AWS_ACCESS_KEY_ID: 'accessKeyId' - AWS_SECRET_ACCESS_KEY: 'secretAccessKey' - AWS_REGION: 'us-east-1' + AWS_ACCESS_KEY_ID: "accessKeyId" + AWS_SECRET_ACCESS_KEY: "secretAccessKey" + AWS_REGION: "us-east-1" entrypoint: - bash command: '-c "for f in /tmp/dynamo/*.json; do aws dynamodb create-table --endpoint-url "http://dynamodb-local:8000" --cli-input-json file://"$${f#./}"; done"' diff --git a/configure.ts b/configure.ts index 840d74f..f40e5f2 100644 --- a/configure.ts +++ b/configure.ts @@ -46,9 +46,10 @@ export async function configure(command: Configure) { ]) /** - * Register provider + * Register provider and commands */ await codemods.updateRcFile((rcFile) => { rcFile.addProvider('@adonisjs/session/session_provider') + rcFile.addCommand('@adonisjs/session/commands') }) } diff --git a/index.ts b/index.ts index 28397cd..69da559 100644 --- a/index.ts +++ b/index.ts @@ -12,4 +12,5 @@ export { configure } from './configure.ts' export { Session } from './src/session.ts' export { stubsRoot } from './stubs/main.ts' export { defineConfig, stores } from './src/define_config.ts' +export { SessionCollection } from './src/session_collection.ts' export { ReadOnlyValuesStore, ValuesStore } from './src/values_store.ts' diff --git a/package.json b/package.json index 5675930..038c162 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "./plugins/api_client": "./build/src/plugins/japa/api_client.js", "./plugins/browser_client": "./build/src/plugins/japa/browser_client.js", "./client": "./build/src/client.js", - "./types": "./build/src/types.js" + "./types": "./build/src/types.js", + "./commands": "./build/commands/main.js" }, "scripts": { "pretest": "npm run lint", @@ -33,7 +34,8 @@ "copy:templates": "copyfiles \"stubs/**/*.stub\" --up=\"1\" build", "precompile": "npm run lint", "compile": "tsdown && tsc --emitDeclarationOnly --declaration", - "postcompile": "npm run copy:templates", + "postcompile": "npm run copy:templates && npm run index:commands", + "index:commands": "adonis-kit index build/commands", "build": "npm run compile", "docs": "typedoc", "version": "npm run build", @@ -46,6 +48,7 @@ "@adonisjs/core": "^7.0.0-next.16", "@adonisjs/eslint-config": "^3.0.0-next.5", "@adonisjs/i18n": "^3.0.0-next.2", + "@adonisjs/lucid": "^22.0.0-next.0", "@adonisjs/prettier-config": "^1.4.5", "@adonisjs/redis": "^10.0.0-next.2", "@adonisjs/tsconfig": "^2.0.0-next.3", @@ -66,6 +69,9 @@ "@vinejs/vine": "^4.2.0", "c8": "^10.1.3", "copyfiles": "^2.4.1", + "better-sqlite3": "^12.5.0", + "mysql2": "^3.15.3", + "pg": "^8.16.3", "cross-env": "^10.1.0", "edge.js": "^6.4.0", "eslint": "^9.39.2", @@ -86,6 +92,7 @@ "peerDependencies": { "@adonisjs/assembler": "^8.0.0-next.26", "@adonisjs/core": "^7.0.0-next.16", + "@adonisjs/lucid": "^22.0.0-next.0", "@adonisjs/redis": "^10.0.0-next.2", "@aws-sdk/client-dynamodb": "^3.955.0", "@aws-sdk/util-dynamodb": "^3.955.0", @@ -98,6 +105,9 @@ "@adonisjs/assembler": { "optional": true }, + "@adonisjs/lucid": { + "optional": true + }, "@adonisjs/redis": { "optional": true }, @@ -121,6 +131,9 @@ } }, "author": "virk,adonisjs", + "contributors": [ + "Julien Ripouteau " + ], "license": "MIT", "homepage": "https://github.com/adonisjs/session#readme", "repository": { @@ -148,7 +161,8 @@ "./src/plugins/edge.ts", "./src/plugins/japa/api_client.ts", "./src/plugins/japa/browser_client.ts", - "./src/client.ts" + "./src/client.ts", + "./commands/make_session_table.ts" ], "outDir": "./build", "clean": true, diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3b5d608 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +ignoredBuiltDependencies: + - '@swc/core' + - esbuild + +onlyBuiltDependencies: + - better-sqlite3 diff --git a/providers/session_provider.ts b/providers/session_provider.ts index b626f3f..651c586 100644 --- a/providers/session_provider.ts +++ b/providers/session_provider.ts @@ -13,6 +13,7 @@ import type { ApplicationService } from '@adonisjs/core/types' import type { Session } from '../src/session.ts' import SessionMiddleware from '../src/session_middleware.ts' +import { SessionCollection } from '../src/session_collection.ts' /** * Events emitted by the session class @@ -45,25 +46,34 @@ export default class SessionProvider { } /** - * Registering muddleware + * Resolves the session config from the config provider */ - register() { - this.app.container.singleton(SessionMiddleware, async (resolver) => { - const sessionConfigProvider = this.app.config.get('session', {}) + async #resolveConfig() { + const sessionConfigProvider = this.app.config.get('session', {}) + const config = await configProvider.resolve(this.app, sessionConfigProvider) + if (!config) { + throw new RuntimeException( + 'Invalid "config/session.ts" file. Make sure you are using the "defineConfig" method' + ) + } - /** - * Resolve config from the provider - */ - const config = await configProvider.resolve(this.app, sessionConfigProvider) - if (!config) { - throw new RuntimeException( - 'Invalid "config/session.ts" file. Make sure you are using the "defineConfig" method' - ) - } + return config + } + /** + * Registering bindings + */ + register() { + this.app.container.singleton(SessionMiddleware, async (resolver) => { + const config = await this.#resolveConfig() const emitter = await resolver.make('emitter') return new SessionMiddleware(config, emitter) }) + + this.app.container.singleton(SessionCollection, async () => { + const config = await this.#resolveConfig() + return new SessionCollection(config) + }) } /** diff --git a/src/define_config.ts b/src/define_config.ts index d1f90ae..60c6472 100644 --- a/src/define_config.ts +++ b/src/define_config.ts @@ -8,12 +8,13 @@ */ /// +/// import { configProvider } from '@adonisjs/core' import string from '@adonisjs/core/helpers/string' import type { ConfigProvider } from '@adonisjs/core/types' import type { CookieOptions } from '@adonisjs/core/types/http' -import { InvalidArgumentsException } from '@adonisjs/core/exceptions' +import { InvalidArgumentsException, RuntimeException } from '@adonisjs/core/exceptions' import debug from './debug.ts' import { MemoryStore } from './stores/memory.ts' @@ -23,6 +24,7 @@ import type { RedisStoreConfig, SessionStoreFactory, DynamoDBStoreConfig, + DatabaseStoreConfig, } from './types.ts' type ConfigInput< @@ -170,6 +172,7 @@ export const stores: { redis: (config: RedisStoreConfig) => ConfigProvider cookie: () => ConfigProvider dynamodb: (config: DynamoDBStoreConfig) => ConfigProvider + database: (config?: DatabaseStoreConfig) => ConfigProvider } = { /** * Creates a file-based session store @@ -231,4 +234,29 @@ export const stores: { } }) }, + /** + * Creates a database-based session store using Lucid + * + * @param config - Database store configuration + */ + database: (config) => { + return configProvider.create(async (app) => { + const { DatabaseStore } = await import('./stores/database.js') + const db = await app.container.make('lucid.db') + const connectionName = config?.connectionName || db.primaryConnectionName + + if (!db.manager.has(connectionName)) { + throw new RuntimeException( + `Invalid database connection "${connectionName}" referenced in session config` + ) + } + + return (_, sessionConfig: SessionConfig) => { + return new DatabaseStore(db.connection(connectionName), sessionConfig.age, { + tableName: config?.tableName, + gcProbability: config?.gcProbability, + }) + } + }) + }, } diff --git a/src/errors.ts b/src/errors.ts index f6ac4f8..8a38097 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -42,3 +42,17 @@ export const E_SESSION_NOT_READY = createError( 'E_SESSION_NOT_READY', 500 ) + +/** + * Error thrown when attempting to use tagging features with a store that doesn't support it. + * Only Memory, Redis, and Database stores support session tagging. + * + * @example + * // This will throw E_SESSION_TAGGING_NOT_SUPPORTED when using cookie store + * const sessions = await sessionCollection.tagged(userId) + */ +export const E_SESSION_TAGGING_NOT_SUPPORTED = createError( + 'Session store does not support tagging. Use memory, redis, or database store instead', + 'E_SESSION_TAGGING_NOT_SUPPORTED', + 500 +) diff --git a/src/session.ts b/src/session.ts index fd40f7a..85d6f9c 100644 --- a/src/session.ts +++ b/src/session.ts @@ -24,6 +24,7 @@ import type { SessionStoreFactory, AllowedSessionValues, SessionStoreContract, + SessionStoreWithTaggingContract, } from './types.ts' /** @@ -44,7 +45,7 @@ import type { * await session.commit() */ export class Session extends Macroable { - #store: SessionStoreContract + #store: SessionStoreContract | SessionStoreWithTaggingContract #emitter: EmitterService #ctx: HttpContext #readonly: boolean = false @@ -516,6 +517,22 @@ export class Session extends Macroable { this.#getFlashStore('write').set('reflashed', lodash.omit(this.flashMessages.all(), keys)) } + /** + * Tag the current session with a user ID. + * Only supported by Memory, Redis, and Database stores. + * This enables features like "logout from all devices". + * + * @param userId - The user ID to tag this session with + * + * @example + * // During login, tag the session with the user's ID + * await session.tag(String(user.id)) + */ + async tag(userId: string): Promise { + if (!('tag' in this.#store)) throw new errors.E_SESSION_TAGGING_NOT_SUPPORTED() + await this.#store.tag(this.#sessionId, userId) + } + /** * Re-generates the session id and migrates data to it * diff --git a/src/session_collection.ts b/src/session_collection.ts new file mode 100644 index 0000000..b2fba6e --- /dev/null +++ b/src/session_collection.ts @@ -0,0 +1,117 @@ +/** + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import debug from './debug.ts' +import { E_SESSION_TAGGING_NOT_SUPPORTED } from './errors.ts' +import type { + ResolvedSessionConfig, + SessionData, + SessionStoreWithTaggingContract, + TaggedSession, +} from './types.ts' + +/** + * SessionCollection provides APIs for programmatic session + * management. It allows reading, destroying, and tagging + * sessions without an HTTP context. + * + * @example + * ```ts + * import app from '@adonisjs/core/services/app' + * import { SessionCollection } from '@adonisjs/session' + * + * const sessionCollection = await app.container.make(SessionCollection) + * + * // List all sessions for a user + * const sessions = await sessionCollection.tagged(String(user.id)) + * + * // Destroy a specific session + * await sessionCollection.destroy(sessionId) + * ``` + */ +export class SessionCollection { + #store: SessionStoreWithTaggingContract + + /** + * Creates a new SessionCollection instance + * + * @param config - Resolved session configuration + */ + constructor(config: ResolvedSessionConfig) { + const storeFactory = config.stores[config.store] + this.#store = storeFactory(null as any, config) as SessionStoreWithTaggingContract + } + + /** + * Check if the current store supports tagging + */ + supportsTagging(): boolean { + return 'tag' in this.#store && 'tagged' in this.#store + } + + /** + * Returns the session data for the given session ID, + * or null if the session does not exist + * + * @param sessionId - Session identifier + * + * @example + * const data = await sessionCollection.get('sess_abc123') + */ + async get(sessionId: string): Promise { + debug('session collection: getting session data %s', sessionId) + return this.#store.read(sessionId) + } + + /** + * Destroys a session by its ID + * + * @param sessionId - Session identifier + * + * @example + * await sessionCollection.destroy('sess_abc123') + */ + async destroy(sessionId: string): Promise { + debug('session collection: destroying session %s', sessionId) + return this.#store.destroy(sessionId) + } + + /** + * Tag a session with a user ID. + * Only supported by Memory, Redis and Database stores. + * + * @param sessionId - Session identifier + * @param userId - User identifier to tag the session with + * + * @example + * await sessionCollection.tag('sess_abc123', 'user_456') + */ + async tag(sessionId: string, userId: string): Promise { + debug('session collection: tagging session %s with user %s', sessionId, userId) + if (!this.supportsTagging()) throw new E_SESSION_TAGGING_NOT_SUPPORTED() + + return (this.#store as SessionStoreWithTaggingContract).tag(sessionId, userId) + } + + /** + * Get all sessions for a given user ID (tag). + * Only supported by Memory, Redis and Database stores. + * + * @param userId - User identifier to get sessions for + * + * @example + * const sessions = await sessionCollection.tagged('user_456') + */ + async tagged(userId: string): Promise { + debug('session collection: getting sessions tagged with user %s', userId) + if (!this.supportsTagging()) throw new E_SESSION_TAGGING_NOT_SUPPORTED() + + return (this.#store as SessionStoreWithTaggingContract).tagged(userId) + } +} diff --git a/src/stores/database.ts b/src/stores/database.ts new file mode 100644 index 0000000..46f0abf --- /dev/null +++ b/src/stores/database.ts @@ -0,0 +1,203 @@ +/** + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import string from '@poppinss/utils/string' +import { MessageBuilder } from '@adonisjs/core/helpers' +import type { QueryClientContract } from '@adonisjs/lucid/types/database' + +import debug from '../debug.ts' +import type { SessionStoreWithTaggingContract, SessionData, TaggedSession } from '../types.ts' + +/** + * Database store to read/write session to SQL databases using Lucid + */ +export class DatabaseStore implements SessionStoreWithTaggingContract { + #client: QueryClientContract + #tableName: string + #ttlSeconds: number + #gcProbability: number + + constructor( + client: QueryClientContract, + age: string | number, + options?: { + /** + * Defaults to "sessions" + */ + tableName?: string + + /** + * The probability (in percent) that garbage collection will be + * triggered on any given request. For example, 2 means 2% chance. + * + * Set to 0 to disable garbage collection. + * + * Defaults to 2 (2% chance) + */ + gcProbability?: number + } + ) { + this.#client = client + this.#tableName = options?.tableName ?? 'sessions' + this.#ttlSeconds = string.seconds.parse(age) + this.#gcProbability = options?.gcProbability ?? 2 + debug('initiating database store') + } + + /** + * Run garbage collection to delete expired sessions. + * This is called based on gcProbability after writing session data. + */ + async #collectGarbage(): Promise { + if (this.#gcProbability <= 0) { + return + } + + const random = Math.random() * 100 + if (random < this.#gcProbability) { + debug('database store: running garbage collection') + const expiredBefore = new Date(Date.now()) + await this.#client.from(this.#tableName).where('expires_at', '<=', expiredBefore).delete() + } + } + + /** + * Parses and verifies session data using MessageBuilder + */ + #parseSessionData(contents: string, sessionId: string): SessionData | null { + try { + return new MessageBuilder().verify(contents, sessionId) + } catch { + return null + } + } + + /** + * Returns session data + * + * @param sessionId - Session identifier + */ + async read(sessionId: string): Promise { + debug('database store: reading session data %s', sessionId) + + const row = await this.#client.from(this.#tableName).where('id', sessionId).first() + + if (!row) { + return null + } + + /** + * Check if the session has expired. If so, delete it and return null. + */ + const expiresAt = new Date(row.expires_at).getTime() + if (Date.now() > expiresAt) { + await this.destroy(sessionId) + return null + } + + return this.#parseSessionData(row.data, sessionId) + } + + /** + * Write session values to the database + * + * @param sessionId - Session identifier + * @param values - Session data to store + */ + async write(sessionId: string, values: Object): Promise { + debug('database store: writing session data %s, %O', sessionId, values) + + const message = new MessageBuilder().build(values, undefined, sessionId) + const expiresAt = new Date(Date.now() + this.#ttlSeconds * 1000) + + await this.#client + .insertQuery() + .table(this.#tableName) + .insert({ id: sessionId, data: message, expires_at: expiresAt }) + .knexQuery.onConflict('id') + .merge(['data', 'expires_at']) + + await this.#collectGarbage() + } + + /** + * Cleanup session by removing it + * + * @param sessionId - Session identifier + */ + async destroy(sessionId: string): Promise { + debug('database store: destroying session data %s', sessionId) + + await this.#client.from(this.#tableName).where('id', sessionId).delete() + } + + /** + * Updates the session expiry + * + * @param sessionId - Session identifier + */ + async touch(sessionId: string): Promise { + debug('database store: touching session data %s', sessionId) + + const expiresAt = new Date(Date.now() + this.#ttlSeconds * 1000) + + await this.#client + .from(this.#tableName) + .where('id', sessionId) + .update({ expires_at: expiresAt }) + } + + /** + * Tag a session with a user ID. + * Uses UPSERT to handle both existing and new sessions. + * + * @param sessionId - Session identifier + * @param userId - User identifier to tag the session with + */ + async tag(sessionId: string, userId: string): Promise { + debug('database store: tagging session %s with user %s', sessionId, userId) + + const data = new MessageBuilder().build({}, undefined, sessionId) + const expiresAt = new Date(Date.now() + this.#ttlSeconds * 1000) + + await this.#client + .insertQuery() + .table(this.#tableName) + .insert({ id: sessionId, user_id: userId, data, expires_at: expiresAt }) + .knexQuery.onConflict('id') + .merge(['user_id']) + } + + /** + * Converts a database row to a TaggedSession object + */ + #rowToTaggedSession(row: { id: string; data: string }): TaggedSession | null { + const data = this.#parseSessionData(row.data, row.id) + if (!data) return null + + return { id: row.id, data } + } + + /** + * Get all sessions for a given user ID (tag) + * + * @param userId - User identifier to get sessions for + */ + async tagged(userId: string): Promise { + debug('database store: getting sessions tagged with user %s', userId) + + const rows = await this.#client + .from(this.#tableName) + .select('id', 'data') + .where('user_id', userId) + .where('expires_at', '>', new Date()) + + return rows.map((row) => this.#rowToTaggedSession(row)).filter((session) => session !== null) + } +} diff --git a/src/stores/memory.ts b/src/stores/memory.ts index d22b2d6..c6d75a6 100644 --- a/src/stores/memory.ts +++ b/src/stores/memory.ts @@ -7,7 +7,7 @@ * file that was distributed with this source code. */ -import type { SessionData, SessionStoreContract } from '../types.ts' +import type { SessionData, SessionStoreWithTaggingContract, TaggedSession } from '../types.ts' /** * Memory store is meant to be used for writing tests. @@ -17,12 +17,17 @@ import type { SessionData, SessionStoreContract } from '../types.ts' * const memoryStore = new MemoryStore() * memoryStore.write('sess_abc123', { userId: 123 }) */ -export class MemoryStore implements SessionStoreContract { +export class MemoryStore implements SessionStoreWithTaggingContract { /** * Static map to store all session data in memory */ static sessions: Map = new Map() + /** + * Static map to store session tags (sessionId -> userId) + */ + static tags: Map = new Map() + /** * Reads session value from memory * @@ -58,6 +63,7 @@ export class MemoryStore implements SessionStoreContract { */ destroy(sessionId: string): void { MemoryStore.sessions.delete(sessionId) + MemoryStore.tags.delete(sessionId) } /** @@ -66,4 +72,32 @@ export class MemoryStore implements SessionStoreContract { * @param sessionId - Session identifier (unused) */ touch(_?: string): void {} + + /** + * Tag a session with a user ID + * + * @param sessionId - Session identifier + * @param userId - User identifier to tag the session with + */ + tag(sessionId: string, userId: string): void { + MemoryStore.tags.set(sessionId, userId) + } + + /** + * Get all sessions for a given user ID (tag) + * + * @param userId - User identifier to get sessions for + */ + tagged(userId: string): TaggedSession[] { + const sessions: TaggedSession[] = [] + + for (const [sessionId, tagUserId] of MemoryStore.tags) { + if (tagUserId !== userId) continue + + const data = MemoryStore.sessions.get(sessionId) + if (data) sessions.push({ id: sessionId, data }) + } + + return sessions + } } diff --git a/src/stores/redis.ts b/src/stores/redis.ts index b9d2926..09efe13 100644 --- a/src/stores/redis.ts +++ b/src/stores/redis.ts @@ -12,7 +12,7 @@ import { MessageBuilder } from '@adonisjs/core/helpers' import type { Connection } from '@adonisjs/redis/types' import debug from '../debug.ts' -import type { SessionStoreContract, SessionData } from '../types.ts' +import type { SessionData, TaggedSession, SessionStoreWithTaggingContract } from '../types.ts' /** * Redis store to read/write session data to Redis server. @@ -21,7 +21,7 @@ import type { SessionStoreContract, SessionData } from '../types.ts' * @example * const redisStore = new RedisStore(redisConnection, '2 hours') */ -export class RedisStore implements SessionStoreContract { +export class RedisStore implements SessionStoreWithTaggingContract { /** * Redis connection instance */ @@ -44,6 +44,60 @@ export class RedisStore implements SessionStoreContract { debug('initiating redis store') } + /** + * Processes a single session result from the pipeline + */ + #processSessionResult(options: { sessionId: string; contents: string | null }): { + session: TaggedSession | null + isInvalid: boolean + } { + if (!options.contents) return { session: null, isInvalid: true } + + const data = this.#parseSessionData(options.contents, options.sessionId) + if (!data) return { session: null, isInvalid: true } + + return { session: { id: options.sessionId, data }, isInvalid: false } + } + + /** + * Fetches session contents for multiple session IDs using a pipeline + */ + async #fetchSessionContents(sessionIds: string[]): Promise> { + const pipeline = this.#connection.pipeline() + sessionIds.forEach((sessionId) => pipeline.get(sessionId)) + const results = await pipeline.exec() + + return results?.map((result) => result[1] as string | null) ?? [] + } + + /** + * Removes invalid session IDs from the user's tag set + */ + async #cleanupInvalidSessions(userId: string, invalidSessionIds: string[]): Promise { + if (invalidSessionIds.length === 0) return + + await this.#connection.srem(this.#getTagKey(userId), ...invalidSessionIds) + } + + /* + * Returns the key for a user's tag set (stores session IDs for a user) + */ + #getTagKey(userId: string): string { + return `session_tag:${userId}` + } + + /** + * Verify contents with the session id and return them as an object. The verify + * method can fail when the contents is not JSON + */ + #parseSessionData(contents: string, sessionId: string): SessionData | null { + try { + return new MessageBuilder().verify(contents, sessionId) + } catch { + return null + } + } + /** * Reads session data from Redis * @@ -112,4 +166,42 @@ export class RedisStore implements SessionStoreContract { debug('redis store: touching session data %s', sessionId) await this.#connection.expire(sessionId, this.#ttlSeconds) } + + /** + * Tag a session with a user ID + * + * @param sessionId - Session identifier + * @param userId - User identifier to tag the session with + */ + async tag(sessionId: string, userId: string): Promise { + debug('redis store: tagging session %s with user %s', sessionId, userId) + await this.#connection.sadd(this.#getTagKey(userId), sessionId) + } + + /** + * Get all sessions for a given user ID (tag) + * + * @param userId - User identifier to get sessions for + */ + async tagged(userId: string): Promise { + debug('redis store: getting sessions tagged with user %s', userId) + + const sessionIds = await this.#connection.smembers(this.#getTagKey(userId)) + if (sessionIds.length === 0) return [] + + const contents = await this.#fetchSessionContents(sessionIds) + + const results = sessionIds.map((sessionId, index) => + this.#processSessionResult({ sessionId, contents: contents[index] }) + ) + + const validSessions = results.filter((r) => r.session !== null).map((r) => r.session!) + const invalidSessionIds = results + .map((result, index) => (result.isInvalid ? sessionIds[index] : null)) + .filter((id) => id !== null) + + await this.#cleanupInvalidSessions(userId, invalidSessionIds) + + return validSessions + } } diff --git a/src/types.ts b/src/types.ts index 395461c..64636e6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -239,3 +239,70 @@ export type SessionStoreFactory = ( ctx: HttpContext, sessionConfig: SessionConfig ) => SessionStoreContract + +/** + * Extended session store contract that supports tagging sessions with user IDs. + * This enables querying all sessions for a specific user, useful for features + * like "logout from all devices" or "view active sessions". + * + * @example + * class MyStore implements SessionStoreWithTaggingContract { + * // ... base SessionStoreContract methods ... + * + * async tag(sessionId: string, userId: string) { + * await this.storage.tag(sessionId, userId) + * } + * + * async tagged(userId: string) { + * return await this.storage.getSessionsByUser(userId) + * } + * } + */ +export interface SessionStoreWithTaggingContract extends SessionStoreContract { + /** + * Associates a session with a user ID (tag). + * This allows querying all sessions for a specific user. + */ + tag(sessionId: string, userId: string): Promise | void + + /** + * Returns all sessions associated with a given user ID (tag). + * Only returns non-expired sessions. + */ + tagged(userId: string): Promise | TaggedSession[] +} + +/** + * Represents a tagged session with its ID and data + */ +export interface TaggedSession { + id: string + data: SessionData +} + +/** + * Configuration used by the database store. + */ +export interface DatabaseStoreConfig { + connectionName?: string + tableName?: string + + /** + * The probability (in percent) that garbage collection of expired + * sessions will be triggered on any given request. + * + * For example, 2 means 2% chance. + * Set to 0 to disable garbage collection. + * + * Defaults to 2 (2% chance) + */ + gcProbability?: number +} + +/** + * Resolved session config after processing by defineConfig + */ +export interface ResolvedSessionConfig extends SessionConfig { + store: string + stores: Record +} diff --git a/stubs/make/migration/sessions.stub b/stubs/make/migration/sessions.stub new file mode 100644 index 0000000..162c1eb --- /dev/null +++ b/stubs/make/migration/sessions.stub @@ -0,0 +1,26 @@ +{{{ + exports({ + to: app.makePath( + 'database/migrations', + `${migration.prefix}_create_${migration.tableName}_table.ts` + ) + }) +}}} +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = '{{ migration.tableName }}' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.string('id').primary() + table.text('data').notNullable() + table.string('user_id').nullable().index() + table.timestamp('expires_at').notNullable().index() + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/tests/session_collection.spec.ts b/tests/session_collection.spec.ts new file mode 100644 index 0000000..9f78080 --- /dev/null +++ b/tests/session_collection.spec.ts @@ -0,0 +1,168 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { MemoryStore } from '../src/stores/memory.ts' +import { SessionCollection } from '../src/session_collection.ts' +import type { ResolvedSessionConfig, SessionStoreContract } from '../src/types.ts' +import { E_SESSION_TAGGING_NOT_SUPPORTED } from '../src/errors.ts' + +function createFakeConfig(store: SessionStoreContract): ResolvedSessionConfig { + return { + enabled: true, + cookieName: 'adonis_session', + age: '2h', + cookie: {}, + store: 'test', + stores: { test: () => store }, + } +} + +test.group('Session Collection', (group) => { + group.each.setup(() => { + return () => { + MemoryStore.sessions.clear() + MemoryStore.tags.clear() + } + }) + + test('get session data by id', async ({ assert }) => { + const store = new MemoryStore() + store.write('session-1', { user: 1, name: 'John' }) + + const collection = new SessionCollection(createFakeConfig(store)) + + const data = await collection.get('session-1') + assert.deepEqual(data, { user: 1, name: 'John' }) + }) + + test('return null when session does not exist', async ({ assert }) => { + const store = new MemoryStore() + const collection = new SessionCollection(createFakeConfig(store)) + + const data = await collection.get('non-existent') + assert.isNull(data) + }) + + test('destroy a session by id', async ({ assert }) => { + const store = new MemoryStore() + store.write('session-1', { user: 1 }) + store.write('session-2', { user: 2 }) + + const collection = new SessionCollection(createFakeConfig(store)) + await collection.destroy('session-1') + + assert.isNull(store.read('session-1')) + assert.isNotNull(store.read('session-2')) + }) +}) + +test.group('Session Collection | Tagging', (group) => { + group.each.setup(() => { + return () => { + MemoryStore.sessions.clear() + MemoryStore.tags.clear() + } + }) + + test('tag a session with a user id', async ({ assert }) => { + const store = new MemoryStore() + store.write('session-1', { user: 1 }) + + const collection = new SessionCollection(createFakeConfig(store)) + await collection.tag('session-1', 'user-123') + + assert.equal(MemoryStore.tags.get('session-1'), 'user-123') + }) + + test('get sessions tagged with a user id', async ({ assert }) => { + const store = new MemoryStore() + store.write('session-1', { user: 1 }) + store.write('session-2', { user: 1 }) + store.write('session-3', { user: 2 }) + + const collection = new SessionCollection(createFakeConfig(store)) + await collection.tag('session-1', 'user-1') + await collection.tag('session-2', 'user-1') + await collection.tag('session-3', 'user-2') + + const user1Sessions = await collection.tagged('user-1') + assert.sameDeepMembers(user1Sessions, [ + { id: 'session-1', data: { user: 1 } }, + { id: 'session-2', data: { user: 1 } }, + ]) + + const user2Sessions = await collection.tagged('user-2') + assert.deepEqual(user2Sessions, [{ id: 'session-3', data: { user: 2 } }]) + }) + + test('return empty array when user has no tagged sessions', async ({ assert }) => { + const store = new MemoryStore() + const collection = new SessionCollection(createFakeConfig(store)) + + const sessions = await collection.tagged('unknown-user') + assert.deepEqual(sessions, []) + }) + + test('supportsTagging returns true for stores with tag and tagged methods', async ({ + assert, + }) => { + const store = new MemoryStore() + const collection = new SessionCollection(createFakeConfig(store)) + + assert.isTrue(collection.supportsTagging()) + }) + + test('supportsTagging returns false for basic stores', async ({ assert }) => { + const basicStore: SessionStoreContract = { + read: () => null, + write: () => {}, + destroy: () => {}, + touch: () => {}, + } + + const collection = new SessionCollection(createFakeConfig(basicStore)) + assert.isFalse(collection.supportsTagging()) + }) + + test('throw error when tagging is not supported', async ({ assert }) => { + const basicStore: SessionStoreContract = { + read: () => null, + write: () => {}, + destroy: () => {}, + touch: () => {}, + } + + const collection = new SessionCollection(createFakeConfig(basicStore)) + + await assert.rejects( + () => collection.tag('session-1', 'user-1'), + // @ts-ignore Japa too strict + E_SESSION_TAGGING_NOT_SUPPORTED + ) + + // @ts-ignore Japa too strict + await assert.rejects(() => collection.tagged('user-1'), E_SESSION_TAGGING_NOT_SUPPORTED) + }) + + test('tagged excludes destroyed sessions', async ({ assert }) => { + const store = new MemoryStore() + store.write('session-1', { user: 1 }) + store.write('session-2', { user: 1 }) + + const collection = new SessionCollection(createFakeConfig(store)) + await collection.tag('session-1', 'user-1') + await collection.tag('session-2', 'user-1') + + await collection.destroy('session-1') + + const sessions = await collection.tagged('user-1') + assert.deepEqual(sessions, [{ id: 'session-2', data: { user: 1 } }]) + }) +}) diff --git a/tests/stores/database_store.spec.ts b/tests/stores/database_store.spec.ts new file mode 100644 index 0000000..36f0520 --- /dev/null +++ b/tests/stores/database_store.spec.ts @@ -0,0 +1,317 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { setTimeout } from 'node:timers/promises' +import { Database } from '@adonisjs/lucid/database' +import { AppFactory } from '@adonisjs/core/factories/app' +import { LoggerFactory } from '@adonisjs/core/factories/logger' +import { EmitterFactory } from '@adonisjs/core/factories/events' + +import { DatabaseStore } from '../../src/stores/database.ts' + +const sessionId = '1234' + +const dbConfig = { + connection: process.env.DB_CONNECTION || 'sqlite', + connections: { + sqlite: { + client: 'better-sqlite3' as const, + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }, + postgres: { + client: 'pg' as const, + connection: { + host: process.env.PG_HOST || '0.0.0.0', + port: Number(process.env.PG_PORT || 5432), + user: process.env.PG_USER || 'postgres', + password: process.env.PG_PASSWORD || 'postgres', + database: process.env.PG_DATABASE || 'session_test', + }, + }, + mysql: { + client: 'mysql2' as const, + connection: { + host: process.env.MYSQL_HOST || '0.0.0.0', + port: Number(process.env.MYSQL_PORT || 3306), + user: process.env.MYSQL_USER || 'root', + password: process.env.MYSQL_PASSWORD || 'root', + database: process.env.MYSQL_DATABASE || 'session_test', + }, + }, + }, +} + +async function createDatabase() { + const app = new AppFactory().create(new URL('./', import.meta.url), () => {}) + await app.init() + + return new Database(dbConfig, new LoggerFactory().create(), new EmitterFactory().create(app)) +} + +test.group('Database store', (group) => { + let db: Database + + group.tap((t) => { + t.skip(!!process.env.NO_DATABASE, 'Database not available') + }) + + group.setup(async () => { + db = await createDatabase() + + await db.connection().schema.createTable('sessions', (table) => { + table.string('id').primary() + table.text('data').notNullable() + table.string('user_id').nullable().index() + table.timestamp('expires_at').notNullable() + }) + }) + + group.each.setup(() => { + return async () => { + await db.from('sessions').delete() + } + }) + + group.teardown(async () => { + await db.connection().schema.dropTableIfExists('sessions') + await db.manager.closeAll() + }) + + test('return null when value is missing', async ({ assert }) => { + const store = new DatabaseStore(db.connection(), '2 hours') + const value = await store.read(sessionId) + assert.isNull(value) + }) + + test('save session data in database', async ({ assert }) => { + const store = new DatabaseStore(db.connection(), '2 hours') + await store.write(sessionId, { message: 'hello-world' }) + + const row = await db.from('sessions').where('id', sessionId).first() + assert.exists(row) + assert.equal(row.id, sessionId) + assert.exists(row.data) + assert.exists(row.expires_at) + }) + + test('read session data from database', async ({ assert }) => { + const store = new DatabaseStore(db.connection(), '2 hours') + await store.write(sessionId, { message: 'hello-world' }) + + const value = await store.read(sessionId) + assert.deepEqual(value, { message: 'hello-world' }) + }) + + test('return null when session data is expired', async ({ assert }) => { + const store = new DatabaseStore(db.connection(), 1) + await store.write(sessionId, { message: 'hello-world' }) + + await setTimeout(2000) + + const value = await store.read(sessionId) + assert.isNull(value) + }).disableTimeout() + + test('delete expired session from database when reading', async ({ assert }) => { + const store = new DatabaseStore(db.connection(), 1) + await store.write(sessionId, { message: 'hello-world' }) + + await setTimeout(2000) + + await store.read(sessionId) + + const row = await db.from('sessions').where('id', sessionId).first() + assert.isNull(row) + }).disableTimeout() + + test('ignore malformed contents', async ({ assert }) => { + const store = new DatabaseStore(db.connection(), '2 hours') + await db + .insertQuery() + .table('sessions') + .insert({ + id: sessionId, + data: 'invalid-json', + expires_at: new Date(Date.now() + 7200000), + }) + + const value = await store.read(sessionId) + assert.isNull(value) + }) + + test('update existing session on write', async ({ assert }) => { + const store = new DatabaseStore(db.connection(), '2 hours') + await store.write(sessionId, { message: 'hello-world' }) + await store.write(sessionId, { message: 'updated' }) + + const value = await store.read(sessionId) + assert.deepEqual(value, { message: 'updated' }) + + const count = await db.from('sessions').count('* as total') + assert.equal(count[0].total, 1) + }) + + test('delete session on destroy', async ({ assert }) => { + const store = new DatabaseStore(db.connection(), '2 hours') + await store.write(sessionId, { message: 'hello-world' }) + await store.destroy(sessionId) + + const row = await db.from('sessions').where('id', sessionId).first() + assert.isNull(row) + }) + + test('update session expiry on touch', async ({ assert }) => { + const store = new DatabaseStore(db.connection(), 10) + await store.write(sessionId, { message: 'hello-world' }) + + const rowBefore = await db.from('sessions').where('id', sessionId).first() + const expiryBefore = new Date(rowBefore.expires_at).getTime() + + await setTimeout(2000) + + await store.touch(sessionId) + + const rowAfter = await db.from('sessions').where('id', sessionId).first() + const expiryAfter = new Date(rowAfter.expires_at).getTime() + + assert.isAbove(expiryAfter, expiryBefore) + }).disableTimeout() + + test('garbage collection cleans up expired sessions', async ({ assert }) => { + // Use gcProbability 100 to always trigger garbage collection + const store = new DatabaseStore(db.connection(), 1, { gcProbability: 100 }) + + // Write a session that will expire + await store.write('expired-session', { message: 'will-expire' }) + + await setTimeout(2000) + + // Write another session (this triggers garbage collection) + await store.write('new-session', { message: 'fresh' }) + + // The expired session should have been cleaned up + const expiredRow = await db.from('sessions').where('id', 'expired-session').first() + assert.isNull(expiredRow) + + // The new session should still exist + const newRow = await db.from('sessions').where('id', 'new-session').first() + assert.exists(newRow) + }).disableTimeout() + + test('garbage collection does not run when disabled', async ({ assert }) => { + const store = new DatabaseStore(db.connection(), 1, { gcProbability: 0 }) + + await store.write('expired-session', { message: 'will-expire' }) + await setTimeout(2000) + await store.write('new-session', { message: 'fresh' }) + + const expiredRow = await db.from('sessions').where('id', 'expired-session').first() + assert.exists(expiredRow) + }).disableTimeout() + + test('tag a session with a user id', async ({ assert }) => { + const store = new DatabaseStore(db.connection(), '2 hours') + + await store.write('session-1', { message: 'hello' }) + await store.tag('session-1', 'user-123') + + const row = await db.from('sessions').where('id', 'session-1').first() + assert.equal(row.user_id, 'user-123') + }) + + test('get sessions tagged with a user id', async ({ assert }) => { + const store = new DatabaseStore(db.connection(), '2 hours') + + await store.write('session-1', { message: 'hello' }) + await store.write('session-2', { message: 'world' }) + await store.write('session-3', { message: 'foo' }) + + await store.tag('session-1', 'user-1') + await store.tag('session-2', 'user-1') + await store.tag('session-3', 'user-2') + + const user1Sessions = await store.tagged('user-1') + assert.sameDeepMembers(user1Sessions, [ + { id: 'session-1', data: { message: 'hello' } }, + { id: 'session-2', data: { message: 'world' } }, + ]) + + const user2Sessions = await store.tagged('user-2') + assert.deepEqual(user2Sessions, [{ id: 'session-3', data: { message: 'foo' } }]) + }) + + test('return empty array when user has no tagged sessions', async ({ assert }) => { + const store = new DatabaseStore(db.connection(), '2 hours') + + const sessions = await store.tagged('unknown-user') + assert.deepEqual(sessions, []) + }) + + test('re-tagging updates user id', async ({ assert }) => { + const store = new DatabaseStore(db.connection(), '2 hours') + + await store.write('session-1', { message: 'hello' }) + await store.tag('session-1', 'user-1') + await store.tag('session-1', 'user-2') + + const row = await db.from('sessions').where('id', 'session-1').first() + assert.equal(row.user_id, 'user-2') + + const user1Sessions = await store.tagged('user-1') + assert.deepEqual(user1Sessions, []) + + const user2Sessions = await store.tagged('user-2') + assert.deepEqual(user2Sessions, [{ id: 'session-1', data: { message: 'hello' } }]) + }) + + test('tagged excludes expired sessions', async ({ assert }) => { + const store = new DatabaseStore(db.connection(), 1, { gcProbability: 0 }) + + await store.write('session-1', { message: 'hello' }) + await store.tag('session-1', 'user-1') + + await setTimeout(2000) + + const sessions = await store.tagged('user-1') + assert.deepEqual(sessions, []) + }).disableTimeout() + + /** + * Simulate what happens during login lifecycle: + * - Session is new (doesnt exist in DB yet) + * - User calls session.tag => create session in database + * - Then commit() calls write() => update data and preserves the rest + */ + test('tag before write creates session and preserves tag', async ({ assert }) => { + const store = new DatabaseStore(db.connection(), '2 hours') + + await store.tag('new-session', 'user-123') + await store.write('new-session', { message: 'hello' }) + + const row = await db.from('sessions').where('id', 'new-session').first() + assert.equal(row.user_id, 'user-123') + + const data = await store.read('new-session') + assert.deepEqual(data, { message: 'hello' }) + }) + + test('tag is preserved after write updates session data', async ({ assert }) => { + const store = new DatabaseStore(db.connection(), '2 hours') + + await store.write('session-1', { message: 'hello' }) + await store.tag('session-1', 'user-123') + + await store.write('session-1', { message: 'updated' }) + + const row = await db.from('sessions').where('id', 'session-1').first() + assert.equal(row.user_id, 'user-123') + }) +}) diff --git a/tests/stores/memory_store.spec.ts b/tests/stores/memory_store.spec.ts index 8fe2c51..83d63af 100644 --- a/tests/stores/memory_store.spec.ts +++ b/tests/stores/memory_store.spec.ts @@ -12,7 +12,10 @@ import { MemoryStore } from '../../src/stores/memory.ts' test.group('Memory store', (group) => { group.each.setup(() => { - return () => MemoryStore.sessions.clear() + return () => { + MemoryStore.sessions.clear() + MemoryStore.tags.clear() + } }) test('return null when session does not exists', async ({ assert }) => { @@ -72,4 +75,69 @@ test.group('Memory store', (group) => { assert.deepEqual(MemoryStore.sessions.get(sessionId), { message: 'hello-world' }) }) + + test('tag a session with a user id', async ({ assert }) => { + const session = new MemoryStore() + + session.write('session-1', { message: 'hello' }) + await session.tag('session-1', 'user-123') + + assert.equal(MemoryStore.tags.get('session-1'), 'user-123') + }) + + test('get sessions tagged with a user id', async ({ assert }) => { + const session = new MemoryStore() + + session.write('session-1', { message: 'hello' }) + session.write('session-2', { message: 'world' }) + session.write('session-3', { message: 'foo' }) + + await session.tag('session-1', 'user-1') + await session.tag('session-2', 'user-1') + await session.tag('session-3', 'user-2') + + const user1Sessions = await session.tagged('user-1') + assert.sameDeepMembers(user1Sessions, [ + { id: 'session-1', data: { message: 'hello' } }, + { id: 'session-2', data: { message: 'world' } }, + ]) + + const user2Sessions = await session.tagged('user-2') + assert.deepEqual(user2Sessions, [{ id: 'session-3', data: { message: 'foo' } }]) + }) + + test('return empty array when user has no tagged sessions', async ({ assert }) => { + const session = new MemoryStore() + + const sessions = await session.tagged('unknown-user') + assert.deepEqual(sessions, []) + }) + + test('destroy cleans up tag reference', async ({ assert }) => { + const session = new MemoryStore() + + session.write('session-1', { message: 'hello' }) + session.tag('session-1', 'user-1') + + session.destroy('session-1') + + assert.isFalse(MemoryStore.tags.has('session-1')) + + const sessions = session.tagged('user-1') + assert.deepEqual(sessions, []) + }) + + test('tagged filters out destroyed sessions', async ({ assert }) => { + const session = new MemoryStore() + + session.write('session-1', { message: 'hello' }) + session.write('session-2', { message: 'world' }) + session.tag('session-1', 'user-1') + session.tag('session-2', 'user-1') + + MemoryStore.sessions.delete('session-1') + + const sessions = session.tagged('user-1') + assert.deepEqual(sessions, [{ id: 'session-2', data: { message: 'world' } }]) + }) }) diff --git a/tests/stores/redis_store.spec.ts b/tests/stores/redis_store.spec.ts index c24d469..f358112 100644 --- a/tests/stores/redis_store.spec.ts +++ b/tests/stores/redis_store.spec.ts @@ -33,7 +33,12 @@ test.group('Redis store', (group) => { group.each.setup(() => { return async () => { - await redis.del(sessionId) + const sessionKeys = await redis.keys('session-*') + const tagKeys = await redis.keys('session_tag:*') + const testKeys = await redis.keys(sessionId) + + const allKeys = [...sessionKeys, ...tagKeys, ...testKeys] + if (allKeys.length > 0) await redis.del(...allKeys) } }) @@ -47,7 +52,7 @@ test.group('Redis store', (group) => { assert.isNull(value) }) - test('save session data in a set', async ({ assert }) => { + test('save session data', async ({ assert }) => { const session = new RedisStore(redis.connection('main'), '2 hours') await session.write(sessionId, { message: 'hello-world' }) @@ -111,4 +116,68 @@ test.group('Redis store', (group) => { const expiryPostTouch = await redis.ttl(sessionId) assert.isAtLeast(expiryPostTouch, 9) }).disableTimeout() + + test('tag a session with a user id', async ({ assert }) => { + const session = new RedisStore(redis.connection('main'), '2 hours') + + await session.write('session-1', { message: 'hello' }) + await session.tag('session-1', 'user-123') + + const members = await redis.smembers('session_tag:user-123') + assert.deepEqual(members, ['session-1']) + }) + + test('get sessions tagged with a user id', async ({ assert }) => { + const session = new RedisStore(redis.connection('main'), '2 hours') + + await session.write('session-1', { message: 'hello' }) + await session.write('session-2', { message: 'world' }) + await session.write('session-3', { message: 'foo' }) + + await session.tag('session-1', 'user-1') + await session.tag('session-2', 'user-1') + await session.tag('session-3', 'user-2') + + const user1Sessions = await session.tagged('user-1') + assert.sameDeepMembers(user1Sessions, [ + { id: 'session-1', data: { message: 'hello' } }, + { id: 'session-2', data: { message: 'world' } }, + ]) + + const user2Sessions = await session.tagged('user-2') + assert.deepEqual(user2Sessions, [{ id: 'session-3', data: { message: 'foo' } }]) + }) + + test('return empty array when user has no tagged sessions', async ({ assert }) => { + const session = new RedisStore(redis.connection('main'), '2 hours') + + const sessions = await session.tagged('unknown-user') + assert.deepEqual(sessions, []) + }) + + test('tagged filters out expired sessions', async ({ assert }) => { + const session = new RedisStore(redis.connection('main'), 1) + + await session.write('session-1', { message: 'hello' }) + await session.tag('session-1', 'user-1') + + await setTimeout(2000) + + const sessions = await session.tagged('user-1') + assert.deepEqual(sessions, []) + }).disableTimeout() + + test('tagged cleans up expired sessions from tag set', async ({ assert }) => { + const session = new RedisStore(redis.connection('main'), 1) + + await session.write('session-1', { message: 'hello' }) + await session.tag('session-1', 'user-1') + + await setTimeout(2000) + + await session.tagged('user-1') + + const members = await redis.smembers('session_tag:user-1') + assert.deepEqual(members, []) + }).disableTimeout() })