diff --git a/.github/COMMIT_CONVENTION.md b/.github/COMMIT_CONVENTION.md deleted file mode 100644 index fc852af..0000000 --- a/.github/COMMIT_CONVENTION.md +++ /dev/null @@ -1,70 +0,0 @@ -## Git Commit Message Convention - -> This is adapted from [Angular's commit convention](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular). - -Using conventional commit messages, we can automate the process of generating the CHANGELOG file. All commits messages will automatically be validated against the following regex. - -``` js -/^(revert: )?(feat|fix|docs|style|refactor|perf|test|workflow|ci|chore|types|build|improvement)((.+))?: .{1,50}/ -``` - -## Commit Message Format -A commit message consists of a **header**, **body** and **footer**. The header has a **type**, **scope** and **subject**: - -> The **scope** is optional - -``` -feat(router): add support for prefix - -Prefix makes it easier to append a path to a group of routes -``` - -1. `feat` is type. -2. `router` is scope and is optional -3. `add support for prefix` is the subject -4. The **body** is followed by a blank line. -5. The optional **footer** can be added after the body, followed by a blank line. - -## Types -Only one type can be used at a time and only following types are allowed. - -- feat -- fix -- docs -- style -- refactor -- perf -- test -- workflow -- ci -- chore -- types -- build - -If a type is `feat`, `fix` or `perf`, then the commit will appear in the CHANGELOG.md file. However if there is any BREAKING CHANGE, the commit will always appear in the changelog. - -### Revert -If the commit reverts a previous commit, it should begin with `revert:`, followed by the header of the reverted commit. In the body it should say: `This reverts commit `., where the hash is the SHA of the commit being reverted. - -## Scope -The scope could be anything specifying place of the commit change. For example: `router`, `view`, `querybuilder`, `database`, `model` and so on. - -## Subject -The subject contains succinct description of the change: - -- use the imperative, present tense: "change" not "changed" nor "changes". -- don't capitalize first letter -- no dot (.) at the end - -## Body - -Just as in the **subject**, use the imperative, present tense: "change" not "changed" nor "changes". -The body should include the motivation for the change and contrast this with previous behavior. - -## Footer - -The footer should contain any information about **Breaking Changes** and is also the place to -reference GitHub issues that this commit **Closes**. - -**Breaking Changes** should start with the word `BREAKING CHANGE:` with a space or two newlines. The rest of the commit message is then used for this. - diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md deleted file mode 100644 index f0c5446..0000000 --- a/.github/CONTRIBUTING.md +++ /dev/null @@ -1,46 +0,0 @@ -# Contributing - -AdonisJS is a community driven project. You are free to contribute in any of the following ways. - -- [Coding style](coding-style) -- [Fix bugs by creating PR's](fix-bugs-by-creating-prs) -- [Share an RFC for new features or big changes](share-an-rfc-for-new-features-or-big-changes) -- [Report security issues](report-security-issues) -- [Be a part of the community](be-a-part-of-community) - -## Coding style - -Majority of AdonisJS core packages are written in Typescript. Having a brief knowledge of Typescript is required to contribute to the core. - -## Fix bugs by creating PR's - -We appreciate every time you report a bug in the framework or related libraries. However, taking time to submit a PR can help us in fixing bugs quickly and ensure a healthy and stable eco-system. - -Go through the following points, before creating a new PR. - -1. Create an issue discussing the bug or short-coming in the framework. -2. Once approved, go ahead and fork the REPO. -3. Make sure to start from the `develop`, since this is the upto date branch. -4. Make sure to keep commits small and relevant. -5. We follow [conventional-commits](https://github.com/conventional-changelog/conventional-changelog) to structure our commit messages. Instead of running `git commit`, you must run `npm commit`, which will show you prompts to create a valid commit message. -6. Once done with all the changes, create a PR against the `develop` branch. - -## Share an RFC for new features or big changes - -Sharing PR's for small changes works great. However, when contributing big features to the framework, it is required to go through the RFC process. - -### What is an RFC? - -RFC stands for **Request for Commits**, a standard process followed by many other frameworks including [Ember](https://github.com/emberjs/rfcs), [yarn](https://github.com/yarnpkg/rfcs) and [rust](https://github.com/rust-lang/rfcs). - -In brief, RFC process allows you to talk about the changes with everyone in the community and get a view of the core team before dedicating your time to work on the feature. - -The RFC proposals are created as Pull Request on [adonisjs/rfcs](https://github.com/adonisjs/rfcs) repo. Make sure to read the README to learn about the process in depth. - -## Report security issues - -All of the security issues, must be reported via [email](mailto:virk@adonisjs.com) and not using any of the public channels. - -## Be a part of community - -We welcome you to participate in [GitHub Discussion](https://github.com/adonisjs/core/discussions) and the AdonisJS [Discord Server](https://discord.gg/vDcEjq6). You are free to ask your questions and share your work or contributions made to AdonisJS eco-system. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index e65000c..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -name: Bug report -about: Report identified bugs ---- - - - -## Prerequisites - -We do our best to reply to all the issues on time. If you will follow the given guidelines, the turn around time will be faster. - -- Lots of raised issues are directly not bugs but instead are design decisions taken by us. -- Make use of our [GH discussions](https://github.com/adonisjs/core/discussions), or [discord server](https://discord.me/adonisjs), if you are not sure that you are reporting a bug. -- Ensure the issue isn't already reported. -- Ensure you are reporting the bug in the correct repo. - -*Delete the above section and the instructions in the sections below before submitting* - -## Package version - - -## Node.js and npm version - - -## Sample Code (to reproduce the issue) - - -## BONUS (a sample repo to reproduce the issue) - diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index abd44a5..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: Feature request -about: Propose changes for adding a new feature ---- - - - -## Prerequisites - -We do our best to reply to all the issues on time. If you will follow the given guidelines, the turn around time will be faster. - -## Consider an RFC - -Please create an [RFC](https://github.com/adonisjs/rfcs) instead, if - -- Feature introduces a breaking change -- Demands lots of time and changes in the current code base. - -*Delete the above section and the instructions in the sections below before submitting* - -## Why this feature is required (specific use-cases will be appreciated)? - - -## Have you tried any other work arounds? - - -## Are you willing to work on it with little guidance? - diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 044e69e..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,28 +0,0 @@ - - -## Proposed changes - -Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue. - -## Types of changes - -What types of changes does your code introduce? - -_Put an `x` in the boxes that apply_ - -- [ ] Bugfix (non-breaking change which fixes an issue) -- [ ] New feature (non-breaking change which adds functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - -## Checklist - -_Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code._ - -- [ ] I have read the [CONTRIBUTING](https://github.com/adonisjs/session/blob/master/.github/CONTRIBUTING.md) doc -- [ ] Lint and unit tests pass locally with my changes -- [ ] I have added tests that prove my fix is effective or that my feature works. -- [ ] I have added necessary documentation (if appropriate) - -## Further comments - -If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... diff --git a/.github/labels.json b/.github/labels.json new file mode 100644 index 0000000..ba001c6 --- /dev/null +++ b/.github/labels.json @@ -0,0 +1,170 @@ +[ + { + "name": "Priority: Critical", + "color": "ea0056", + "description": "The issue needs urgent attention", + "aliases": [] + }, + { + "name": "Priority: High", + "color": "5666ed", + "description": "Look into this issue before picking up any new work", + "aliases": [] + }, + { + "name": "Priority: Medium", + "color": "f4ff61", + "description": "Try to fix the issue for the next patch/minor release", + "aliases": [] + }, + { + "name": "Priority: Low", + "color": "87dfd6", + "description": "Something worth considering, but not a top priority for the team", + "aliases": [] + }, + { + "name": "Semver: Alpha", + "color": "008480", + "description": "Will make it's way to the next alpha version of the package", + "aliases": [] + }, + { + "name": "Semver: Major", + "color": "ea0056", + "description": "Has breaking changes", + "aliases": [] + }, + { + "name": "Semver: Minor", + "color": "fbe555", + "description": "Mainly new features and improvements", + "aliases": [] + }, + { + "name": "Semver: Next", + "color": "5666ed", + "description": "Will make it's way to the bleeding edge version of the package", + "aliases": [] + }, + { + "name": "Semver: Patch", + "color": "87dfd6", + "description": "A bug fix", + "aliases": [] + }, + { + "name": "Status: Abandoned", + "color": "ffffff", + "description": "Dropped and not into consideration", + "aliases": ["wontfix"] + }, + { + "name": "Status: Accepted", + "color": "e5fbf2", + "description": "The proposal or the feature has been accepted for the future versions", + "aliases": [] + }, + { + "name": "Status: Blocked", + "color": "ea0056", + "description": "The work on the issue or the PR is blocked. Check comments for reasoning", + "aliases": [] + }, + { + "name": "Status: Completed", + "color": "008672", + "description": "The work has been completed, but not released yet", + "aliases": [] + }, + { + "name": "Status: In Progress", + "color": "73dbc4", + "description": "Still banging the keyboard", + "aliases": ["in progress"] + }, + { + "name": "Status: On Hold", + "color": "f4ff61", + "description": "The work was started earlier, but is on hold now. Check comments for reasoning", + "aliases": ["On Hold"] + }, + { + "name": "Status: Review Needed", + "color": "fbe555", + "description": "Review from the core team is required before moving forward", + "aliases": [] + }, + { + "name": "Status: Awaiting More Information", + "color": "89f8ce", + "description": "Waiting on the issue reporter or PR author to provide more information", + "aliases": [] + }, + { + "name": "Status: Need Contributors", + "color": "7057ff", + "description": "Looking for contributors to help us move forward with this issue or PR", + "aliases": [] + }, + { + "name": "Type: Bug", + "color": "ea0056", + "description": "The issue has indentified a bug", + "aliases": ["bug"] + }, + { + "name": "Type: Security", + "color": "ea0056", + "description": "Spotted security vulnerability and is a top priority for the core team", + "aliases": [] + }, + { + "name": "Type: Duplicate", + "color": "00837e", + "description": "Already answered or fixed previously", + "aliases": ["duplicate"] + }, + { + "name": "Type: Enhancement", + "color": "89f8ce", + "description": "Improving an existing feature", + "aliases": ["enhancement"] + }, + { + "name": "Type: Feature Request", + "color": "483add", + "description": "Request to add a new feature to the package", + "aliases": [] + }, + { + "name": "Type: Invalid", + "color": "dbdbdb", + "description": "Doesn't really belong here. Maybe use discussion threads?", + "aliases": ["invalid"] + }, + { + "name": "Type: Question", + "color": "eceafc", + "description": "Needs clarification", + "aliases": ["help wanted", "question"] + }, + { + "name": "Type: Documentation Change", + "color": "7057ff", + "description": "Documentation needs some improvements", + "aliases": ["documentation"] + }, + { + "name": "Type: Dependencies Update", + "color": "00837e", + "description": "Bump dependencies", + "aliases": ["dependencies"] + }, + { + "name": "Good First Issue", + "color": "008480", + "description": "Want to contribute? Just filter by this label", + "aliases": ["good first issue"] + } +] diff --git a/.github/stale.yml b/.github/stale.yml index 7a6a571..f767674 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -6,10 +6,10 @@ daysUntilClose: 7 # Issues with these labels will never be considered stale exemptLabels: - - "Type: Security" + - 'Type: Security' # Label to use when marking an issue as stale -staleLabel: "Status: Abandoned" +staleLabel: 'Status: Abandoned' # Comment to post when marking an issue as stale. Set to `false` to disable markComment: > diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..8c26a40 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,61 @@ +name: test +on: + - push + - pull_request +jobs: + lint: + uses: adonisjs/.github/.github/workflows/lint.yml@main + + typecheck: + uses: adonisjs/.github/.github/workflows/typecheck.yml@main + + test_linux: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20.10.0, 21.x] + services: + redis: + image: redis + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - name: Install + run: npm install + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run tests + run: npm test + env: + REDIS_HOST: 0.0.0.0 + REDIS_PORT: 6379 + + test_windows: + runs-on: windows-latest + strategy: + matrix: + node-version: [20.10.0, 21.x] + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - name: Install + run: npm install + - name: Install Playwright Browsers + run: npx playwright install --with-deps + - name: Run tests + run: npm test + env: + NO_REDIS: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 134f3f2..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: test -on: - - push - - pull_request -jobs: - linux: - runs-on: ubuntu-latest - services: - redis: - image: redis - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 6379:6379 - strategy: - matrix: - node-version: - - 14.15.4 - - 17.x - steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - name: Install - run: npm install - - name: Run tests - run: npm test - env: - REDIS_HOST: 0.0.0.0 - REDIS_PORT: 6379 diff --git a/.husky/commit-msg b/.husky/commit-msg index 4654c12..988eb59 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1,3 +1,4 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" -HUSKY_GIT_PARAMS=$1 node ./node_modules/@adonisjs/mrm-preset/validate-commit/conventional/validate.js +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx --no -- commitlint --edit diff --git a/README.md b/README.md index 475155b..15ba8ee 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,33 @@ -
- -
+# @adonisjs/session
-
-

Sessions

-

This package adds support for sessions to AdonisJS

-
+[![gh-workflow-image]][gh-workflow-url] [![npm-image]][npm-url] ![][typescript-image] [![license-image]][license-url] -
- -
+## Introduction +Use sessions in your AdonisJS applications with a unified API to persist session data across different data-stores. Has inbuilt support for **cookie**, **files**, and **redis** drivers. -[![gh-workflow-image]][gh-workflow-url] [![typescript-image]][typescript-url] [![npm-image]][npm-url] [![license-image]][license-url] [![synk-image]][synk-url] +## Official Documentation +The documentation is available on the [AdonisJS website](https://docs.adonisjs.com/guides/sessions) -
+## Contributing +One of the primary goals of AdonisJS is to have a vibrant community of users and contributors who believes in the principles of the framework. -
-

- - Website - - | - - Guides - - | - - Contributing - -

-
+We encourage you to read the [contribution guide](https://github.com/adonisjs/.github/blob/main/docs/CONTRIBUTING.md) before contributing to the framework. -
- Built with ❤︎ by Harminder Virk -
+## Code of Conduct +In order to ensure that the AdonisJS community is welcoming to all, please review and abide by the [Code of Conduct](https://github.com/adonisjs/.github/blob/main/docs/CODE_OF_CONDUCT.md). -[gh-workflow-image]: https://img.shields.io/github/workflow/status/adonisjs/session/test?style=for-the-badge -[gh-workflow-url]: https://github.com/adonisjs/session/actions/workflows/test.yml "Github action" +## License +AdonisJS session is open-sourced software licensed under the [MIT license](LICENSE.md). -[typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript -[typescript-url]: "typescript" +[gh-workflow-image]: https://img.shields.io/github/actions/workflow/status/adonisjs/session/checks.yml?style=for-the-badge +[gh-workflow-url]: https://github.com/adonisjs/session/actions/workflows/checks.yml "Github action" [npm-image]: https://img.shields.io/npm/v/@adonisjs/session/latest.svg?style=for-the-badge&logo=npm [npm-url]: https://www.npmjs.com/package/@adonisjs/session/v/latest "npm" -[license-image]: https://img.shields.io/npm/l/@adonisjs/session?color=blueviolet&style=for-the-badge -[license-url]: LICENSE.md "license" +[typescript-image]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript -[synk-image]: https://img.shields.io/snyk/vulnerabilities/github/adonisjs/session?label=Synk%20Vulnerabilities&style=for-the-badge -[synk-url]: https://snyk.io/test/github/adonisjs/session?targetFile=package.json "synk" +[license-url]: LICENSE.md +[license-image]: https://img.shields.io/github/license/adonisjs/session?style=for-the-badge diff --git a/adonis-typings/container.ts b/adonis-typings/container.ts deleted file mode 100644 index fd32c56..0000000 --- a/adonis-typings/container.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * @adonisjs/session - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare module '@ioc:Adonis/Core/Application' { - import { SessionManagerContract } from '@ioc:Adonis/Addons/Session' - - interface ContainerBindings { - 'Adonis/Addons/Session': SessionManagerContract - } -} diff --git a/adonis-typings/context.ts b/adonis-typings/context.ts deleted file mode 100644 index 86a0deb..0000000 --- a/adonis-typings/context.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * @adonisjs/session - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare module '@ioc:Adonis/Core/HttpContext' { - import { SessionContract } from '@ioc:Adonis/Addons/Session' - - interface HttpContextContract { - session: SessionContract - } -} diff --git a/adonis-typings/index.ts b/adonis-typings/index.ts deleted file mode 100644 index dffc5ad..0000000 --- a/adonis-typings/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* - * @adonisjs/session - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// -/// -/// -/// diff --git a/adonis-typings/session.ts b/adonis-typings/session.ts deleted file mode 100644 index dfa1e73..0000000 --- a/adonis-typings/session.ts +++ /dev/null @@ -1,319 +0,0 @@ -/* - * @adonisjs/session - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare module '@ioc:Adonis/Addons/Session' { - import { CookieOptions } from '@ioc:Adonis/Core/Response' - import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' - import { ApplicationContract } from '@ioc:Adonis/Core/Application' - - /** - * Shape of session config. - */ - export interface SessionConfig { - /** - * Enable/disable session for the entire application lifecycle - */ - enabled: boolean - - /** - * The driver in play - */ - driver: string - - /** - * Cookie name. - */ - cookieName: string - - /** - * Clear session when browser closes - */ - clearWithBrowser: boolean - - /** - * Age of session cookie - */ - age: string | number - - /** - * Config for the cookie driver and also the session id - * cookie - */ - cookie: Omit, 'maxAge' | 'expires'> - - /** - * Config for the file driver - */ - file?: { - location: string - } - - /** - * The redis connection to use from the `config/redis` file - */ - redisConnection?: string - } - - /** - * Shape of a driver that every session driver must have - */ - export interface SessionDriverContract { - read(sessionId: string): Promise | null> | Record | null - write(sessionId: string, values: Record): Promise | void - destroy(sessionId: string): Promise | void - touch(sessionId: string): Promise | void - } - - /** - * The callback to be passed to the `extend` method. It is invoked - * for each request (if extended driver is in use). - */ - export type ExtendCallback = ( - manager: SessionManagerContract, - config: SessionConfig, - ctx: HttpContextContract - ) => SessionDriverContract - - /** - * The values allowed by the `session.put` method - */ - export type AllowedSessionValues = string | boolean | number | object | Date | Array - - /** - * Store used for storing session values + flash messages - */ - export interface StoreContract { - /** - * A boolean to know if store is empty - */ - isEmpty: boolean - - /** - * Set value for a key - */ - set(key: string, value: AllowedSessionValues): void - - /** - * Increment value for a key. An exception is raised when existing - * value is not a number - */ - increment(key: string, steps?: number): void - - /** - * Decrement value for a key. An exception is raised when existing - * value is not a number - */ - decrement(key: string, steps?: number): void - - /** - * Replace existing values with new ones - */ - update(values: Record): void - - /** - * Merge values with existing ones - */ - merge(values: Record): any - - /** - * Get all values - */ - all(): any - - /** - * Get value for a given key or use the default value - */ - get(key: string, defaultValue?: any): any - - /** - * Find if a value exists. Optionally you can also check arrays - * to have length too - */ - has(key: string, checkForArraysLength?: boolean): boolean - - /** - * Unset value - */ - unset(key: string): void - - /** - * Clear all values - */ - clear(): void - - /** - * Read value and then unset it at the same time - */ - pull(key: string, defaultValue?: any): any - - /** - * Convert store values toObject - */ - toObject(): any - - /** - * Convert store values to toJSON - */ - toJSON(): any - } - - /** - * Shape of the actual session store - */ - export interface SessionContract { - /** - * Has the store being initiated - */ - initiated: boolean - - /** - * Is session store readonly. Will be during Websockets - * request - */ - readonly: boolean - - /** - * Is session just created or we read received the - * session id from the request - */ - fresh: boolean - - /** - * Session id - */ - sessionId: string - - /** - * Previous request flash messages - */ - flashMessages: StoreContract - - /** - * Flash messages that will be sent in the current - * request response - */ - responseFlashMessages: StoreContract - - /** - * Initiate session store - */ - initiate(readonly: boolean): Promise - - /** - * Commit session mutations - */ - commit(): Promise - - /** - * Re-generate session id. This help avoid session - * replay attacks. - */ - regenerate(): void - - /** - * Store API - */ - has(key: string): boolean - put(key: string, value: AllowedSessionValues): void - get(key: string, defaultValue?: any): any - all(): any - forget(key: string): void - pull(key: string, defaultValue?: any): any - increment(key: string, steps?: number): any - decrement(key: string, steps?: number): any - clear(): void - - /** - * Flash a key-value pair - */ - flash(values: { [key: string]: AllowedSessionValues }): void - flash(key: string, value: AllowedSessionValues): void - - /** - * Flash request body - */ - flashAll(): void - - /** - * Flash selected keys from the request body - */ - flashOnly(keys: string[]): void - - /** - * Omit selected keys from the request data and flash - * the rest of values - */ - flashExcept(keys: string[]): void - - /** - * Reflash existing flash messages - */ - reflash(): void - - /** - * Reflash selected keys from the existing flash messages - */ - reflashOnly(keys: string[]): void - - /** - * Omit selected keys from the existing flash messages - * and flash the rest of values - */ - reflashExcept(keys: string[]): void - } - - /** - * SessionClient exposes the API to set session data as a client - */ - export interface SessionClientContract extends StoreContract { - /** - * Find if the sessions are enabled - */ - isEnabled(): boolean - - /** - * Flash messages store to set flash messages - */ - flashMessages: StoreContract - - /** - * Load session data from the driver - */ - load(cookies: Record): Promise<{ - session: Record - flashMessages: Record | null - }> - - /** - * Commits the session data to the session store and returns - * the session id and cookie name for it to be accessible - * by the server - */ - commit(): Promise<{ cookieName: string; sessionId: string; signedSessionId: string }> - - /** - * Forget the session data. - */ - forget(): Promise - } - - /** - * Session manager shape - */ - export interface SessionManagerContract { - isEnabled(): boolean - application: ApplicationContract - client(): SessionClientContract - create(ctx: HttpContextContract): SessionContract - extend(driver: string, callback: ExtendCallback): void - } - - const Session: SessionManagerContract - export default Session -} diff --git a/adonis-typings/tests.ts b/adonis-typings/tests.ts deleted file mode 100644 index 33e5697..0000000 --- a/adonis-typings/tests.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - * @adonisjs/session - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import '@japa/api-client' -import { InspectOptions } from 'util' -import { AllowedSessionValues, SessionClientContract } from '@ioc:Adonis/Addons/Session' - -declare module '@japa/api-client' { - export interface ApiRequest { - sessionClient: SessionClientContract - - /** - * Send session values in the request - */ - session(session: Record): this - - /** - * Send flash messages in the request - */ - flashMessages(messages: Record): this - } - - export interface ApiResponse { - /** - * A copy of session data loaded from the driver - */ - sessionJar: { - session: Record - flashMessages: Record | null - } - - /** - * Get session data - */ - session(): Record - - /** - * Dump session - */ - dumpSession(options?: InspectOptions): this - - /** - * Get flash messages set by the server - */ - flashMessages(): Record - - /** - * Assert response to contain a given session and optionally - * has the expected value - */ - assertSession(key: string, value?: any): void - - /** - * Assert response to not contain a given session - */ - assertSessionMissing(key: string): void - - /** - * Assert response to contain a given flash message and optionally - * has the expected value - */ - assertFlashMessage(key: string, value?: any): void - - /** - * Assert response to not contain a given session - */ - assertFlashMissing(key: string): void - } -} diff --git a/bin/japaTypes.ts b/bin/japaTypes.ts deleted file mode 100644 index d42cac6..0000000 --- a/bin/japaTypes.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Assert } from '@japa/assert' - -declare module '@japa/runner' { - interface TestContext { - assert: Assert - } -} diff --git a/bin/test.ts b/bin/test.ts index 5aba7ce..1777bb0 100644 --- a/bin/test.ts +++ b/bin/test.ts @@ -1,7 +1,7 @@ import { assert } from '@japa/assert' -import { specReporter } from '@japa/spec-reporter' -import { runFailedTests } from '@japa/run-failed-tests' -import { processCliArgs, configure, run } from '@japa/runner' +import { snapshot } from '@japa/snapshot' +import { fileSystem } from '@japa/file-system' +import { processCLIArgs, configure, run } from '@japa/runner' /* |-------------------------------------------------------------------------- @@ -16,14 +16,11 @@ import { processCliArgs, configure, run } from '@japa/runner' | | Please consult japa.dev/runner-config for the config docs. */ +processCLIArgs(process.argv.slice(2)) configure({ - ...processCliArgs(process.argv.slice(2)), - ...{ - files: ['test/**/*.spec.ts'], - plugins: [assert(), runFailedTests()], - reporters: [specReporter()], - importer: (filePath: string) => import(filePath), - }, + files: ['tests/**/*.spec.ts'], + plugins: [assert(), fileSystem(), snapshot()], + forceExit: true, }) /* diff --git a/config.ts b/config.ts deleted file mode 100644 index e4e8077..0000000 --- a/config.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @adonisjs/session - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { SessionConfig } from '@ioc:Adonis/Addons/Session' - -/** - * Helper to define session config - */ -export function sessionConfig(config: Config): Config { - return config -} diff --git a/configure.ts b/configure.ts new file mode 100644 index 0000000..028e0d7 --- /dev/null +++ b/configure.ts @@ -0,0 +1,54 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type Configure from '@adonisjs/core/commands/configure' +import { stubsRoot } from './stubs/main.js' + +/** + * Configures the package + */ +export async function configure(command: Configure) { + const codemods = await command.createCodemods() + + /** + * Publish config file + */ + await codemods.makeUsingStub(stubsRoot, 'config/session.stub', {}) + + /** + * Define environment variables + */ + await codemods.defineEnvVariables({ SESSION_DRIVER: 'cookie' }) + + /** + * Define environment variables validations + */ + await codemods.defineEnvValidations({ + variables: { + SESSION_DRIVER: `Env.schema.enum(['cookie', 'memory'] as const)`, + }, + leadingComment: 'Variables for configuring session package', + }) + + /** + * Register middleware + */ + await codemods.registerMiddleware('router', [ + { + path: '@adonisjs/session/session_middleware', + }, + ]) + + /** + * Register provider + */ + await codemods.updateRcFile((rcFile) => { + rcFile.addProvider('@adonisjs/session/session_provider') + }) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..561075b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +version: '3.4' + +services: + redis: + image: redis:alpine + ports: + - 6379:6379 + command: redis-server --appendonly yes + + redis-insight: + image: redislabs/redisinsight:latest + ports: + - 8001:8001 + environment: + - REDIS_URI=redis://redis:6379 diff --git a/factories/main.ts b/factories/main.ts new file mode 100644 index 0000000..556c554 --- /dev/null +++ b/factories/main.ts @@ -0,0 +1,10 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +export { SessionMiddlewareFactory } from './session_middleware_factory.js' diff --git a/factories/session_middleware_factory.ts b/factories/session_middleware_factory.ts new file mode 100644 index 0000000..87be293 --- /dev/null +++ b/factories/session_middleware_factory.ts @@ -0,0 +1,69 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Emitter } from '@adonisjs/core/events' +import { AppFactory } from '@adonisjs/core/factories/app' +import type { ApplicationService, EventsList } from '@adonisjs/core/types' + +import { defineConfig } from '../index.js' +import SessionMiddleware from '../src/session_middleware.js' +import type { SessionConfig, SessionStoreFactory } from '../src/types.js' + +/** + * Exposes the API to create an instance of the session middleware + * without additional plumbing + */ +export class SessionMiddlewareFactory { + #config: Partial & { + store: string + stores: Record + } = { + store: 'memory', + stores: {}, + } + + #emitter?: Emitter + + #getApp() { + return new AppFactory().create(new URL('./', import.meta.url), () => {}) as ApplicationService + } + + #getEmitter() { + return this.#emitter || new Emitter(this.#getApp()) + } + + /** + * Merge custom options + */ + merge(options: { + config?: Partial & { + store: string + stores: Record + } + emitter?: Emitter + }) { + if (options.config) { + this.#config = options.config + } + + if (options.emitter) { + this.#emitter = options.emitter + } + + return this + } + + /** + * Creates an instance of the session middleware + */ + async create() { + const config = await defineConfig(this.#config).resolver(this.#getApp()) + return new SessionMiddleware(config, this.#getEmitter()) + } +} diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..a0a549e --- /dev/null +++ b/index.ts @@ -0,0 +1,13 @@ +/** + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +export * as errors from './src/errors.js' +export { configure } from './configure.js' +export { stubsRoot } from './stubs/main.js' +export { defineConfig, stores } from './src/define_config.js' diff --git a/instructions.md b/instructions.md deleted file mode 100644 index 98742b8..0000000 --- a/instructions.md +++ /dev/null @@ -1,10 +0,0 @@ -The package has been configured successfully. The session configuration stored inside `config/session.ts` file relies on the following environment variables and hence we recommend validating them. - -Open the `env.ts` file and paste the following code inside the `Env.rules` object. - -```ts -SESSION_DRIVER: Env.schema.string() -``` - -- Here we expect the `SESSION_DRIVER` environment variable to be always present -- And should be a valid string diff --git a/package.json b/package.json index 673670c..204f604 100644 --- a/package.json +++ b/package.json @@ -1,167 +1,170 @@ { "name": "@adonisjs/session", - "version": "6.4.0", "description": "Session provider for AdonisJS", - "typings": "./build/adonis-typings/index.d.ts", - "main": "build/providers/SessionProvider.js", + "version": "7.0.0-15", + "engines": { + "node": ">=18.16.0" + }, + "main": "build/index.js", + "type": "module", "files": [ - "build/adonis-typings", - "build/providers", - "build/src", - "build/config.js", - "build/config.d.ts", - "build/templates", - "build/instructions.md" + "build", + "!build/bin", + "!build/tests", + "!build/tests_helpers" ], - "dependencies": { - "@poppinss/utils": "^5.0.0", - "fs-extra": "^10.1.0" - }, - "peerDependencies": { - "@adonisjs/core": "^5.8.0" - }, - "devDependencies": { - "@adonisjs/core": "^5.8.6", - "@adonisjs/mrm-preset": "^5.0.3", - "@adonisjs/redis": "^7.3.1", - "@adonisjs/require-ts": "^2.0.12", - "@japa/assert": "^1.3.5", - "@japa/preset-adonis": "^1.1.1", - "@japa/run-failed-tests": "^1.0.8", - "@japa/runner": "^2.1.1", - "@japa/spec-reporter": "^1.2.0", - "@poppinss/dev-utils": "^2.0.3", - "@types/node": "^18.7.15", - "@types/supertest": "^2.0.12", - "commitizen": "^4.2.5", - "copyfiles": "^2.4.1", - "cz-conventional-changelog": "^3.3.0", - "del-cli": "^5.0.0", - "eslint": "^8.23.0", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-adonis": "^2.1.0", - "eslint-plugin-prettier": "^4.2.1", - "github-label-sync": "^2.2.0", - "husky": "^8.0.1", - "mrm": "^4.1.0", - "np": "^7.6.2", - "prettier": "^2.7.1", - "set-cookie-parser": "^2.5.1", - "supertest": "^6.2.4", - "typescript": "^4.8.2" + "exports": { + ".": "./build/index.js", + "./factories": "./build/factories/main.js", + "./session_provider": "./build/providers/session_provider.js", + "./session_middleware": "./build/src/session_middleware.js", + "./plugins/edge": "./build/src/plugins/edge.js", + "./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" }, "scripts": { - "mrm": "mrm --preset=@adonisjs/mrm-preset", "pretest": "npm run lint", - "test": "node -r @adonisjs/require-ts/build/register ./bin/test.ts", - "clean": "del build", - "compile": "npm run lint && npm run clean && tsc", - "copyfiles": "copyfiles \"templates/**/*.txt\" \"instructions.md\" build", - "build": "npm run compile && npm run copyfiles", - "commit": "git-cz", - "release": "np --message=\"chore(release): %s\"", - "version": "npm run build", + "test": "cross-env NODE_DEBUG=adonisjs:session c8 npm run quick:test", + "clean": "del-cli build", + "typecheck": "tsc --noEmit", + "copy:templates": "copyfiles \"stubs/**/*.stub\" --up=\"1\" build", + "precompile": "npm run lint && npm run clean", + "compile": "tsup-node && tsc --emitDeclarationOnly --declaration", + "postcompile": "npm run copy:templates", + "build": "npm run compile", "prepublishOnly": "npm run build", "lint": "eslint . --ext=.ts", "format": "prettier --write .", - "sync-labels": "github-label-sync --labels ./node_modules/@adonisjs/mrm-preset/gh-labels.json adonisjs/session" + "release": "np", + "version": "npm run build", + "sync-labels": "github-label-sync --labels .github/labels.json adonisjs/session", + "quick:test": "node --enable-source-maps --loader=ts-node/esm bin/test.ts" + }, + "devDependencies": { + "@adonisjs/assembler": "^7.0.0", + "@adonisjs/core": "^6.2.0", + "@adonisjs/eslint-config": "^1.2.1", + "@adonisjs/prettier-config": "^1.2.1", + "@adonisjs/redis": "^8.0.0", + "@adonisjs/tsconfig": "^1.2.1", + "@japa/api-client": "^2.0.2", + "@japa/assert": "^2.1.0", + "@japa/browser-client": "^2.0.2", + "@japa/file-system": "^2.1.1", + "@japa/plugin-adonisjs": "^3.0.0", + "@japa/runner": "^3.1.1", + "@japa/snapshot": "^2.0.4", + "@swc/core": "^1.3.102", + "@types/node": "^20.10.7", + "@types/set-cookie-parser": "^2.4.7", + "@types/supertest": "^6.0.2", + "@vinejs/vine": "^1.7.0", + "c8": "^9.0.0", + "copyfiles": "^2.4.1", + "cross-env": "^7.0.3", + "del-cli": "^5.0.0", + "edge.js": "^6.0.1", + "eslint": "^8.56.0", + "get-port": "^7.0.0", + "github-label-sync": "^2.3.1", + "husky": "^8.0.3", + "np": "^9.2.0", + "playwright": "^1.40.1", + "prettier": "^3.1.1", + "set-cookie-parser": "^2.6.0", + "supertest": "^6.3.3", + "ts-node": "^10.9.2", + "tsup": "^8.0.1", + "typescript": "^5.3.3" }, + "dependencies": { + "@poppinss/utils": "^6.7.0" + }, + "peerDependencies": { + "@adonisjs/core": "^6.2.0", + "@adonisjs/redis": "^8.0.0", + "@japa/api-client": "^2.0.2", + "@japa/browser-client": "^2.0.2", + "edge.js": "^6.0.1" + }, + "peerDependenciesMeta": { + "@adonisjs/redis": { + "optional": true + }, + "edge.js": { + "optional": true + }, + "@japa/api-client": { + "optional": true + }, + "@japa/browser-client": { + "optional": true + } + }, + "author": "virk,adonisjs", + "license": "MIT", + "homepage": "https://github.com/adonisjs/adonis-session#readme", "repository": { "type": "git", "url": "git+https://github.com/adonisjs/adonis-session.git" }, + "bugs": { + "url": "https://github.com/adonisjs/adonis-session/issues" + }, "keywords": [ "session", "adonisjs" ], - "author": "virk,adonisjs", - "license": "MIT", - "bugs": { - "url": "https://github.com/adonisjs/adonis-session/issues" + "eslintConfig": { + "extends": "@adonisjs/eslint-config/package" }, - "homepage": "https://github.com/adonisjs/adonis-session#readme", - "nyc": { - "exclude": [ - "test" - ], - "extension": [ - ".ts" + "prettier": "@adonisjs/prettier-config", + "commitlint": { + "extends": [ + "@commitlint/config-conventional" ] }, - "husky": { - "hooks": { - "commit-msg": "node ./node_modules/@adonisjs/mrm-preset/validateCommit/conventional/validate.js" - } - }, - "config": { - "commitizen": { - "path": "cz-conventional-changelog" - } - }, "publishConfig": { - "tag": "latest", - "access": "public" - }, - "adonisjs": { - "instructionsMd": "./build/instructions.md", - "templates": { - "config": [ - "session.txt" - ] - }, - "env": { - "SESSION_DRIVER": "cookie" - }, - "types": "@adonisjs/session", - "providers": [ - "@adonisjs/session" - ] + "access": "public", + "tag": "next" }, "np": { - "contents": ".", + "message": "chore(release): %s", + "tag": "next", + "branch": "main", "anyBranch": false }, - "mrmConfig": { - "core": true, - "license": "MIT", - "services": [ - "github-actions" + "c8": { + "reporter": [ + "text", + "html" ], - "minNodeVersion": "14.15.4", - "probotApps": [ - "stale", - "lock" - ], - "runGhActionsOnWindows": false + "exclude": [ + "tests/**", + "stubs/**", + "factories/**", + "bin/**" + ] }, - "eslintConfig": { - "extends": [ - "plugin:adonis/typescriptPackage", - "prettier" - ], - "plugins": [ - "prettier" + "tsup": { + "entry": [ + "./index.ts", + "./factories/main.ts", + "./providers/session_provider.ts", + "./src/session_middleware.ts", + "./src/types.ts", + "./src/plugins/edge.ts", + "./src/plugins/japa/api_client.ts", + "./src/plugins/japa/browser_client.ts", + "./src/client.ts" ], - "rules": { - "prettier/prettier": [ - "error", - { - "endOfLine": "auto" - } - ] - } - }, - "eslintIgnore": [ - "build" - ], - "prettier": { - "trailingComma": "es5", - "semi": false, - "singleQuote": true, - "useTabs": false, - "quoteProps": "consistent", - "bracketSpacing": true, - "arrowParens": "always", - "printWidth": 100 + "outDir": "./build", + "clean": true, + "format": "esm", + "dts": false, + "sourcemap": true, + "target": "esnext" } } diff --git a/providers/SessionProvider.ts b/providers/SessionProvider.ts deleted file mode 100644 index cf6d601..0000000 --- a/providers/SessionProvider.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * @adonisjs/session - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { ApplicationContract } from '@ioc:Adonis/Core/Application' - -/** - * Session provider for AdonisJS - */ -export default class SessionProvider { - constructor(protected app: ApplicationContract) {} - public static needsApplication = true - - /** - * Register Session Manager - */ - public register(): void { - this.app.container.singleton('Adonis/Addons/Session', () => { - const { SessionManager } = require('../src/SessionManager') - return new SessionManager(this.app, this.app.config.get('session', {})) - }) - } - - /** - * Register bindings for tests - */ - protected registerTestsBindings() { - this.app.container.withBindings( - [ - 'Japa/Preset/ApiRequest', - 'Japa/Preset/ApiResponse', - 'Japa/Preset/ApiClient', - 'Adonis/Addons/Session', - ], - (ApiRequest, ApiResponse, ApiClient, Session) => { - const { defineTestsBindings } = require('../src/Bindings/Tests') - defineTestsBindings(ApiRequest, ApiResponse, ApiClient, Session) - } - ) - } - - /** - * Register server bindings - */ - protected registerServerBindings() { - this.app.container.withBindings( - ['Adonis/Core/Server', 'Adonis/Core/HttpContext', 'Adonis/Addons/Session'], - (Server, HttpContext, Session) => { - const { defineServerBindings } = require('../src/Bindings/Server') - defineServerBindings(HttpContext, Server, Session) - } - ) - } - - public boot(): void { - this.registerServerBindings() - this.registerTestsBindings() - } -} diff --git a/providers/session_provider.ts b/providers/session_provider.ts new file mode 100644 index 0000000..6a62360 --- /dev/null +++ b/providers/session_provider.ts @@ -0,0 +1,75 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { configProvider } from '@adonisjs/core' +import { RuntimeException } from '@poppinss/utils' +import type { ApplicationService } from '@adonisjs/core/types' + +import type { Session } from '../src/session.js' +import SessionMiddleware from '../src/session_middleware.js' + +/** + * Events emitted by the session class + */ +declare module '@adonisjs/core/types' { + interface EventsList { + 'session:initiated': { session: Session } + 'session:committed': { session: Session } + 'session:migrated': { fromSessionId: string; toSessionId: string; session: Session } + } +} + +/** + * Session provider configures the session management inside an + * AdonisJS application + */ +export default class SessionProvider { + constructor(protected app: ApplicationService) {} + + /** + * Registers edge plugin when edge is installed + * in the user application. + */ + protected async registerEdgePlugin() { + if (this.app.usingEdgeJS) { + const edge = await import('edge.js') + const { edgePluginSession } = await import('../src/plugins/edge.js') + edge.default.use(edgePluginSession) + } + } + + /** + * Registering muddleware + */ + register() { + this.app.container.singleton(SessionMiddleware, async (resolver) => { + const sessionConfigProvider = this.app.config.get('session', {}) + + /** + * 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' + ) + } + + const emitter = await resolver.make('emitter') + return new SessionMiddleware(config, emitter) + }) + } + + /** + * Adding edge tags (if edge is installed) + */ + async boot() { + await this.registerEdgePlugin() + } +} diff --git a/src/Bindings/Server.ts b/src/Bindings/Server.ts deleted file mode 100644 index 56d02ef..0000000 --- a/src/Bindings/Server.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * @adonisjs/session - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// - -import { ServerContract } from '@ioc:Adonis/Core/Server' -import { SessionManagerContract } from '@ioc:Adonis/Addons/Session' -import { HttpContextConstructorContract } from '@ioc:Adonis/Core/HttpContext' - -/** - * Share "session" with the HTTP context. Define hooks to initiate and - * commit session when sessions are enabled. - */ -export function defineServerBindings( - HttpContext: HttpContextConstructorContract, - Server: ServerContract, - Session: SessionManagerContract -) { - /** - * Sharing session with the context - */ - HttpContext.getter( - 'session', - function session() { - return Session.create(this) - }, - true - ) - - /** - * Do not register hooks when sessions are disabled - */ - if (!Session.isEnabled()) { - return - } - - /** - * Initiate session store - */ - Server.hooks.before(async (ctx) => { - await ctx.session.initiate(false) - }) - - /** - * Commit store mutations - */ - Server.hooks.after(async (ctx) => { - await ctx.session.commit() - }) -} diff --git a/src/Bindings/Tests.ts b/src/Bindings/Tests.ts deleted file mode 100644 index ed9cde2..0000000 --- a/src/Bindings/Tests.ts +++ /dev/null @@ -1,169 +0,0 @@ -/* - * @adonisjs/session - * - * (c) AdonisJS - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// - -import { ContainerBindings } from '@ioc:Adonis/Core/Application' -import { SessionManagerContract, AllowedSessionValues } from '@ioc:Adonis/Addons/Session' -import { inspect, InspectOptions } from 'util' - -/** - * Define test bindings - */ -export function defineTestsBindings( - ApiRequest: ContainerBindings['Japa/Preset/ApiRequest'], - ApiResponse: ContainerBindings['Japa/Preset/ApiResponse'], - ApiClient: ContainerBindings['Japa/Preset/ApiClient'], - SessionManager: SessionManagerContract -) { - /** - * Set "sessionClient" on the api request - */ - ApiRequest.getter( - 'sessionClient', - function () { - return SessionManager.client() - }, - true - ) - - /** - * Send session values in the request - */ - ApiRequest.macro('session', function (session: Record) { - if (!this.sessionClient.isEnabled()) { - throw new Error('Cannot set session. Make sure to enable it inside "config/session" file') - } - - this.sessionClient.merge(session) - return this - }) - - /** - * Send flash messages in the request - */ - ApiRequest.macro('flashMessages', function (messages: Record) { - if (!this.sessionClient.isEnabled()) { - throw new Error( - 'Cannot set flash messages. Make sure to enable the session inside "config/session" file' - ) - } - - this.sessionClient.flashMessages.merge(messages) - return this - }) - - /** - * Returns reference to the session data from the session - * jar - */ - ApiResponse.macro('session', function () { - return this.sessionJar.session - }) - - /** - * Returns reference to the flash messages from the session - * jar - */ - ApiResponse.macro('flashMessages', function () { - return this.sessionJar.flashMessages || {} - }) - - /** - * Assert response to contain a given session and optionally - * has the expected value - */ - ApiResponse.macro('assertSession', function (name: string, value?: any) { - this.ensureHasAssert() - this.assert!.property(this.session(), name) - - if (value !== undefined) { - this.assert!.deepEqual(this.session()[name], value) - } - }) - - /** - * Assert response to not contain a given session - */ - ApiResponse.macro('assertSessionMissing', function (name: string) { - this.ensureHasAssert() - this.assert!.notProperty(this.session(), name) - }) - - /** - * Assert response to contain a given flash message and optionally - * has the expected value - */ - ApiResponse.macro('assertFlashMessage', function (name: string, value?: any) { - this.ensureHasAssert() - this.assert!.property(this.flashMessages(), name) - - if (value !== undefined) { - this.assert!.deepEqual(this.flashMessages()[name], value) - } - }) - - /** - * Assert response to not contain a given session - */ - ApiResponse.macro('assertFlashMissing', function (name: string) { - this.ensureHasAssert() - this.assert!.notProperty(this.flashMessages(), name) - }) - - /** - * Dump session to the console - */ - ApiResponse.macro('dumpSession', function (options?: InspectOptions) { - const inspectOptions = { depth: 2, showHidden: false, colors: true, ...options } - console.log(`"session" => ${inspect(this.session(), inspectOptions)}`) - console.log(`"flashMessages" => ${inspect(this.flashMessages(), inspectOptions)}`) - }) - - /** - * Adding hooks directly on the request object moves the hooks to - * the end of the queue (basically after the globally hooks) - */ - ApiClient.onRequest((req) => { - /** - * Hook into request and persist session data to be available - * on the server during the request. - */ - req.setup(async (request) => { - /** - * Persist session data and set the session id within the - * cookie - */ - const { cookieName, sessionId } = await request.sessionClient.commit() - request.cookie(cookieName, sessionId) - - /** - * Cleanup if request has error. Otherwise the teardown - * hook will clear - */ - return async (error: any) => { - if (error) { - await request.sessionClient.forget() - } - } - }) - - /** - * Load messages from the session store and keep a reference to it - * inside the response object. - * - * We also destroy the session after getting a copy of the session - * data - */ - req.teardown(async (response) => { - response.sessionJar = await response.request.sessionClient.load(response.cookies()) - await response.request.sessionClient.forget() - }) - }) -} diff --git a/src/Client/index.ts b/src/Client/index.ts deleted file mode 100644 index db90c4c..0000000 --- a/src/Client/index.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* - * @adonisjs/session - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// - -import { cuid } from '@poppinss/utils/build/helpers' -import { - SessionConfig, - SessionDriverContract, - SessionClientContract, -} from '@ioc:Adonis/Addons/Session' - -import { CookieClientContract } from '@ioc:Adonis/Core/CookieClient' -import { Store } from '../Store' - -/** - * SessionClient exposes the API to set session data as a client - */ -export class SessionClient extends Store implements SessionClientContract { - /** - * Each instance of client works on a single session id. Generate - * multiple client instances for a different session id - */ - private sessionId = cuid() - - /** - * Session key for setting flash messages - */ - private flashMessagesKey = '__flash__' - - /** - * Flash messages store. They are merged with the session data during - * commit - */ - public flashMessages = new Store({}) - - constructor( - private config: SessionConfig, - private driver: SessionDriverContract, - private cookieClient: CookieClientContract, - values: { [key: string]: any } | null - ) { - super(values) - } - - /** - * Find if the sessions are enabled - */ - public isEnabled() { - return this.config.enabled - } - - /** - * Load session from the driver - */ - public async load(cookies: Record) { - const sessionIdCookie = cookies[this.config.cookieName] - const sessionId = sessionIdCookie ? sessionIdCookie.value : this.sessionId - - const contents = await this.driver.read(sessionId) - const store = new Store(contents) - const flashMessages = store.pull(this.flashMessagesKey, null) - - return { - session: store.all(), - flashMessages, - } - } - - /** - * Commits the session data to the session store and returns - * the session id and cookie name for it to be accessible - * by the server - */ - public async commit() { - this.set(this.flashMessagesKey, this.flashMessages.all()) - await this.driver.write(this.sessionId, this.toJSON()) - - /** - * Clear from the session client memory - */ - this.clear() - this.flashMessages.clear() - - return { - sessionId: this.sessionId!, - signedSessionId: this.cookieClient.sign(this.config.cookieName, this.sessionId)!, - cookieName: this.config.cookieName, - } - } - - /** - * Clear the session store - */ - public async forget() { - /** - * Clear from the session client memory - */ - this.clear() - this.flashMessages.clear() - - /** - * Clear with the driver - */ - await this.driver.destroy(this.sessionId) - } -} diff --git a/src/Drivers/Cookie.ts b/src/Drivers/Cookie.ts deleted file mode 100644 index 80dfd02..0000000 --- a/src/Drivers/Cookie.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * @adonisjs/session - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// - -import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' -import { SessionDriverContract, SessionConfig } from '@ioc:Adonis/Addons/Session' - -/** - * Cookie driver utilizes the encrypted HTTP cookies to write session value. - */ -export class CookieDriver implements SessionDriverContract { - constructor(private config: SessionConfig, private ctx: HttpContextContract) {} - - /** - * Read session value from the cookie - */ - public read(sessionId: string): { [key: string]: any } | null { - const cookieValue = this.ctx.request.encryptedCookie(sessionId) - if (typeof cookieValue !== 'object') { - return null - } - return cookieValue - } - - /** - * Write session values to the cookie - */ - public write(sessionId: string, values: { [key: string]: any }): void { - if (typeof values !== 'object') { - throw new Error('Session cookie driver expects an object of values') - } - - this.ctx.response.encryptedCookie(sessionId, values, this.config.cookie) - } - - /** - * Removes the session cookie - */ - public destroy(sessionId: string): void { - if (this.ctx.request.cookiesList()[sessionId]) { - this.ctx.response.clearCookie(sessionId) - } - } - - /** - * Updates the cookie with existing cookie values - */ - public touch(sessionId: string): void { - const value = this.read(sessionId) - if (!value) { - return - } - - this.write(sessionId, value) - } -} diff --git a/src/Drivers/File.ts b/src/Drivers/File.ts deleted file mode 100644 index 698ab2d..0000000 --- a/src/Drivers/File.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * @adonisjs/session - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// - -import { join } from 'path' -import { Exception } from '@poppinss/utils' -import { MessageBuilder } from '@poppinss/utils/build/helpers' -import { readFile, ensureFile, outputFile, remove } from 'fs-extra' -import { SessionDriverContract, SessionConfig } from '@ioc:Adonis/Addons/Session' - -/** - * File driver to read/write session to filesystem - */ -export class FileDriver implements SessionDriverContract { - constructor(private config: SessionConfig) { - if (!this.config.file || !this.config.file.location) { - throw new Exception( - 'Missing "file.location" for session file driver inside "config/session" file', - 500, - 'E_INVALID_SESSION_DRIVER_CONFIG' - ) - } - } - - /** - * Returns complete path to the session file - */ - private getFilePath(sessionId: string): string { - return join(this.config.file!.location, `${sessionId}.txt`) - } - - /** - * Returns file contents. A new file will be created if it's - * missing. - */ - public async read(sessionId: string): Promise<{ [key: string]: any } | null> { - const filePath = this.getFilePath(sessionId) - await ensureFile(filePath) - - const contents = await readFile(filePath, 'utf-8') - if (!contents.trim()) { - return null - } - - /** - * Verify contents with the session id and return them as an object. - */ - const verifiedContents = new MessageBuilder().verify(contents.trim(), sessionId) - if (typeof verifiedContents !== 'object') { - return null - } - - return verifiedContents - } - - /** - * Write session values to a file - */ - public async write(sessionId: string, values: { [key: string]: any }): Promise { - if (typeof values !== 'object') { - throw new Error('Session file driver expects an object of values') - } - - const message = new MessageBuilder().build(values, undefined, sessionId) - await outputFile(this.getFilePath(sessionId), message) - } - - /** - * Cleanup session file by removing it - */ - public async destroy(sessionId: string): Promise { - await remove(this.getFilePath(sessionId)) - } - - /** - * Writes the value by reading it from the store - */ - public async touch(sessionId: string): Promise { - const value = await this.read(sessionId) - if (!value) { - return - } - - await this.write(sessionId, value) - } -} diff --git a/src/Drivers/Memory.ts b/src/Drivers/Memory.ts deleted file mode 100644 index 2b8ee8f..0000000 --- a/src/Drivers/Memory.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * @adonisjs/session - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// - -import { SessionDriverContract } from '@ioc:Adonis/Addons/Session' - -/** - * Memory driver is meant to be used for writing tests. - */ -export class MemoryDriver implements SessionDriverContract { - public static sessions: Map = new Map() - - /** - * Read session id value from the memory - */ - public read(sessionId: string): { [key: string]: any } | null { - return MemoryDriver.sessions.get(sessionId) || null - } - - /** - * Save in memory value for a given session id - */ - public write(sessionId: string, values: { [key: string]: any }): void { - if (typeof values !== 'object') { - throw new Error('Session memory driver expects an object of values') - } - - MemoryDriver.sessions.set(sessionId, values) - } - - /** - * Cleanup for a single session - */ - public destroy(sessionId: string): void { - MemoryDriver.sessions.delete(sessionId) - } - - public touch(): void {} -} diff --git a/src/Drivers/Redis.ts b/src/Drivers/Redis.ts deleted file mode 100644 index 98d4e57..0000000 --- a/src/Drivers/Redis.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * @adonisjs/session - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// - -import { Exception } from '@poppinss/utils' -import { MessageBuilder, string } from '@poppinss/utils/build/helpers' -import { SessionDriverContract, SessionConfig } from '@ioc:Adonis/Addons/Session' -import { RedisManagerContract, RedisConnectionContract } from '@ioc:Adonis/Addons/Redis' - -/** - * File driver to read/write session to filesystem - */ -export class RedisDriver implements SessionDriverContract { - /** - * Convert milliseconds to seconds - */ - private ttl: number = Math.round( - (typeof this.config.age === 'string' ? string.toMs(this.config.age) : this.config.age) / 1000 - ) - - constructor(private config: SessionConfig, private redis: RedisManagerContract) { - if (!this.config.redisConnection) { - throw new Exception( - 'Missing redisConnection for session redis driver inside "config/session" file', - 500, - 'E_INVALID_SESSION_DRIVER_CONFIG' - ) - } - } - - /** - * Returns instance of the redis connection - */ - private getRedisConnection(): RedisConnectionContract { - return (this.redis.connection as any)(this.config.redisConnection) - } - - /** - * Returns file contents. A new file will be created if it's - * missing. - */ - public async read(sessionId: string): Promise<{ [key: string]: any } | null> { - const contents = await this.getRedisConnection().get(sessionId) - if (!contents) { - return null - } - - const verifiedContents = new MessageBuilder().verify(contents, sessionId) - if (typeof verifiedContents !== 'object') { - return null - } - - return verifiedContents - } - - /** - * Write session values to a file - */ - public async write(sessionId: string, values: Object): Promise { - if (typeof values !== 'object') { - throw new Error('Session file driver expects an object of values') - } - - await this.getRedisConnection().setex( - sessionId, - this.ttl, - new MessageBuilder().build(values, undefined, sessionId) - ) - } - - /** - * Cleanup session file by removing it - */ - public async destroy(sessionId: string): Promise { - await this.getRedisConnection().del(sessionId) - } - - /** - * Updates the value expiry - */ - public async touch(sessionId: string): Promise { - await this.getRedisConnection().expire(sessionId, this.ttl) - } -} diff --git a/src/Session/index.ts b/src/Session/index.ts deleted file mode 100644 index b3a4224..0000000 --- a/src/Session/index.ts +++ /dev/null @@ -1,431 +0,0 @@ -/* - * @adonisjs/session - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -/// - -import { Exception, lodash } from '@poppinss/utils' -import { cuid } from '@poppinss/utils/build/helpers' -import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' - -import { - SessionConfig, - SessionContract, - AllowedSessionValues, - SessionDriverContract, -} from '@ioc:Adonis/Addons/Session' - -import { Store } from '../Store' - -/** - * Session class exposes the API to read/write values to the session for - * a given request. - */ -export class Session implements SessionContract { - /** - * Set to true inside the `initiate` method - */ - public initiated = false - - /** - * A boolean to know if it's a fresh session or not. Fresh - * sessions are those, whose session id is not present - * in cookie - */ - public fresh = false - - /** - * A boolean to know if store is initiated in readonly mode - * or not. This is done during Websocket requests - */ - public readonly = false - - /** - * Session id for the given request. A new session id is only - * generated when the cookie for the session id is missing - */ - public sessionId = this.getSessionId() - - /** - * A copy of previously set flash messages - */ - public flashMessages = new Store({}) - - /** - * Session id for the current request. It will be different - * from the "this.sessionId" when regenerate is called. - */ - private currentSessionId = this.sessionId - - /** - * A instance of store with values read from the driver. The store - * in initiated inside the [[initiate]] method - */ - private store: Store - - /** - * Whether or not to re-generate the session id before comitting - * session values. - */ - private regeneratedSessionId = false - - /** - * A copy of flash messages. The `input` messages - * are overwritten when any of the input related - * methods are used. - * - * The `others` object is expanded with each call. - */ - public responseFlashMessages = new Store({}) - - /** - * Session key for setting flash messages - */ - private flashMessagesKey = '__flash__' - - constructor( - private ctx: HttpContextContract, - private config: SessionConfig, - private driver: SessionDriverContract - ) {} - - /** - * Returns a merged copy of flash messages or null - * when nothing is set - */ - private setFlashMessages(): void { - if (this.responseFlashMessages.isEmpty) { - return - } - - const { input, ...others } = this.responseFlashMessages.all() - this.put(this.flashMessagesKey, { ...input, ...others }) - } - - /** - * Returns the existing session id or creates one. - */ - private getSessionId(): string { - const sessionId = this.ctx.request.cookie(this.config.cookieName) - if (sessionId) { - this.ctx.logger.trace('existing session found') - return sessionId - } - - this.fresh = true - this.ctx.logger.trace('generating new session id') - return cuid() - } - - /** - * Ensures the session store is initialized - */ - private ensureIsReady(): void { - if (!this.initiated) { - throw new Exception( - 'Session store is not initiated yet. Make sure you are using the session hook', - 500, - 'E_RUNTIME_EXCEPTION' - ) - } - } - - /** - * Raises exception when session store is in readonly mode - */ - private ensureIsMutable() { - if (this.readonly) { - throw new Exception( - 'Session store is in readonly mode and cannot be mutated', - 500, - 'E_RUNTIME_EXCEPTION' - ) - } - } - - /** - * Touches the session cookie - */ - private touchSessionCookie(): void { - this.ctx.logger.trace('touching session cookie') - this.ctx.response.cookie(this.config.cookieName, this.sessionId, this.config.cookie!) - } - - /** - * Commits the session value to the store - */ - private async commitValuesToStore(): Promise { - this.ctx.logger.trace('persist session store with driver') - await this.driver.write(this.sessionId, this.store.toJSON()) - } - - /** - * Touches the driver to make sure the session values doesn't expire - */ - private async touchDriver(): Promise { - this.ctx.logger.trace('touch driver for liveliness') - await this.driver.touch(this.sessionId) - } - - /** - * Reading flash messages from the last HTTP request and - * updating the flash messages bag - */ - private readLastRequestFlashMessage() { - if (this.readonly) { - return - } - - this.flashMessages.update(this.pull(this.flashMessagesKey, null)) - } - - /** - * Share flash messages & read only session's functions with views - * (only when view property exists) - */ - private shareLocalsWithView() { - if (!this.ctx['view'] || typeof this.ctx['view'].share !== 'function') { - return - } - - this.ctx['view'].share({ - flashMessages: this.flashMessages, - session: { - get: this.get.bind(this), - has: this.has.bind(this), - all: this.all.bind(this), - }, - }) - } - - /** - * Initiating the session by reading it's value from the - * driver and feeding it to a store. - * - * Multiple calls to `initiate` results in a noop. - */ - public async initiate(readonly: boolean): Promise { - if (this.initiated) { - return - } - - this.readonly = readonly - - /** - * Profiling the driver read method - */ - await this.ctx.profiler.profileAsync( - 'session:initiate', - { driver: this.config.driver }, - async () => { - const contents = await this.driver.read(this.sessionId) - this.store = new Store(contents) - } - ) - - this.initiated = true - this.readLastRequestFlashMessage() - this.shareLocalsWithView() - } - - /** - * Re-generates the session id. This can is used to avoid - * session fixation attacks. - */ - public regenerate(): void { - this.ctx.logger.trace('explicitly re-generating session id') - this.sessionId = cuid() - this.regeneratedSessionId = true - } - - /** - * Set/update session value - */ - public put(key: string, value: AllowedSessionValues): void { - this.ensureIsReady() - this.ensureIsMutable() - this.store.set(key, value) - } - - /** - * Find if the value exists in the session - */ - public has(key: string): boolean { - this.ensureIsReady() - return this.store.has(key) - } - - /** - * Get value from the session. The default value is returned - * when actual value is `undefined` - */ - public get(key: string, defaultValue?: any): any { - this.ensureIsReady() - return this.store.get(key, defaultValue) - } - - /** - * Returns everything from the session - */ - public all(): any { - this.ensureIsReady() - return this.store.all() - } - - /** - * Remove value for a given key from the session - */ - public forget(key: string): void { - this.ensureIsReady() - this.ensureIsMutable() - this.store.unset(key) - } - - /** - * The method is equivalent to calling `session.get` followed - * by `session.forget` - */ - public pull(key: string, defaultValue?: any): any { - this.ensureIsReady() - this.ensureIsMutable() - return this.store.pull(key, defaultValue) - } - - /** - * Increment value for a number inside the session store. The - * method raises an error when underlying value is not - * a number - */ - public increment(key: string, steps: number = 1): void { - this.ensureIsReady() - this.ensureIsMutable() - this.store.increment(key, steps) - } - - /** - * Decrement value for a number inside the session store. The - * method raises an error when underlying value is not - * a number - */ - public decrement(key: string, steps: number = 1): void { - this.ensureIsReady() - this.ensureIsMutable() - this.store.decrement(key, steps) - } - - /** - * Remove everything from the session - */ - public clear(): void { - this.ensureIsReady() - this.ensureIsMutable() - this.store.clear() - } - - /** - * Add a new flash message - */ - public flash( - key: string | { [key: string]: AllowedSessionValues }, - value?: AllowedSessionValues - ): void { - this.ensureIsReady() - this.ensureIsMutable() - - /** - * Update value - */ - if (typeof key === 'string') { - if (value) { - this.responseFlashMessages.set(key, value) - } - } else { - this.responseFlashMessages.merge(key) - } - } - - /** - * Flash all form values - */ - public flashAll(): void { - this.ensureIsReady() - this.ensureIsMutable() - this.responseFlashMessages.set('input', this.ctx.request.original()) - } - - /** - * Flash all form values except mentioned keys - */ - public flashExcept(keys: string[]): void { - this.ensureIsReady() - this.ensureIsMutable() - this.responseFlashMessages.set('input', lodash.omit(this.ctx.request.original(), keys)) - } - - /** - * Flash only defined keys from the form values - */ - public flashOnly(keys: string[]): void { - this.ensureIsReady() - this.ensureIsMutable() - this.responseFlashMessages.set('input', lodash.pick(this.ctx.request.original(), keys)) - } - - /** - * Reflash existing flash messages - */ - public reflash() { - this.flash(this.flashMessages.all()) - } - - /** - * Reflash selected keys from the existing flash messages - */ - public reflashOnly(keys: string[]) { - this.flash(lodash.pick(this.flashMessages.all(), keys)) - } - - /** - * Omit selected keys from the existing flash messages - * and flash the rest of values - */ - public reflashExcept(keys: string[]) { - this.flash(lodash.omit(this.flashMessages.all(), keys)) - } - - /** - * Writes value to the underlying session driver. - */ - public async commit(): Promise { - await this.ctx.profiler.profileAsync( - 'session:commit', - { driver: this.config.driver }, - async () => { - if (!this.initiated) { - this.touchSessionCookie() - await this.touchDriver() - return - } - - /** - * Cleanup old session and re-generate new session - */ - if (this.regeneratedSessionId) { - await this.driver.destroy(this.currentSessionId) - } - - /** - * Touch the session cookie to keep it alive. - */ - this.touchSessionCookie() - this.setFlashMessages() - await this.commitValuesToStore() - } - ) - } -} diff --git a/src/SessionManager/index.ts b/src/SessionManager/index.ts deleted file mode 100644 index 0dc4285..0000000 --- a/src/SessionManager/index.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * @adonisjs/session - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { string } from '@poppinss/utils/build/helpers' -import { ApplicationContract } from '@ioc:Adonis/Core/Application' -import { Exception, ManagerConfigValidator } from '@poppinss/utils' -import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext' - -import { - SessionConfig, - ExtendCallback, - SessionDriverContract, - SessionManagerContract, - SessionClientContract, -} from '@ioc:Adonis/Addons/Session' - -import { Session } from '../Session' - -type SessionManagerConfig = SessionConfig & { - cookie: { - expires: undefined - maxAge: number | undefined - } -} - -/** - * Session manager exposes the API to create session instance for a given - * request and also add new drivers. - */ -export class SessionManager implements SessionManagerContract { - /** - * A private map of drivers added from outside in. - */ - private extendedDrivers: Map = new Map() - - /** - * Reference to session config - */ - private config: SessionManagerConfig - - constructor(public application: ApplicationContract, config: SessionConfig) { - this.validateConfig(config) - this.processConfig(config) - } - - /** - * Validates the config - */ - private validateConfig(config: SessionConfig) { - const validator = new ManagerConfigValidator(config, 'session', 'config/session') - validator.validateDefault('driver') - } - - /** - * Processes the config and decides the `expires` option for the cookie - */ - private processConfig(config: SessionConfig): void { - /** - * Explicitly overwriting `cookie.expires` and `cookie.maxAge` from - * the user defined config - */ - const processedConfig: SessionManagerConfig = Object.assign({ enabled: true }, config, { - cookie: { - ...config.cookie, - expires: undefined, - maxAge: undefined, - }, - }) - - /** - * Set the max age when `clearWithBrowser = false`. Otherwise cookie - * is a session cookie - */ - if (!processedConfig.clearWithBrowser) { - const age = - typeof processedConfig.age === 'string' - ? Math.round(string.toMs(processedConfig.age) / 1000) - : processedConfig.age - - processedConfig.cookie.maxAge = age - } - - this.config = processedConfig - } - - /** - * Returns an instance of cookie driver - */ - private createCookieDriver(ctx: HttpContextContract): any { - const { CookieDriver } = require('../Drivers/Cookie') - return new CookieDriver(this.config, ctx) - } - - /** - * Returns an instance of the memory driver - */ - private createMemoryDriver(): any { - const { MemoryDriver } = require('../Drivers/Memory') - return new MemoryDriver() - } - - /** - * Returns an instance of file driver - */ - private createFileDriver(): any { - const { FileDriver } = require('../Drivers/File') - return new FileDriver(this.config) - } - - /** - * Returns an instance of redis driver - */ - private createRedisDriver(): any { - const { RedisDriver } = require('../Drivers/Redis') - - if (!this.application.container.hasBinding('Adonis/Addons/Redis')) { - throw new Error( - 'Install "@adonisjs/redis" in order to use the redis driver for storing sessions' - ) - } - - return new RedisDriver(this.config, this.application.container.use('Adonis/Addons/Redis')) - } - - /** - * Creates an instance of extended driver - */ - private createExtendedDriver(ctx: HttpContextContract): any { - if (!this.extendedDrivers.has(this.config.driver)) { - throw new Exception( - `"${this.config.driver}" is not a valid session driver`, - 500, - 'E_INVALID_SESSION_DRIVER' - ) - } - - return this.extendedDrivers.get(this.config.driver)!(this, this.config, ctx) - } - - /** - * Creates an instance of driver by looking at the config value `driver`. - * An hard exception is raised in case of invalid driver name - */ - private createDriver(ctx: HttpContextContract): SessionDriverContract { - switch (this.config.driver) { - case 'cookie': - return this.createCookieDriver(ctx) - case 'file': - return this.createFileDriver() - case 'redis': - return this.createRedisDriver() - case 'memory': - return this.createMemoryDriver() - default: - return this.createExtendedDriver(ctx) - } - } - - /** - * Find if the sessions are enabled - */ - public isEnabled() { - return this.config.enabled - } - - /** - * Creates an instance of the session client - */ - public client(): SessionClientContract { - const { SessionClient } = require('../Client') - const CookieClient = this.application.container.resolveBinding('Adonis/Core/CookieClient') - - return new SessionClient(this.config, this.createMemoryDriver(), CookieClient, {}) - } - - /** - * Creates a new session instance for a given HTTP request - */ - public create(ctx: HttpContextContract): Session { - return new Session(ctx, this.config, this.createDriver(ctx)) - } - - /** - * Extend the drivers list by adding a new one. - */ - public extend(driver: string, callback: ExtendCallback): void { - this.extendedDrivers.set(driver, callback) - } -} diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..f3b939b --- /dev/null +++ b/src/client.ts @@ -0,0 +1,101 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { cuid } from '@adonisjs/core/helpers' + +import debug from './debug.js' +import { ValuesStore } from './values_store.js' +import type { SessionData, SessionStoreContract } from './types.js' + +/** + * Session client exposes the API to set session data as a client + */ +export class SessionClient { + /** + * Data store + */ + #valuesStore = new ValuesStore({}) + + /** + * Flash messages store + */ + #flashMessagesStore = new ValuesStore({}) + + /** + * The session store to use for reading and writing session data + */ + #store: SessionStoreContract + + /** + * Session key for setting flash messages + */ + flashKey = '__flash__' + + /** + * Session to use when no explicit session id is + * defined + */ + sessionId = cuid() + + constructor(store: SessionStoreContract) { + this.#store = store + } + + /** + * Merge session data + */ + merge(values: SessionData) { + this.#valuesStore.merge(values) + return this + } + + /** + * Merge flash messages + */ + flash(values: SessionData) { + this.#flashMessagesStore.merge(values) + return this + } + + /** + * Commits data to the session store. + */ + async commit() { + if (!this.#flashMessagesStore.isEmpty) { + this.#valuesStore.set(this.flashKey, this.#flashMessagesStore.toJSON()) + } + + debug('committing session data during api request') + if (!this.#valuesStore.isEmpty) { + this.#store.write(this.sessionId, this.#valuesStore.toJSON()) + } + } + + /** + * Destroys the session data with the store + */ + async destroy(sessionId?: string) { + debug('destroying session data during api request') + this.#store.destroy(sessionId || this.sessionId) + } + + /** + * Loads session data from the session store + */ + async load(sessionId?: string) { + const contents = await this.#store.read(sessionId || this.sessionId) + const store = new ValuesStore(contents) + const flashMessages = store.pull(this.flashKey, {}) + + return { + values: store.all(), + flashMessages, + } + } +} diff --git a/src/debug.ts b/src/debug.ts new file mode 100644 index 0000000..6a9321e --- /dev/null +++ b/src/debug.ts @@ -0,0 +1,12 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { debuglog } from 'node:util' + +export default debuglog('adonisjs:session') diff --git a/src/define_config.ts b/src/define_config.ts new file mode 100644 index 0000000..a0546fa --- /dev/null +++ b/src/define_config.ts @@ -0,0 +1,151 @@ +/* + * @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 { configProvider } from '@adonisjs/core' +import type { ConfigProvider } from '@adonisjs/core/types' +import { InvalidArgumentsException } from '@poppinss/utils' +import type { CookieOptions } from '@adonisjs/core/types/http' + +import debug from './debug.js' +import { MemoryStore } from './stores/memory.js' +import type { + SessionConfig, + FileStoreConfig, + RedisStoreConfig, + SessionStoreFactory, +} from './types.js' + +/** + * Resolved config with stores + */ +type ResolvedConfig> = SessionConfig & { + store: keyof KnownStores + stores: KnownStores + cookie: Partial +} + +/** + * Helper to normalize session config + */ +export function defineConfig< + KnownStores extends Record>, +>( + config: Partial & { + store: keyof KnownStores | 'memory' + stores: KnownStores + } +): ConfigProvider< + ResolvedConfig<{ + [K in keyof KnownStores]: SessionStoreFactory + }> +> { + debug('processing session config %O', config) + + /** + * Make sure a store is defined + */ + if (!config.store) { + throw new InvalidArgumentsException('Missing "store" property inside the session config') + } + + /** + * Destructuring config with the default values. We pull out + * stores and cookie values, since we have to transform + * them in the output value. + */ + const { stores, cookie, ...rest } = { + enabled: true, + age: '2h', + cookieName: 'adonis_session', + clearWithBrowser: false, + ...config, + } + + const cookieOptions: Partial = { ...cookie } + + /** + * Define maxAge property when session id cookie is + * not a session cookie. + */ + if (!rest.clearWithBrowser) { + cookieOptions.maxAge = string.seconds.parse(rest.age) + debug('computing maxAge "%s" for session id cookie', cookieOptions.maxAge) + } + + return configProvider.create(async (app) => { + const storesNames = Object.keys(config.stores) + + /** + * List of stores with memory store always configured + */ + const storesList = { + memory: () => new MemoryStore(), + } as Record + + /** + * Looping for stores and resolving them + */ + for (let storeName of storesNames) { + const store = config.stores[storeName] + if (typeof store === 'function') { + storesList[storeName] = store + } else { + storesList[storeName] = await store.resolver(app) + } + } + + const transformedConfig = { + ...rest, + cookie: cookieOptions, + stores: storesList as { [K in keyof KnownStores]: SessionStoreFactory }, + } + + debug('transformed session config %O', transformedConfig) + return transformedConfig + }) +} + +/** + * Inbuilt stores to store the session data. + */ +export const stores: { + file: (config: FileStoreConfig) => ConfigProvider + redis: (config: RedisStoreConfig) => ConfigProvider + cookie: () => ConfigProvider +} = { + file: (config) => { + return configProvider.create(async () => { + const { FileStore } = await import('./stores/file.js') + return (_, sessionConfig: SessionConfig) => { + return new FileStore(config, sessionConfig.age) + } + }) + }, + redis: (config) => { + return configProvider.create(async (app) => { + const { RedisStore } = await import('./stores/redis.js') + const redis = await app.container.make('redis') + + return (_, sessionConfig: SessionConfig) => { + return new RedisStore(redis.connection(config.connection), sessionConfig.age) + } + }) + }, + cookie: () => { + return configProvider.create(async () => { + const { CookieStore } = await import('./stores/cookie.js') + return (ctx, sessionConfig: SessionConfig) => { + return new CookieStore(sessionConfig.cookie, ctx) + } + }) + }, +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..21ca81d --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,28 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { createError } from '@poppinss/utils' + +/** + * Raised when session store is not mutable + */ +export const E_SESSION_NOT_MUTABLE = createError( + 'Session store is in readonly mode and cannot be mutated', + 'E_SESSION_NOT_MUTABLE', + 500 +) + +/** + * Raised when session store has been initiated + */ +export const E_SESSION_NOT_READY = createError( + 'Session store has not been initiated. Make sure you have registered the session middleware', + 'E_SESSION_NOT_READY', + 500 +) diff --git a/src/plugins/edge.ts b/src/plugins/edge.ts new file mode 100644 index 0000000..157be41 --- /dev/null +++ b/src/plugins/edge.ts @@ -0,0 +1,193 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { PluginFn } from 'edge.js/types' +import debug from '../debug.js' + +/** + * The edge plugin for AdonisJS Session adds tags to read + * flash messages + */ +export const edgePluginSession: PluginFn = (edge) => { + debug('registering session tags with edge') + + edge.registerTag({ + tagName: 'flashMessage', + seekable: true, + block: true, + compile(parser, buffer, token) { + const expression = parser.utils.transformAst( + parser.utils.generateAST(token.properties.jsArg, token.loc, token.filename), + token.filename, + parser + ) + + const key = parser.utils.stringify(expression) + + /** + * Write an if statement + */ + buffer.writeStatement( + `if (state.flashMessages.has(${key})) {`, + token.filename, + token.loc.start.line + ) + + /** + * Define a local variable + */ + buffer.writeExpression( + `let $message = state.flashMessages.get(${key})`, + token.filename, + token.loc.start.line + ) + + /** + * Create a local variables scope and tell the parser about + * the existence of the "message" variable + */ + parser.stack.defineScope() + parser.stack.defineVariable('$message') + + /** + * Process component children using the parser + */ + token.children.forEach((child) => { + parser.processToken(child, buffer) + }) + + /** + * Clear the scope of the local variables before we + * close the if statement + */ + parser.stack.clearScope() + + /** + * Close if statement + */ + buffer.writeStatement(`}`, token.filename, token.loc.start.line) + }, + }) + + edge.registerTag({ + tagName: 'inputError', + seekable: true, + block: true, + compile(parser, buffer, token) { + const expression = parser.utils.transformAst( + parser.utils.generateAST(token.properties.jsArg, token.loc, token.filename), + token.filename, + parser + ) + + const key = parser.utils.stringify(expression) + + /** + * Write an if statement + */ + buffer.writeStatement( + `if (!!state.flashMessages.get('inputErrorsBag', {})[${key}]) {`, + token.filename, + token.loc.start.line + ) + + /** + * Define a local variable + */ + buffer.writeExpression( + `let $messages = state.flashMessages.get('inputErrorsBag', {})[${key}]`, + token.filename, + token.loc.start.line + ) + + /** + * Create a local variables scope and tell the parser about + * the existence of the "messages" variable + */ + parser.stack.defineScope() + parser.stack.defineVariable('$messages') + + /** + * Process component children using the parser + */ + token.children.forEach((child) => { + parser.processToken(child, buffer) + }) + + /** + * Clear the scope of the local variables before we + * close the if statement + */ + parser.stack.clearScope() + + /** + * Close if statement + */ + buffer.writeStatement(`}`, token.filename, token.loc.start.line) + }, + }) + + edge.registerTag({ + tagName: 'error', + seekable: true, + block: true, + compile(parser, buffer, token) { + const expression = parser.utils.transformAst( + parser.utils.generateAST(token.properties.jsArg, token.loc, token.filename), + token.filename, + parser + ) + + const key = parser.utils.stringify(expression) + + /** + * Write an if statement + */ + buffer.writeStatement( + `if (state.flashMessages.has(['errorsBag', ${key}])) {`, + token.filename, + token.loc.start.line + ) + + /** + * Define a local variable + */ + buffer.writeExpression( + `let $message = state.flashMessages.get(['errorsBag', ${key}])`, + token.filename, + token.loc.start.line + ) + + /** + * Create a local variables scope and tell the parser about + * the existence of the "messages" variable + */ + parser.stack.defineScope() + parser.stack.defineVariable('$message') + + /** + * Process component children using the parser + */ + token.children.forEach((child) => { + parser.processToken(child, buffer) + }) + + /** + * Clear the scope of the local variables before we + * close the if statement + */ + parser.stack.clearScope() + + /** + * Close if statement + */ + buffer.writeStatement(`}`, token.filename, token.loc.start.line) + }, + }) +} diff --git a/src/plugins/japa/api_client.ts b/src/plugins/japa/api_client.ts new file mode 100644 index 0000000..c7f72e6 --- /dev/null +++ b/src/plugins/japa/api_client.ts @@ -0,0 +1,248 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import lodash from '@poppinss/utils/lodash' +import { configProvider } from '@adonisjs/core' +import { RuntimeException } from '@poppinss/utils' +import type { PluginFn } from '@japa/runner/types' +import type { ApplicationService } from '@adonisjs/core/types' +import { ApiClient, ApiRequest, ApiResponse } from '@japa/api-client' + +import { SessionClient } from '../../client.js' +import type { SessionData } from '../../types.js' + +declare module '@japa/api-client' { + export interface ApiRequest { + sessionClient: SessionClient + + /** + * Make HTTP request along with the provided session data + */ + withSession(values: SessionData): this + + /** + * Make HTTP request along with the provided session flash + * messages. + */ + withFlashMessages(values: SessionData): this + } + + export interface ApiResponse { + sessionBag: { + values: SessionData + flashMessages: SessionData + } + + /** + * Get session data from the HTTP response + */ + session(key?: string): any + + /** + * Get flash messages from the HTTP response + */ + flashMessages(): SessionData + + /** + * Get flash messages for a specific key from the HTTP response + */ + flashMessage(key: string): SessionData + + /** + * Assert session key-value pair exists + */ + assertSession(key: string, value?: any): void + + /** + * Assert key is missing in session store + */ + assertSessionMissing(key: string): void + + /** + * Assert flash message key-value pair exists + */ + assertFlashMessage(key: string, value?: any): void + + /** + * Assert key is missing flash messages store + */ + assertFlashMissing(key: string): void + + /** + * Assert flash messages has validation errors for + * the given field + */ + assertHasValidationError(field: string): void + + /** + * Assert flash messages does not have validation errors + * for the given field + */ + assertDoesNotHaveValidationError(field: string): void + + /** + * Assert error message for a given field + */ + assertValidationError(field: string, message: string): void + + /** + * Assert all error messages for a given field + */ + assertValidationErrors(field: string, messages: string[]): void + } +} + +/** + * Hooks AdonisJS Session with the Japa API client + * plugin + */ +export const sessionApiClient = (app: ApplicationService) => { + const pluginFn: PluginFn = async function () { + const sessionConfigProvider = app.config.get('session', {}) + + /** + * Resolve config from the provider + */ + const config = await configProvider.resolve(app, sessionConfigProvider) + if (!config) { + throw new RuntimeException( + 'Invalid "config/session.ts" file. Make sure you are using the "defineConfig" method' + ) + } + + /** + * Stick an singleton session client to APIRequest. The session + * client is used to keep a track of session data we have + * to send during the request. + */ + ApiRequest.getter( + 'sessionClient', + function () { + return new SessionClient(config.stores.memory()) + }, + true + ) + + /** + * Define session data + */ + ApiRequest.macro('withSession', function (this: ApiRequest, data) { + this.sessionClient.merge(data) + return this + }) + + /** + * Define flash messages + */ + ApiRequest.macro('withFlashMessages', function (this: ApiRequest, data) { + this.sessionClient.flash(data) + return this + }) + + /** + * Get session data + */ + ApiResponse.macro('session', function (this: ApiResponse, key) { + return key ? lodash.get(this.sessionBag.values, key) : this.sessionBag.values + }) + + /** + * Get flash messages + */ + ApiResponse.macro('flashMessages', function (this: ApiResponse) { + return this.sessionBag.flashMessages + }) + ApiResponse.macro('flashMessage', function (this: ApiResponse, key) { + return lodash.get(this.sessionBag.flashMessages, key) + }) + + /** + * Response session assertions + */ + ApiResponse.macro('assertSession', function (this: ApiResponse, key, value) { + this.assert!.property(this.session(), key) + if (value !== undefined) { + this.assert!.deepEqual(this.session(key), value) + } + }) + ApiResponse.macro('assertSessionMissing', function (this: ApiResponse, key) { + this.assert!.notProperty(this.session(), key) + }) + ApiResponse.macro('assertFlashMessage', function (this: ApiResponse, key, value) { + this.assert!.property(this.flashMessages(), key) + if (value !== undefined) { + this.assert!.deepEqual(this.flashMessage(key), value) + } + }) + ApiResponse.macro('assertFlashMissing', function (this: ApiResponse, key) { + this.assert!.notProperty(this.flashMessages(), key) + }) + ApiResponse.macro('assertHasValidationError', function (this: ApiResponse, field) { + this.assert!.property(this.flashMessage('errors'), field) + }) + ApiResponse.macro('assertDoesNotHaveValidationError', function (this: ApiResponse, field) { + this.assert!.notProperty(this.flashMessage('errors'), field) + }) + ApiResponse.macro('assertValidationError', function (this: ApiResponse, field, message) { + this.assert!.include(this.flashMessage('errors')?.[field] || [], message) + }) + ApiResponse.macro('assertValidationErrors', function (this: ApiResponse, field, messages) { + this.assert!.deepEqual(this.flashMessage('errors')?.[field] || [], messages) + }) + + /** + * We define the hook using the "request.setup" method because we + * want to allow other Japa hooks to mutate the session store + * without running into race conditions + */ + ApiClient.onRequest((request) => { + request.setup(async () => { + /** + * Set cookie + */ + request.withCookie(config.cookieName, request.sessionClient.sessionId) + + /** + * Persist data + */ + await request.sessionClient.commit() + + /** + * Cleanup if request fails + */ + return async (error: any) => { + if (error) { + await request.sessionClient.destroy() + } + } + }) + + request.teardown(async (response) => { + const sessionId = response.cookie(config.cookieName) + + /** + * Reading session data from the response cookie + */ + response.sessionBag = sessionId + ? await response.request.sessionClient.load(sessionId.value) + : { + values: {}, + flashMessages: {}, + } + + /** + * Cleanup state + */ + await request.sessionClient.destroy(sessionId?.value) + }) + }) + } + + return pluginFn +} diff --git a/src/plugins/japa/browser_client.ts b/src/plugins/japa/browser_client.ts new file mode 100644 index 0000000..acdfe9a --- /dev/null +++ b/src/plugins/japa/browser_client.ts @@ -0,0 +1,178 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { configProvider } from '@adonisjs/core' +import { RuntimeException } from '@poppinss/utils' +import type { PluginFn } from '@japa/runner/types' +import { decoratorsCollection } from '@japa/browser-client' +import type { ApplicationService } from '@adonisjs/core/types' +import type { CookieOptions as AdonisCookieOptions } from '@adonisjs/core/types/http' + +import { SessionClient } from '../../client.js' +import type { SessionConfig, SessionData } from '../../types.js' + +declare module 'playwright' { + export interface BrowserContext { + sessionClient: SessionClient + + /** + * Initiate session. The session id cookie will be defined + * if missing + */ + initiateSession(options?: Partial): Promise + + /** + * Returns data from the session store + */ + getSession(): Promise + + /** + * Returns data from the session store + */ + getFlashMessages(): Promise + + /** + * Set session data + */ + setSession(values: SessionData): Promise + + /** + * Set flash messages + */ + setFlashMessages(values: SessionData): Promise + } +} + +/** + * Transforming AdonisJS same site option to playwright + * same site option. + */ +function transformSameSiteOption(sameSite?: AdonisCookieOptions['sameSite']) { + if (!sameSite) { + return + } + + if (sameSite === true || sameSite === 'strict') { + return 'Strict' as const + } + + if (sameSite === 'lax') { + return 'Lax' as const + } + + if (sameSite === 'none') { + return 'None' as const + } +} + +/** + * Transforming AdonisJS session config to playwright cookie options. + */ +function getSessionCookieOptions( + config: SessionConfig, + cookieOptions?: Partial +) { + const options = { ...config.cookie, ...cookieOptions } + return { + ...options, + expires: undefined, + sameSite: transformSameSiteOption(options.sameSite), + } +} + +/** + * Hooks AdonisJS Session with the Japa browser client + * plugin + */ +export const sessionBrowserClient = (app: ApplicationService) => { + const pluginFn: PluginFn = async function () { + const sessionConfigProvider = app.config.get('session', {}) + + /** + * Resolve config from the provider + */ + const config = await configProvider.resolve(app, sessionConfigProvider) + if (!config) { + throw new RuntimeException( + 'Invalid "config/session.ts" file. Make sure you are using the "defineConfig" method' + ) + } + + decoratorsCollection.register({ + context(context) { + /** + * Reference to session client per browser context + */ + context.sessionClient = new SessionClient(config.stores.memory()) + + /** + * Initiating session store + */ + context.initiateSession = async function (options) { + const sessionId = await context.getCookie(config.cookieName) + if (sessionId) { + context.sessionClient.sessionId = sessionId + return + } + + await context.setCookie( + config.cookieName, + context.sessionClient.sessionId, + getSessionCookieOptions(config, options) + ) + } + + /** + * Returns session data + */ + context.getSession = async function () { + await context.initiateSession() + const sessionData = await context.sessionClient.load() + return sessionData.values + } + + /** + * Returns flash messages from the data store + */ + context.getFlashMessages = async function () { + await context.initiateSession() + const sessionData = await context.sessionClient.load() + return sessionData.flashMessages + } + + /** + * Set session data + */ + context.setSession = async function (values) { + await context.initiateSession() + context.sessionClient.merge(values) + await context.sessionClient.commit() + } + + /** + * Set flash messages + */ + context.setFlashMessages = async function (values) { + await context.initiateSession() + context.sessionClient.flash(values) + await context.sessionClient.commit() + } + + /** + * Destroy session when context is closed + */ + context.on('close', async function () { + await context.sessionClient.destroy() + }) + }, + }) + } + + return pluginFn +} diff --git a/src/session.ts b/src/session.ts new file mode 100644 index 0000000..7749354 --- /dev/null +++ b/src/session.ts @@ -0,0 +1,481 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import lodash from '@poppinss/utils/lodash' +import { cuid } from '@adonisjs/core/helpers' +import type { HttpContext } from '@adonisjs/core/http' +import type { EmitterService } from '@adonisjs/core/types' +import type { HttpError } from '@adonisjs/core/types/http' + +import debug from './debug.js' +import * as errors from './errors.js' +import { ReadOnlyValuesStore, ValuesStore } from './values_store.js' +import type { + SessionData, + SessionConfig, + SessionStoreFactory, + AllowedSessionValues, + SessionStoreContract, +} from './types.js' + +/** + * The session class exposes the API to read and write values to + * the session store. + * + * A session instance is isolated between requests but + * uses a centralized persistence store and + */ +export class Session { + #config: SessionConfig + #store: SessionStoreContract + #emitter: EmitterService + #ctx: HttpContext + #readonly: boolean = false + + /** + * Session values store + */ + #valuesStore?: ValuesStore + + /** + * Session id refers to the session id that will be committed + * as a cookie during the response. + */ + #sessionId: string + + /** + * Session id from cookie refers to the value we read from the + * cookie during the HTTP request. + * + * This only might not exist during the first request. Also during + * session id re-generation, this value will be different from + * the session id. + */ + #sessionIdFromCookie?: string + + /** + * Store of flash messages that be written during the + * HTTP request + */ + responseFlashMessages = new ValuesStore({}) + + /** + * Store of flash messages for the current HTTP request. + */ + flashMessages = new ValuesStore({}) + + /** + * The key to use for storing flash messages inside + * the session store. + */ + flashKey: string = '__flash__' + + /** + * Session id for the current HTTP request + */ + get sessionId() { + return this.#sessionId + } + + /** + * A boolean to know if a fresh session is created during + * the request + */ + get fresh(): boolean { + return this.#sessionIdFromCookie === undefined + } + + /** + * A boolean to know if session is in readonly + * state + */ + get readonly() { + return this.#readonly + } + + /** + * A boolean to know if session store has been initiated + */ + get initiated() { + return !!this.#valuesStore + } + + /** + * A boolean to know if the session id has been re-generated + * during the current request + */ + get hasRegeneratedSession() { + return !!(this.#sessionIdFromCookie && this.#sessionIdFromCookie !== this.#sessionId) + } + + /** + * A boolean to know if the session store is empty + */ + get isEmpty() { + return this.#valuesStore?.isEmpty ?? true + } + + /** + * A boolean to know if the session store has been + * modified + */ + get hasBeenModified() { + return this.#valuesStore?.hasBeenModified ?? false + } + + constructor( + config: SessionConfig, + storeFactory: SessionStoreFactory, + emitter: EmitterService, + ctx: HttpContext + ) { + this.#ctx = ctx + this.#config = config + this.#emitter = emitter + this.#store = storeFactory(ctx, config) + this.#sessionIdFromCookie = ctx.request.cookie(config.cookieName, undefined) + this.#sessionId = this.#sessionIdFromCookie || cuid() + } + + /** + * Returns the flash messages store for a given + * mode + */ + #getFlashStore(mode: 'write' | 'read'): ValuesStore { + if (!this.#valuesStore) { + throw new errors.E_SESSION_NOT_READY() + } + + if (mode === 'write' && this.readonly) { + throw new errors.E_SESSION_NOT_MUTABLE() + } + + return this.responseFlashMessages + } + + /** + * Returns the store instance for a given mode + */ + #getValuesStore(mode: 'write' | 'read'): ValuesStore { + if (!this.#valuesStore) { + throw new errors.E_SESSION_NOT_READY() + } + + if (mode === 'write' && this.readonly) { + throw new errors.E_SESSION_NOT_MUTABLE() + } + + return this.#valuesStore + } + + /** + * Initiates the session store. The method results in a noop + * when called multiple times + */ + async initiate(readonly: boolean): Promise { + if (this.#valuesStore) { + return + } + + debug('initiating session (readonly: %s)', readonly) + + this.#readonly = readonly + const contents = await this.#store.read(this.#sessionId) + this.#valuesStore = new ValuesStore(contents) + + /** + * Extract flash messages from the store and keep a local + * copy of it. + */ + if (this.has(this.flashKey)) { + debug('reading flash data') + if (this.#readonly) { + this.flashMessages.update(this.get(this.flashKey, null)) + } else { + this.flashMessages.update(this.pull(this.flashKey, null)) + } + } + + /** + * Share session with the templates. We assume the view property + * is a reference to edge templates + */ + if ('view' in this.#ctx) { + this.#ctx.view.share({ + session: new ReadOnlyValuesStore(this.#valuesStore.all()), + flashMessages: new ReadOnlyValuesStore(this.flashMessages.all()), + old: function (key: string, defaultValue?: any) { + return this.flashMessages.get(key, defaultValue) + }, + }) + } + + this.#emitter.emit('session:initiated', { session: this }) + } + + /** + * Put a key-value pair to the session data store + */ + put(key: string, value: AllowedSessionValues) { + this.#getValuesStore('write').set(key, value) + } + + /** + * Check if a key exists inside the datastore + */ + has(key: string): boolean { + return this.#getValuesStore('read').has(key) + } + + /** + * Get the value of a key from the session datastore. + * You can specify a default value to use, when key + * does not exists or has undefined value. + */ + get(key: string, defaultValue?: any) { + return this.#getValuesStore('read').get(key, defaultValue) + } + + /** + * Get everything from the session store + */ + all() { + return this.#getValuesStore('read').all() + } + + /** + * Remove a key from the session datastore + */ + forget(key: string) { + return this.#getValuesStore('write').unset(key) + } + + /** + * Read value for a key from the session datastore + * and remove it simultaneously. + */ + pull(key: string, defaultValue?: any) { + return this.#getValuesStore('write').pull(key, defaultValue) + } + + /** + * Increment the value of a key inside the session + * store. + * + * A new key will be defined if does not exists already. + * The value of a new key will be 1 + */ + increment(key: string, steps: number = 1) { + return this.#getValuesStore('write').increment(key, steps) + } + + /** + * Increment the value of a key inside the session + * store. + * + * A new key will be defined if does not exists already. + * The value of a new key will be -1 + */ + decrement(key: string, steps: number = 1) { + return this.#getValuesStore('write').decrement(key, steps) + } + + /** + * Empty the session store + */ + clear() { + return this.#getValuesStore('write').clear() + } + + /** + * Add a key-value pair to flash messages + */ + flash(key: string, value: AllowedSessionValues): void + flash(keyValue: SessionData): void + flash(key: string | SessionData, value?: AllowedSessionValues): void { + if (typeof key === 'string') { + if (value) { + this.#getFlashStore('write').set(key, value) + } + } else { + this.#getFlashStore('write').merge(key) + } + } + + /** + * Flash errors to the errorsBag. You can read these + * errors via the "@error" tag. + * + * Appends new messages to the existing collection. + */ + flashErrors(errorsCollection: Record) { + this.flash({ errorsBag: errorsCollection }) + } + + /** + * Flash validation error messages. Make sure the error + * is an instance of VineJS ValidationException. + * + * Overrides existing inputErrors + */ + flashValidationErrors(error: HttpError) { + const errorsBag = error.messages.reduce((result: Record, message: any) => { + if (result[message.field]) { + result[message.field].push(message.message) + } else { + result[message.field] = [message.message] + } + return result + }, {}) + + this.flashExcept(['_csrf', '_method']) + + /** + * Adding to inputErrorsBag for "@inputError" tag + * to read validation errors + */ + this.flash('inputErrorsBag', errorsBag) + + /** + * For legacy support and not to break apps using + * the older version of @adonisjs/session package + */ + this.flash('errors', errorsBag) + } + + /** + * Flash form input data to the flash messages store + */ + flashAll() { + return this.#getFlashStore('write').set('input', this.#ctx.request.original()) + } + + /** + * Flash form input data (except some keys) to the flash messages store + */ + flashExcept(keys: string[]): void { + this.#getFlashStore('write').set('input', lodash.omit(this.#ctx.request.original(), keys)) + } + + /** + * Flash form input data (only some keys) to the flash messages store + */ + flashOnly(keys: string[]): void { + this.#getFlashStore('write').set('input', lodash.pick(this.#ctx.request.original(), keys)) + } + + /** + * Reflash messages from the last request in the current response + */ + reflash(): void { + this.#getFlashStore('write').set('reflashed', this.flashMessages.all()) + } + + /** + * Reflash messages (only some keys) from the last + * request in the current response + */ + reflashOnly(keys: string[]) { + this.#getFlashStore('write').set('reflashed', lodash.pick(this.flashMessages.all(), keys)) + } + + /** + * Reflash messages (except some keys) from the last + * request in the current response + */ + reflashExcept(keys: string[]) { + this.#getFlashStore('write').set('reflashed', lodash.omit(this.flashMessages.all(), keys)) + } + + /** + * Re-generate the session id and migrate data to it. + */ + regenerate() { + this.#sessionId = cuid() + } + + /** + * Commit session changes. No more mutations will be + * allowed after commit. + */ + async commit() { + if (!this.#valuesStore || this.readonly) { + return + } + + /** + * If the flash messages store is not empty, we should put + * its messages inside main session store. + */ + if (!this.responseFlashMessages.isEmpty) { + const { input, reflashed, ...others } = this.responseFlashMessages.all() + this.put(this.flashKey, { ...reflashed, ...input, ...others }) + } + + debug('committing session data') + + /** + * Touch the session id cookie to stay alive + */ + this.#ctx.response.cookie(this.#config.cookieName, this.#sessionId, this.#config.cookie!) + + /** + * Delete the session data when the session store + * is empty. + * + * Also we only destroy the session id we read from the cookie. + * If there was no session id in the cookie, there won't be + * any data inside the store either. + */ + if (this.isEmpty) { + if (this.#sessionIdFromCookie) { + await this.#store.destroy(this.#sessionIdFromCookie) + } + this.#emitter.emit('session:committed', { session: this }) + return + } + + /** + * Touch the store expiry when the session store was + * not modified. + */ + if (!this.hasBeenModified) { + if (this.#sessionIdFromCookie && this.#sessionIdFromCookie !== this.#sessionId) { + await this.#store.destroy(this.#sessionIdFromCookie) + await this.#store.write(this.#sessionId, this.#valuesStore.toJSON()) + this.#emitter.emit('session:migrated', { + fromSessionId: this.#sessionIdFromCookie, + toSessionId: this.sessionId, + session: this, + }) + } else { + await this.#store.touch(this.#sessionId) + } + this.#emitter.emit('session:committed', { session: this }) + return + } + + /** + * Otherwise commit to the session store + */ + if (this.#sessionIdFromCookie && this.#sessionIdFromCookie !== this.#sessionId) { + await this.#store.destroy(this.#sessionIdFromCookie) + await this.#store.write(this.#sessionId, this.#valuesStore.toJSON()) + this.#emitter.emit('session:migrated', { + fromSessionId: this.#sessionIdFromCookie, + toSessionId: this.sessionId, + session: this, + }) + } else { + await this.#store.write(this.#sessionId, this.#valuesStore.toJSON()) + } + + this.#emitter.emit('session:committed', { session: this }) + } +} diff --git a/src/session_middleware.ts b/src/session_middleware.ts new file mode 100644 index 0000000..11ba503 --- /dev/null +++ b/src/session_middleware.ts @@ -0,0 +1,93 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { EmitterService } from '@adonisjs/core/types' +import type { NextFn } from '@adonisjs/core/types/http' +import { ExceptionHandler, HttpContext } from '@adonisjs/core/http' + +import { Session } from './session.js' +import type { SessionConfig, SessionStoreFactory } from './types.js' + +/** + * HttpContext augmentations + */ +declare module '@adonisjs/core/http' { + export interface HttpContext { + session: Session + } +} + +/** + * Overwriting validation exception renderer + */ +const originalErrorHandler = ExceptionHandler.prototype.renderValidationErrorAsHTML +ExceptionHandler.macro('renderValidationErrorAsHTML', async function (error, ctx) { + if (ctx.session) { + ctx.session.flashValidationErrors(error) + ctx.response.redirect('back', true) + } else { + return originalErrorHandler(error, ctx) + } +}) + +/** + * Session middleware is used to initiate the session store + * and commit its values during an HTTP request + */ +export default class SessionMiddleware> { + #config: SessionConfig & { + store: keyof KnownStores + stores: KnownStores + } + #emitter: EmitterService + + constructor( + config: SessionConfig & { + store: keyof KnownStores + stores: KnownStores + }, + emitter: EmitterService + ) { + this.#config = config + this.#emitter = emitter + } + + async handle(ctx: HttpContext, next: NextFn) { + if (!this.#config.enabled) { + return next() + } + + ctx.session = new Session( + this.#config, + this.#config.stores[this.#config.store], // reference to store factory + this.#emitter, + ctx + ) + + /** + * Initiate session store + */ + await ctx.session.initiate(false) + + /** + * Call next middlewares or route handler + */ + const response = await next() + + /** + * Commit store mutations + */ + await ctx.session.commit() + + /** + * Return response + */ + return response + } +} diff --git a/src/stores/cookie.ts b/src/stores/cookie.ts new file mode 100644 index 0000000..54455d4 --- /dev/null +++ b/src/stores/cookie.ts @@ -0,0 +1,74 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { HttpContext } from '@adonisjs/core/http' +import type { CookieOptions } from '@adonisjs/core/types/http' + +import debug from '../debug.js' +import type { SessionData, SessionStoreContract } from '../types.js' + +/** + * Cookie store stores the session data inside an encrypted + * cookie. + */ +export class CookieStore implements SessionStoreContract { + #ctx: HttpContext + #config: Partial + + constructor(config: Partial, ctx: HttpContext) { + this.#config = config + this.#ctx = ctx + debug('initiating cookie store %O', this.#config) + } + + /** + * Read session value from the cookie + */ + read(sessionId: string): SessionData | null { + debug('cookie store: reading session data %s', sessionId) + + const cookieValue = this.#ctx.request.encryptedCookie(sessionId) + if (typeof cookieValue !== 'object') { + return null + } + + return cookieValue + } + + /** + * Write session values to the cookie + */ + write(sessionId: string, values: SessionData): void { + debug('cookie store: writing session data %s: %O', sessionId, values) + this.#ctx.response.encryptedCookie(sessionId, values, this.#config) + } + + /** + * Removes the session cookie + */ + destroy(sessionId: string): void { + debug('cookie store: destroying session data %s', sessionId) + if (this.#ctx.request.cookiesList()[sessionId]) { + this.#ctx.response.clearCookie(sessionId) + } + } + + /** + * Updates the cookie with existing cookie values + */ + touch(sessionId: string): void { + const value = this.read(sessionId) + debug('cookie store: touching session data %s', sessionId) + if (!value) { + return + } + + this.write(sessionId, value) + } +} diff --git a/src/stores/file.ts b/src/stores/file.ts new file mode 100644 index 0000000..e085c70 --- /dev/null +++ b/src/stores/file.ts @@ -0,0 +1,152 @@ +/** + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { Stats } from 'node:fs' +import { dirname, join } from 'node:path' +import string from '@poppinss/utils/string' +import { MessageBuilder } from '@adonisjs/core/helpers' +import { access, mkdir, readFile, rm, writeFile, utimes, stat } from 'node:fs/promises' + +import debug from '../debug.js' +import type { FileStoreConfig, SessionData, SessionStoreContract } from '../types.js' + +/** + * File store writes the session data on the file system as. Each session + * id gets its own file. + */ +export class FileStore implements SessionStoreContract { + #config: FileStoreConfig + #age: string | number + + constructor(config: FileStoreConfig, age: string | number) { + this.#config = config + this.#age = age + debug('initiating file store %O', this.#config) + } + + /** + * Returns an absolute path to the session id file + */ + #getFilePath(sessionId: string): string { + return join(this.#config.location, `${sessionId}.txt`) + } + + /** + * Check if a file exists at a given path or not + */ + async #pathExists(path: string) { + try { + await access(path) + return true + } catch { + return false + } + } + + /** + * Returns stats for a file and ignoring missing + * files. + */ + async #stats(path: string): Promise { + try { + const stats = await stat(path) + return stats + } catch { + return null + } + } + + /** + * Output file with contents to the given path + */ + async #outputFile(path: string, contents: string) { + const pathDirname = dirname(path) + + const dirExists = await this.#pathExists(pathDirname) + if (!dirExists) { + await mkdir(pathDirname, { recursive: true }) + } + + await writeFile(path, contents, 'utf-8') + } + + /** + * Reads the session data from the disk. + */ + async read(sessionId: string): Promise { + const filePath = this.#getFilePath(sessionId) + debug('file store: reading session data %', sessionId) + + /** + * Return null when no session id file exists in first + * place + */ + const stats = await this.#stats(filePath) + if (!stats) { + return null + } + + /** + * Check if the file has been expired and return null (if expired) + */ + const sessionWillExpireAt = stats.mtimeMs + string.milliseconds.parse(this.#age) + if (Date.now() > sessionWillExpireAt) { + debug('file store: expired session data %s', sessionId) + return null + } + + /** + * Reading the file contents if the file exists + */ + let contents = await readFile(filePath, 'utf-8') + contents = contents.trim() + if (!contents) { + return null + } + + /** + * Verify contents with the session id and return them as an object. The verify + * method can fail when the contents is not JSON> + */ + try { + return new MessageBuilder().verify(contents, sessionId) + } catch { + return null + } + } + + /** + * Writes the session data to the disk as a string + */ + async write(sessionId: string, values: SessionData): Promise { + debug('file store: writing session data %s: %O', sessionId, values) + + const filePath = this.#getFilePath(sessionId) + const message = new MessageBuilder().build(values, undefined, sessionId) + + await this.#outputFile(filePath, message) + } + + /** + * Removes the session file from the disk + */ + async destroy(sessionId: string): Promise { + debug('file store: destroying session data %s', sessionId) + await rm(this.#getFilePath(sessionId), { force: true }) + } + + /** + * Updates the session expiry by rewriting it to the + * persistence store + */ + async touch(sessionId: string): Promise { + debug('file store: touching session data %s', sessionId) + await utimes(this.#getFilePath(sessionId), new Date(), new Date()) + } +} diff --git a/src/stores/memory.ts b/src/stores/memory.ts new file mode 100644 index 0000000..3c4890a --- /dev/null +++ b/src/stores/memory.ts @@ -0,0 +1,40 @@ +/** + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { SessionData, SessionStoreContract } from '../types.js' + +/** + * Memory store is meant to be used for writing tests. + */ +export class MemoryStore implements SessionStoreContract { + static sessions: Map = new Map() + + /** + * Read session id value from the memory + */ + read(sessionId: string): SessionData | null { + return MemoryStore.sessions.get(sessionId) || null + } + + /** + * Save in memory value for a given session id + */ + write(sessionId: string, values: SessionData): void { + MemoryStore.sessions.set(sessionId, values) + } + + /** + * Cleanup for a single session + */ + destroy(sessionId: string): void { + MemoryStore.sessions.delete(sessionId) + } + + touch(): void {} +} diff --git a/src/stores/redis.ts b/src/stores/redis.ts new file mode 100644 index 0000000..3be9ba4 --- /dev/null +++ b/src/stores/redis.ts @@ -0,0 +1,78 @@ +/** + * @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 { Connection } from '@adonisjs/redis/types' + +import debug from '../debug.js' +import type { SessionStoreContract, SessionData } from '../types.js' + +/** + * File store to read/write session to filesystem + */ +export class RedisStore implements SessionStoreContract { + #connection: Connection + #ttlSeconds: number + + constructor(connection: Connection, age: string | number) { + this.#connection = connection + this.#ttlSeconds = string.seconds.parse(age) + debug('initiating redis store') + } + + /** + * Returns file contents. A new file will be created if it's + * missing. + */ + async read(sessionId: string): Promise { + debug('redis store: reading session data %s', sessionId) + + const contents = await this.#connection.get(sessionId) + if (!contents) { + return null + } + + /** + * Verify contents with the session id and return them as an object. The verify + * method can fail when the contents is not JSON> + */ + try { + return new MessageBuilder().verify(contents, sessionId) + } catch { + return null + } + } + + /** + * Write session values to a file + */ + async write(sessionId: string, values: Object): Promise { + debug('redis store: writing session data %s, %O', sessionId, values) + + const message = new MessageBuilder().build(values, undefined, sessionId) + await this.#connection.setex(sessionId, this.#ttlSeconds, message) + } + + /** + * Cleanup session file by removing it + */ + async destroy(sessionId: string): Promise { + debug('redis store: destroying session data %s', sessionId) + await this.#connection.del(sessionId) + } + + /** + * Updates the value expiry + */ + async touch(sessionId: string): Promise { + debug('redis store: touching session data %s', sessionId) + await this.#connection.expire(sessionId, this.#ttlSeconds) + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..dc747c3 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,111 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { HttpContext } from '@adonisjs/core/http' +import { RedisConnections } from '@adonisjs/redis/types' +import type { CookieOptions } from '@adonisjs/core/types/http' + +/** + * The values allowed by the `session.put` method + */ +export type AllowedSessionValues = string | boolean | number | object | Date | Array +export type SessionData = Record + +/** + * Session stores must implement the session store contract. + */ +export interface SessionStoreContract { + /** + * The read method is used to read the data from the persistence + * store and return it back as an object + */ + read(sessionId: string): Promise | SessionData | null + + /** + * The write method is used to write the session data into the + * persistence store. + */ + write(sessionId: string, data: SessionData): Promise | void + + /** + * The destroy method is used to destroy the session by removing + * its data from the persistence store + */ + destroy(sessionId: string): Promise | void + + /** + * The touch method should update the lifetime of session id without + * making changes to the session data. + */ + touch(sessionId: string): Promise | void +} + +/** + * Base configuration for managing sessions without + * stores. + */ +export interface SessionConfig { + /** + * Enable/disable sessions temporarily + */ + enabled: boolean + + /** + * The name of the cookie for storing the session id. + */ + cookieName: string + + /** + * When set to true, the session id cookie will be removed + * when the user closes the browser. + * + * However, the persisted data will continue to exist until + * it gets expired. + */ + clearWithBrowser: boolean + + /** + * How long the session data should be kept alive without any + * activity. + * + * The session id cookie will also live for the same duration, unless + * "clearWithBrowser" is enabled + * + * The value should be a time expression or a number in seconds + */ + age: string | number + + /** + * Configuration used by the cookie driver and for storing the + * session id cookie. + */ + cookie: Omit, 'maxAge' | 'expires'> +} + +/** + * Configuration used by the file store. + */ +export type FileStoreConfig = { + location: string +} + +/** + * Configuration used by the redis store. + */ +export type RedisStoreConfig = { + connection: keyof RedisConnections +} + +/** + * Factory function to instantiate session store + */ +export type SessionStoreFactory = ( + ctx: HttpContext, + sessionConfig: SessionConfig +) => SessionStoreContract diff --git a/src/Store/index.ts b/src/values_store.ts similarity index 51% rename from src/Store/index.ts rename to src/values_store.ts index 8c9941a..a65bf71 100644 --- a/src/Store/index.ts +++ b/src/values_store.ts @@ -1,70 +1,133 @@ /* * @adonisjs/redis * - * (c) Harminder Virk + * (c) AdonisJS * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ -/// - -import { Exception, lodash } from '@poppinss/utils' -import { AllowedSessionValues, StoreContract } from '@ioc:Adonis/Addons/Session' +import lodash from '@poppinss/utils/lodash' +import { RuntimeException } from '@poppinss/utils' +import type { AllowedSessionValues, SessionData } from './types.js' /** - * Session store to mutate and access values from the session object + * Readonly session store */ -export class Store implements StoreContract { +export class ReadOnlyValuesStore { /** * Underlying store values */ - private values: { [key: string]: any } + protected values: SessionData + + /** + * Find if store is empty or not + */ + get isEmpty() { + return !this.values || Object.keys(this.values).length === 0 + } - constructor(values: { [key: string]: any } | null) { + constructor(values: SessionData | null) { this.values = values || {} } /** - * Find if store is empty or not + * Get value for a given key */ - public get isEmpty() { - return !this.values || Object.keys(this.values).length === 0 + get(key: string | string[], defaultValue?: any): any { + const value = lodash.get(this.values, key) + if (defaultValue !== undefined && (value === null || value === undefined)) { + return defaultValue + } + + return value } /** - * Set key/value pair + * A boolean to know if value exists. Extra guards to check + * arrays for it's length as well. */ - public set(key: string, value: AllowedSessionValues): void { - lodash.set(this.values, key, value) + has(key: string | string[], checkForArraysLength: boolean = true): boolean { + const value = this.get(key) + if (!Array.isArray(value)) { + return !!value + } + + return checkForArraysLength ? value.length > 0 : !!value } /** - * Get value for a given key + * Get all values */ - public get(key: string, defaultValue?: any): any { - return lodash.get(this.values, key, defaultValue) + all(): any { + return this.values } /** - * Remove key + * Returns object representation of values */ - public unset(key: string): void { - lodash.unset(this.values, key) + toObject() { + return this.all() } /** - * Reset store by clearing it's values. + * Returns the store values */ - public clear(): void { - this.update({}) + toJSON(): any { + return this.all() + } + + /** + * Returns string representation of the store + */ + toString() { + return JSON.stringify(this.all()) + } +} + +/** + * Session store encapsulates the session data and offers a + * declarative API to mutate it. + */ +export class ValuesStore extends ReadOnlyValuesStore { + /** + * A boolean to know if store has been + * modified + */ + #modified: boolean = false + + constructor(values: SessionData | null) { + super(values) + } + + /** + * Find if the store has been modified. + */ + get hasBeenModified(): boolean { + return this.#modified + } + + /** + * Set key/value pair + */ + set(key: string | string[], value: AllowedSessionValues): void { + this.#modified = true + lodash.set(this.values, key, value) + } + + /** + * Remove key + */ + unset(key: string | string[]): void { + this.#modified = true + lodash.unset(this.values, key) } /** * Pull value from the store. It is same as calling * store.get and then store.unset */ - public pull(key: string, defaultValue?: any): any { + pull(key: string | string[], defaultValue?: any): any { return ((value): any => { this.unset(key) return value @@ -75,10 +138,10 @@ export class Store implements StoreContract { * Increment number. The method raises an error when * nderlying value is not a number */ - public increment(key: string, steps: number = 1): void { + increment(key: string | string[], steps: number = 1): void { const value = this.get(key, 0) if (typeof value !== 'number') { - throw new Exception(`Cannot increment "${key}", since original value is not a number`) + throw new RuntimeException(`Cannot increment "${key}". Existing value is not a number`) } this.set(key, value + steps) @@ -88,67 +151,35 @@ export class Store implements StoreContract { * Increment number. The method raises an error when * nderlying value is not a number */ - public decrement(key: string, steps: number = 1): void { + decrement(key: string | string[], steps: number = 1): void { const value = this.get(key, 0) if (typeof value !== 'number') { - throw new Exception(`Cannot increment "${key}", since original value is not a number`) + throw new RuntimeException(`Cannot decrement "${key}". Existing value is not a number`) } this.set(key, value - steps) } /** - * Overwrite the underlying values object + * Overwrite existing store data with new values. */ - public update(values: { [key: string]: any }): void { + update(values: { [key: string]: any }): void { + this.#modified = true this.values = values } /** * Update to merge values */ - public merge(values: { [key: string]: any }): any { + merge(values: { [key: string]: any }): any { + this.#modified = true lodash.merge(this.values, values) } /** - * A boolean to know if value exists. Extra guards to check - * arrays for it's length as well. - */ - public has(key: string, checkForArraysLength: boolean = true): boolean { - const value = this.get(key) - if (!Array.isArray(value)) { - return !!value - } - - return checkForArraysLength ? value.length > 0 : !!value - } - - /** - * Get all values - */ - public all(): any { - return this.values - } - - /** - * Returns object representation of values - */ - public toObject() { - return this.all() - } - - /** - * Returns the store values - */ - public toJSON(): any { - return this.all() - } - - /** - * Returns string representation of the store + * Reset store by clearing it's values. */ - public toString() { - return JSON.stringify(this.all()) + clear(): void { + this.update({}) } } diff --git a/stubs/config/session.stub b/stubs/config/session.stub new file mode 100644 index 0000000..6414b49 --- /dev/null +++ b/stubs/config/session.stub @@ -0,0 +1,51 @@ +{{{ + exports({ to: app.configPath('session.ts') }) +}}} +import env from '#start/env' +import app from '@adonisjs/core/services/app' +import { defineConfig, stores } from '@adonisjs/session' + +const sessionConfig = defineConfig({ + enabled: true, + cookieName: 'adonis-session', + + /** + * When set to true, the session id cookie will be deleted + * once the user closes the browser. + */ + clearWithBrowser: false, + + /** + * Define how long to keep the session data alive without + * any activity. + */ + age: '2h', + + /** + * Configuration for session cookie and the + * cookie store + */ + cookie: { + path: '/', + httpOnly: true, + secure: app.inProduction, + sameSite: 'lax', + }, + + /** + * The store to use. Make sure to validate the environment + * variable in order to infer the store name without any + * errors. + */ + store: env.get('SESSION_DRIVER'), + + /** + * List of configured stores. Refer documentation to see + * list of available stores and their config. + */ + stores: { + cookie: stores.cookie(), + } +}) + +export default sessionConfig diff --git a/stubs/main.ts b/stubs/main.ts new file mode 100644 index 0000000..cd7dca9 --- /dev/null +++ b/stubs/main.ts @@ -0,0 +1,12 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { getDirname } from '@poppinss/utils' + +export const stubsRoot = getDirname(import.meta.url) diff --git a/templates/session.txt b/templates/session.txt deleted file mode 100644 index 3d359cc..0000000 --- a/templates/session.txt +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Config source: https://git.io/JeYHp - * - * Feel free to let us know via PR, if you find something broken in this config - * file. - */ - -import Env from '@ioc:Adonis/Core/Env' -import Application from '@ioc:Adonis/Core/Application' -import { sessionConfig } from '@adonisjs/session/build/config' - -export default sessionConfig({ - /* - |-------------------------------------------------------------------------- - | Enable/Disable sessions - |-------------------------------------------------------------------------- - | - | Setting the following property to "false" will disable the session for the - | entire application - | - */ - enabled: true, - - /* - |-------------------------------------------------------------------------- - | Driver - |-------------------------------------------------------------------------- - | - | The session driver to use. You can choose between one of the following - | drivers. - | - | - cookie (Uses signed cookies to store session values) - | - file (Uses filesystem to store session values) - | - redis (Uses redis. Make sure to install "@adonisjs/redis" as well) - | - | Note: Switching drivers will make existing sessions invalid. - | - */ - driver: Env.get('SESSION_DRIVER'), - - /* - |-------------------------------------------------------------------------- - | Cookie name - |-------------------------------------------------------------------------- - | - | The name of the cookie that will hold the session id. - | - */ - cookieName: 'adonis-session', - - /* - |-------------------------------------------------------------------------- - | Clear session when browser closes - |-------------------------------------------------------------------------- - | - | Whether or not you want to destroy the session when browser closes. Setting - | this value to `true` will ignore the `age`. - | - */ - clearWithBrowser: false, - - /* - |-------------------------------------------------------------------------- - | Session age - |-------------------------------------------------------------------------- - | - | The duration for which session stays active after no activity. A new HTTP - | request to the server is considered as activity. - | - | The value can be a number in milliseconds or a string that must be valid - | as per https://npmjs.org/package/ms package. - | - | Example: `2 days`, `2.5 hrs`, `1y`, `5s` and so on. - | - */ - age: '2h', - - /* - |-------------------------------------------------------------------------- - | Cookie values - |-------------------------------------------------------------------------- - | - | The cookie settings are used to setup the session id cookie and also the - | driver will use the same values. - | - */ - cookie: { - path: '/', - httpOnly: true, - sameSite: false, - }, - - /* - |-------------------------------------------------------------------------- - | Configuration for the file driver - |-------------------------------------------------------------------------- - | - | The file driver needs absolute path to the directory in which sessions - | must be stored. - | - */ - file: { - location: Application.tmpPath('sessions'), - }, - - /* - |-------------------------------------------------------------------------- - | Redis driver - |-------------------------------------------------------------------------- - | - | The redis connection you want session driver to use. The same connection - | must be defined inside `config/redis.ts` file as well. - | - */ - redisConnection: 'local', -}) diff --git a/test-helpers/index.ts b/test-helpers/index.ts deleted file mode 100644 index 6cdb1c1..0000000 --- a/test-helpers/index.ts +++ /dev/null @@ -1,135 +0,0 @@ -/* - * @adonisjs/session - * - * (c) Harminder Virk - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -import { join } from 'path' -import { Filesystem } from '@poppinss/dev-utils' -import { SessionConfig } from '@ioc:Adonis/Addons/Session' -import { Application } from '@adonisjs/core/build/standalone' -import { RedisManagerContract } from '@ioc:Adonis/Addons/Redis' -import { ApplicationContract } from '@ioc:Adonis/Core/Application' -import { RedisManager } from '@adonisjs/redis/build/src/RedisManager/index.js' - -export const fs = new Filesystem(join(__dirname, 'app')) - -/** - * Session default config - */ -export const sessionConfig: SessionConfig = { - enabled: true, - driver: 'cookie', - cookieName: 'adonis-session', - clearWithBrowser: false, - age: 3000, - cookie: { - path: '/', - }, -} - -export async function setup(config?: any) { - await fs.add('.env', '') - await fs.add( - 'config/app.ts', - ` - export const appKey = '${Math.random().toFixed(36).substring(2, 38)}', - export const http = { - cookie: {}, - trustProxy: () => true, - } - ` - ) - - await fs.add( - 'config/session.ts', - ` - const sessionConfig = ${JSON.stringify(config || sessionConfig, null, 2)} - export default sessionConfig - ` - ) - - const app = new Application(fs.basePath, 'web', { - providers: [ - '@adonisjs/core', - '../../providers/SessionProvider', - '@japa/preset-adonis/TestsProvider', - ], - }) - - await app.setup() - await app.registerProviders() - await app.bootProviders() - - return app -} - -/** - * Sleep for a while - */ -export function sleep(time: number): Promise { - return new Promise((resolve) => setTimeout(resolve, time)) -} - -/** - * Signs value to be set as cookie header - */ -export function signCookie(app: ApplicationContract, value: any, name: string) { - const encryption = app.container.use('Adonis/Core/Encryption') - return `${name}=s:${encryption.verifier.sign(value, undefined, name)}` -} - -/** - * Encrypt value to be set as cookie header - */ -export function encryptCookie(app: ApplicationContract, value: any, name: string) { - const encryption = app.container.use('Adonis/Core/Encryption') - return `${name}=e:${encryption.encrypt(value, undefined, name)}` -} - -/** - * Decrypt cookie - */ -export function decryptCookie(app: ApplicationContract, header: any, name: string) { - const encryption = app.container.use('Adonis/Core/Encryption') - const cookieValue = decodeURIComponent(header['set-cookie'][0].split(';')[0]) - .replace(`${name}=`, '') - .slice(2) - - return encryption.decrypt(cookieValue, name) -} - -/** - * Unsign cookie - */ -export function unsignCookie(app: ApplicationContract, header: any, name: string) { - const encryption = app.container.use('Adonis/Core/Encryption') - - const cookieValue = decodeURIComponent(header['set-cookie'][0].split(';')[0]) - .replace(`${name}=`, '') - .slice(2) - - return encryption.verifier.unsign(cookieValue, name) -} - -/** - * Reference to the redis manager - */ -export function getRedisManager(application: ApplicationContract) { - return new RedisManager( - application, - { - connection: 'session', - connections: { - session: { - host: process.env.REDIS_HOST || '0.0.0.0', - port: process.env.REDIS_PORT || 6379, - }, - }, - } as any, - application.container.use('Adonis/Core/Event') - ) as unknown as RedisManagerContract -} diff --git a/test/cookie-driver.spec.ts b/test/cookie-driver.spec.ts deleted file mode 100644 index 10c0b28..0000000 --- a/test/cookie-driver.spec.ts +++ /dev/null @@ -1,118 +0,0 @@ -/* - * @adonisjs/session - * - * (c) Harminder Virk - * - * 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 supertest from 'supertest' -import { createServer } from 'http' - -import { CookieDriver } from '../src/Drivers/Cookie' -import { setup, fs, encryptCookie, decryptCookie, sessionConfig } from '../test-helpers' - -test.group('Cookie driver', (group) => { - group.each.teardown(async () => { - await fs.cleanup() - }) - - test('return null object when cookie is missing', async ({ assert }) => { - assert.plan(1) - - const app = await setup() - const sessionId = '1234' - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const session = new CookieDriver(sessionConfig, ctx) - const value = session.read(sessionId) - assert.isNull(value) - res.end() - }) - - await supertest(server).get('/') - }) - - test('return empty object when cookie value is invalid', async ({ assert }) => { - assert.plan(1) - - const app = await setup() - const sessionId = '1234' - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const session = new CookieDriver(sessionConfig, ctx) - const value = session.read(sessionId) - assert.isNull(value) - res.end() - }) - - await supertest(server).get('/').set('cookie', '1234=hello-world') - }) - - test('return cookie values as an object', async ({ assert }) => { - const app = await setup() - const sessionId = '1234' - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - const session = new CookieDriver(sessionConfig, ctx) - const value = session.read(sessionId) - - res.writeHead(200, { 'content-type': 'application/json' }) - res.write(JSON.stringify(value)) - res.end() - }) - - const { body } = await supertest(server) - .get('/') - .set('cookie', encryptCookie(app, { message: 'hello-world' }, sessionId)) - - assert.deepEqual(body, { message: 'hello-world' }) - }) - - test('write cookie value', async ({ assert }) => { - const app = await setup() - const sessionId = '1234' - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - const session = new CookieDriver(sessionConfig, ctx) - session.write(sessionId, { message: 'hello-world' }) - - ctx.response.send('') - ctx.response.finish() - }) - - const { header } = await supertest(server).get('/') - assert.deepEqual(decryptCookie(app, header, sessionId), { message: 'hello-world' }) - }) - - test('update cookie with existing value', async ({ assert }) => { - const app = await setup() - const sessionId = '1234' - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - const session = new CookieDriver(sessionConfig, ctx) - session.touch(sessionId) - - ctx.response.send('') - ctx.response.finish() - }) - - const { header } = await supertest(server) - .get('/') - .set('cookie', encryptCookie(app, { message: 'hello-world' }, sessionId)) - - assert.deepEqual(decryptCookie(app, header, sessionId), { message: 'hello-world' }) - }) -}) diff --git a/test/file-driver.spec.ts b/test/file-driver.spec.ts deleted file mode 100644 index 9804b89..0000000 --- a/test/file-driver.spec.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * @adonisjs/session - * - * (c) Harminder Virk - * - * 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 { join } from 'path' -import { Filesystem } from '@poppinss/dev-utils' - -import { FileDriver } from '../src/Drivers/File' -import { sleep, sessionConfig } from '../test-helpers' - -const fs = new Filesystem() -const config = Object.assign({}, sessionConfig, { - driver: 'file', - file: { - location: fs.basePath, - }, -}) - -test.group('File driver', (group) => { - group.each.teardown(async () => { - await fs.cleanup() - }) - - test('return null when file is missing', async ({ assert }) => { - const sessionId = '1234' - const session = new FileDriver(config) - const value = await session.read(sessionId) - assert.isNull(value) - }) - - test('write session value to the file', async ({ assert }) => { - const sessionId = '1234' - const session = new FileDriver(config) - await session.write(sessionId, { message: 'hello-world' }) - - const contents = await fs.get('1234.txt') - assert.deepEqual(JSON.parse(contents), { - message: { message: 'hello-world' }, - purpose: '1234', - }) - }) - - test('get session existing value', async ({ assert }) => { - const sessionId = '1234' - const session = new FileDriver(config) - await session.write(sessionId, { message: 'hello-world' }) - const value = await session.read(sessionId) - assert.deepEqual(value, { message: 'hello-world' }) - }) - - test('remove session file', async ({ assert }) => { - const sessionId = '1234' - const session = new FileDriver(config) - await session.write(sessionId, { message: 'hello-world' }) - await session.destroy(sessionId) - - const exists = await fs.fsExtra.pathExists(join(fs.basePath, '1234.txt')) - assert.isFalse(exists) - }) - - test('update session expiry', async ({ assert }) => { - const sessionId = '1234' - - const session = new FileDriver(config) - await session.write(sessionId, { message: 'hello-world' }) - await sleep(1000) - - const { mtimeMs } = await fs.fsExtra.stat(join(fs.basePath, '1234.txt')) - assert.isBelow(mtimeMs, Date.now()) - - await session.touch(sessionId) - let { mtimeMs: newMtimeMs } = await fs.fsExtra.stat(join(fs.basePath, '1234.txt')) - assert.isAbove(newMtimeMs, mtimeMs) - }).timeout(0) -}) diff --git a/test/redis-driver.spec.ts b/test/redis-driver.spec.ts deleted file mode 100644 index fbcaeb6..0000000 --- a/test/redis-driver.spec.ts +++ /dev/null @@ -1,134 +0,0 @@ -/* - * @adonisjs/session - * - * (c) Harminder Virk - * - * 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 { RedisDriver } from '../src/Drivers/Redis' -import { fs, setup, sleep, sessionConfig, getRedisManager } from '../test-helpers' - -const config = Object.assign({}, sessionConfig, { driver: 'redis', redisConnection: 'session' }) - -test.group('Redis driver', (group) => { - group.each.teardown(async () => { - await fs.cleanup() - }) - - test('return null when value is missing', async ({ assert }) => { - const app = await setup() - - const sessionId = '1234' - const redis = getRedisManager(app) - const session = new RedisDriver(config, redis) - - const value = await session.read(sessionId) - await redis.disconnectAll() - assert.isNull(value) - }) - - test('write session value to the redis store', async ({ assert }) => { - const app = await setup() - - const sessionId = '1234' - const redis = getRedisManager(app) - - const session = new RedisDriver(config, redis) - await session.write(sessionId, { message: 'hello-world' }) - - const contents = await redis.connection('session').get('1234') - await redis.connection('session').del('1234') - await redis.disconnectAll() - - assert.deepEqual(JSON.parse(contents!), { - message: { message: 'hello-world' }, - purpose: '1234', - }) - }) - - test('get session existing value', async ({ assert }) => { - const app = await setup() - - const sessionId = '1234' - const redis = getRedisManager(app) - - await redis.connection('session').set( - '1234', - JSON.stringify({ - message: { message: 'hello-world' }, - purpose: '1234', - }) - ) - - const session = new RedisDriver(config, redis) - const contents = await session.read(sessionId) - await redis.connection('session').del('1234') - await redis.disconnectAll() - - assert.deepEqual(contents, { message: 'hello-world' }) - }) - - test('remove session', async ({ assert }) => { - const app = await setup() - - const sessionId = '1234' - const redis = getRedisManager(app) - - await redis.connection('session').set( - '1234', - JSON.stringify({ - message: { message: 'hello-world' }, - purpose: '1234', - }) - ) - - const session = new RedisDriver(config, redis) - let contents = await session.read(sessionId) - assert.deepEqual(contents, { message: 'hello-world' }) - - await session.destroy('1234') - contents = await session.read(sessionId) - - await redis.disconnectAll() - assert.isNull(contents) - }) - - test('update session expiry', async ({ assert }) => { - const app = await setup() - - const sessionId = '1234' - const redis = getRedisManager(app) - - const session = new RedisDriver(config, redis) - await redis.connection('session').set( - '1234', - JSON.stringify({ - message: { message: 'hello-world' }, - purpose: '1234', - }) - ) - - await sleep(1000) - - let expiry = await redis.connection('session').ttl('1234') - assert.isBelow(expiry, 3) - - /** - * Update expiry - */ - await session.touch(sessionId) - expiry = await redis.connection('session').ttl('1234') - assert.equal(expiry, 3) - - const contents = await session.read(sessionId) - await session.destroy('1234') - await redis.disconnectAll() - - assert.deepEqual(contents, { message: 'hello-world' }) - }).timeout(0) -}) diff --git a/test/session-client.spec.ts b/test/session-client.spec.ts deleted file mode 100644 index 9791746..0000000 --- a/test/session-client.spec.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * @adonisjs/session - * - * (c) Harminder Virk - * - * 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 supertest from 'supertest' -import { createServer } from 'http' -import setCookieParser from 'set-cookie-parser' - -import { MemoryDriver } from '../src/Drivers/Memory' -import { SessionManager } from '../src/SessionManager' -import { setup, fs, sessionConfig } from '../test-helpers' - -test.group('Session Client', (group) => { - group.each.teardown(async () => { - MemoryDriver.sessions = new Map() - await fs.cleanup() - }) - - test('set session using the session client', async ({ assert }) => { - assert.plan(1) - const app = await setup() - - const config = Object.assign({}, sessionConfig, { driver: 'memory', clearWithBrowser: true }) - const manager = new SessionManager(app, config) - - const client = manager.client() - client.set('username', 'virk') - const { signedSessionId, cookieName } = await client.commit() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const session = manager.create(ctx) - await session.initiate(false) - - assert.deepEqual(session.all(), { username: 'virk' }) - ctx.response.finish() - }) - - await supertest(server).get('/').set('Cookie', `${cookieName}=${signedSessionId}`) - }) - - test('set flash messages', async ({ assert }) => { - assert.plan(1) - const app = await setup() - - const config = Object.assign({}, sessionConfig, { driver: 'memory', clearWithBrowser: true }) - const manager = new SessionManager(app, config) - - const client = manager.client() - client.flashMessages.merge({ foo: 'bar' }) - const { signedSessionId, cookieName } = await client.commit() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const session = manager.create(ctx) - await session.initiate(false) - - assert.deepEqual(session.flashMessages.all(), { foo: 'bar' }) - ctx.response.finish() - }) - - await supertest(server).get('/').set('Cookie', `${cookieName}=${signedSessionId}`) - }) - - test('clear session store', async ({ assert }) => { - assert.plan(1) - const app = await setup() - - const config = Object.assign({}, sessionConfig, { driver: 'memory', clearWithBrowser: true }) - const manager = new SessionManager(app, config) - - const client = manager.client() - client.set('username', 'virk') - const { signedSessionId, cookieName } = await client.commit() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const session = manager.create(ctx) - await session.initiate(false) - - assert.deepEqual(session.all(), {}) - ctx.response.finish() - }) - - await client.forget() - await supertest(server).get('/').set('Cookie', `${cookieName}=${signedSessionId}`) - }) - - test('get session data from the driver', async ({ assert }) => { - const app = await setup() - - const config = Object.assign({}, sessionConfig, { driver: 'memory', clearWithBrowser: true }) - const manager = new SessionManager(app, config) - const cookieClient = app.container.resolveBinding('Adonis/Core/CookieClient') - - const client = manager.client() - client.set('username', 'virk') - const { signedSessionId, cookieName } = await client.commit() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const session = manager.create(ctx) - await session.initiate(false) - session.put('age', 22) - session.regenerate() - - await session.commit() - ctx.response.finish() - }) - - const response = await supertest(server) - .get('/') - .set('Cookie', `${cookieName}=${signedSessionId}`) - - const cookies = setCookieParser.parse(response.header['set-cookie'], { map: true }) - const parsedCookies = Object.keys(cookies).reduce((result, key) => { - const value = cookies[key] - value.value = cookieClient.parse(value.name, value.value) - result[key] = value - return result - }, {}) - - const { session, flashMessages } = await client.load(parsedCookies) - - assert.deepEqual(session, { username: 'virk', age: 22 }) - assert.isNull(flashMessages) - }) - - test('get flash messages from the driver', async ({ assert }) => { - const app = await setup() - - const config = Object.assign({}, sessionConfig, { driver: 'memory', clearWithBrowser: true }) - const manager = new SessionManager(app, config) - const cookieClient = app.container.resolveBinding('Adonis/Core/CookieClient') - - const client = manager.client() - const { signedSessionId, cookieName } = await client.commit() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const session = manager.create(ctx) - await session.initiate(false) - - session.flash({ foo: 'bar' }) - session.put('username', 'virk') - - await session.commit() - ctx.response.finish() - }) - - const response = await supertest(server) - .get('/') - .set('Cookie', `${cookieName}=${signedSessionId}`) - - const cookies = setCookieParser.parse(response.header['set-cookie'], { map: true }) - const parsedCookies = Object.keys(cookies).reduce((result, key) => { - const value = cookies[key] - value.value = cookieClient.parse(value.name, value.value) - result[key] = value - return result - }, {}) - - const { session, flashMessages } = await client.load(parsedCookies) - - assert.deepEqual(session, { username: 'virk' }) - assert.deepEqual(flashMessages, { foo: 'bar' }) - }) -}) diff --git a/test/session-manager.spec.ts b/test/session-manager.spec.ts deleted file mode 100644 index 42bd858..0000000 --- a/test/session-manager.spec.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * @adonisjs/session - * - * (c) Harminder Virk - * - * 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 supertest from 'supertest' -import { createServer } from 'http' -import { MessageBuilder } from '@poppinss/utils/build/helpers' - -import { Store } from '../src/Store' -import { SessionManager } from '../src/SessionManager' -import { setup, fs, sessionConfig, unsignCookie, getRedisManager } from '../test-helpers' - -test.group('Session Manager', (group) => { - group.each.teardown(async () => { - await fs.cleanup() - }) - - test('do not set maxAge when clearWithBrowser is true', async ({ assert }) => { - const app = await setup() - const config = Object.assign({}, sessionConfig, { clearWithBrowser: true }) - const manager = new SessionManager(app, config) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const session = manager.create(ctx) - await session.initiate(false) - - session.put('user', { username: 'virk' }) - await session.commit() - ctx.response.send('') - ctx.response.finish() - }) - - const { header } = await supertest(server).get('/') - assert.lengthOf(header['set-cookie'][0].split(';'), 2) - }) - - test('set maxAge when clearWithBrowser is false', async ({ assert }) => { - const app = await setup() - const manager = new SessionManager(app, sessionConfig) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - const session = manager.create(ctx) - await session.initiate(false) - - session.put('user', { username: 'virk' }) - await session.commit() - ctx.response.send('') - ctx.response.finish() - }) - - const { header } = await supertest(server).get('/') - assert.lengthOf(header['set-cookie'][0].split(';'), 3) - - const maxAge = header['set-cookie'][0].split(';')[1].replace(' Max-Age=', '') - assert.equal(maxAge, '3000') - }) - - test('use file driver to persist session value', async ({ assert }) => { - const app = await setup() - const config = Object.assign({}, sessionConfig, { - driver: 'file', - file: { - location: fs.basePath, - }, - }) - const manager = new SessionManager(app, config) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const session = manager.create(ctx) - await session.initiate(false) - - session.put('user', { username: 'virk' }) - await session.commit() - ctx.response.send('') - ctx.response.finish() - }) - - const { header } = await supertest(server).get('/') - - const sessionId = unsignCookie(app, header, sessionConfig.cookieName) - const sessionContents = await fs.get(`${sessionId}.txt`) - const sessionValues = new MessageBuilder().verify(sessionContents, sessionId) - assert.deepEqual(new Store(sessionValues).all(), { user: { username: 'virk' } }) - }) - - test('use redis driver to persist session value', async ({ assert }) => { - const app = await setup() - const config = Object.assign({}, sessionConfig, { - driver: 'redis', - redisConnection: 'session', - }) - - const redis = getRedisManager(app) - const manager = new SessionManager(app, config) - - app.container.singleton('Adonis/Addons/Redis', () => redis) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - const session = manager.create(ctx) - await session.initiate(false) - - session.put('user', { username: 'virk' }) - await session.commit() - ctx.response.send('') - ctx.response.finish() - }) - - const { header } = await supertest(server).get('/') - const sessionId = unsignCookie(app, header, sessionConfig.cookieName) - - const sessionContents = await redis.connection('session').get(sessionId) - const sessionValues = new MessageBuilder().verify(sessionContents, sessionId) - - await redis.connection('session').del(sessionId) - await redis.disconnectAll() - - assert.deepEqual(new Store(sessionValues).all(), { user: { username: 'virk' } }) - }) - - test('extend by adding a custom driver', async ({ assert }) => { - assert.plan(2) - - const app = await setup( - Object.assign({}, sessionConfig, { - driver: 'mongo', - }) - ) - - class MongoDriver { - public read() { - return {} - } - public write(_, data: any) { - assert.deepEqual(data, { name: 'virk' }) - } - public touch() {} - public destroy() {} - } - - app.container.singleton('Adonis/Addons/Redis', () => getRedisManager(app)) - app.container.use('Adonis/Addons/Session').extend('mongo', (manager) => { - assert.deepEqual(app.container.use('Adonis/Addons/Session'), manager) - return new MongoDriver() - }) - - const session = app.container - .use('Adonis/Addons/Session') - .create(app.container.use('Adonis/Core/HttpContext').create('/', {})) - - await session.initiate(false) - session.put('name', 'virk') - await session.commit() - }) -}) diff --git a/test/session-provider.spec.ts b/test/session-provider.spec.ts deleted file mode 100644 index d3a2069..0000000 --- a/test/session-provider.spec.ts +++ /dev/null @@ -1,193 +0,0 @@ -/* - * @adonisjs/session - * - * (c) Harminder Virk - * - * 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 { createServer } from 'http' -import { ApiClient } from '@japa/api-client' -import { fs, setup } from '../test-helpers' -import { MemoryDriver } from '../src/Drivers/Memory' -import { SessionManager } from '../src/SessionManager' - -test.group('Session Provider', (group) => { - group.each.teardown(async () => { - ApiClient.clearSetupHooks() - ApiClient.clearTeardownHooks() - ApiClient.clearRequestHandlers() - await fs.cleanup() - }) - - test('register session provider', async ({ assert }) => { - const app = await setup({ - driver: 'cookie', - }) - - assert.instanceOf(app.container.use('Adonis/Addons/Session'), SessionManager) - assert.deepEqual( - app.container.use('Adonis/Addons/Session'), - app.container.use('Adonis/Addons/Session') - ) - assert.deepEqual(app.container.use('Adonis/Addons/Session')['application'], app) - assert.equal(app.container.use('Adonis/Core/Server').hooks['hooks'].before.length, 1) - assert.equal(app.container.use('Adonis/Core/Server').hooks['hooks'].after.length, 1) - }) - - test('raise error when config is missing', async ({ assert }) => { - assert.plan(1) - - try { - await setup({}) - } catch (error) { - assert.equal( - error.message, - 'Invalid "session" config. Missing value for "driver". Make sure to set it inside the "config/session" file' - ) - } - }) - - test('do not register hooks when session is disabled', async ({ assert }) => { - const app = await setup({ - enabled: false, - driver: 'cookie', - }) - - assert.instanceOf(app.container.use('Adonis/Addons/Session'), SessionManager) - assert.deepEqual( - app.container.use('Adonis/Addons/Session'), - app.container.use('Adonis/Addons/Session') - ) - assert.deepEqual(app.container.use('Adonis/Addons/Session')['application'], app) - assert.equal(app.container.use('Adonis/Core/Server').hooks['hooks'].before.length, 0) - assert.equal(app.container.use('Adonis/Core/Server').hooks['hooks'].after.length, 0) - }) - - test('register test api request methods', async ({ assert }) => { - const app = await setup({ - driver: 'cookie', - }) - - assert.instanceOf(app.container.use('Adonis/Addons/Session'), SessionManager) - assert.deepEqual( - app.container.use('Adonis/Addons/Session'), - app.container.use('Adonis/Addons/Session') - ) - - assert.isTrue(app.container.use('Japa/Preset/ApiRequest').hasMacro('session')) - assert.isTrue(app.container.use('Japa/Preset/ApiRequest').hasMacro('flashMessages')) - assert.isTrue(app.container.use('Japa/Preset/ApiRequest').hasGetter('sessionClient')) - }) - - test('set session before making the api request', async ({ assert }) => { - const app = await setup({ - driver: 'memory', - cookieName: 'adonis-session', - }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - await ctx.session.initiate(false) - - try { - ctx.response.send(ctx.session.all()) - } catch (error) { - ctx.response.status(500).send(error.stack) - } - - ctx.response.finish() - }) - server.listen(3333) - - const client = new (app.container.use('Japa/Preset/ApiClient'))('http://localhost:3333') - const response = await client.get('/').session({ username: 'virk' }) - server.close() - - assert.deepEqual(response.status(), 200) - assert.deepEqual(response.body(), { username: 'virk' }) - }) - - test('get session data from the response', async ({ assert }) => { - const app = await setup({ - driver: 'memory', - cookieName: 'adonis-session', - }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - await ctx.session.initiate(false) - ctx.session.put('username', 'virk') - await ctx.session.commit() - - ctx.response.finish() - }) - server.listen(3333) - - const client = new (app.container.use('Japa/Preset/ApiClient'))('http://localhost:3333', assert) - const response = await client.get('/') - server.close() - - assert.equal(MemoryDriver.sessions.size, 0) - assert.deepEqual(response.status(), 200) - - response.assertSession('username', 'virk') - response.assertSessionMissing('age') - }) - - test('get flash messages from the response', async ({ assert }) => { - const app = await setup({ - driver: 'memory', - cookieName: 'adonis-session', - }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - await ctx.session.initiate(false) - ctx.session.flash({ username: 'virk' }) - await ctx.session.commit() - - ctx.response.finish() - }) - server.listen(3333) - - const client = new (app.container.use('Japa/Preset/ApiClient'))('http://localhost:3333', assert) - const response = await client.get('/') - server.close() - - assert.equal(MemoryDriver.sessions.size, 0) - assert.deepEqual(response.status(), 200) - - response.assertFlashMessage('username', 'virk') - response.assertFlashMissing('age') - }) - - test('destroy session when request fails', async ({ assert }) => { - const app = await setup({ - driver: 'memory', - cookieName: 'adonis-session', - }) - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - await ctx.session.initiate(false) - ctx.session.put('username', 'virk') - await ctx.session.commit() - - ctx.response.status(500).send('Server error') - ctx.response.finish() - }) - server.listen(3333) - - const client = new (app.container.use('Japa/Preset/ApiClient'))('http://localhost:3333', assert) - await assert.rejects(() => client.get('/')) - server.close() - - assert.equal(MemoryDriver.sessions.size, 0) - }) -}) diff --git a/test/session.spec.ts b/test/session.spec.ts deleted file mode 100644 index 05b9785..0000000 --- a/test/session.spec.ts +++ /dev/null @@ -1,754 +0,0 @@ -/** - * @adonisjs/session - * - * (c) Harminder Virk - * - * 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 supertest from 'supertest' -import { createServer } from 'http' - -import { Store } from '../src/Store' -import { Session } from '../src/Session' -import { MemoryDriver } from '../src/Drivers/Memory' -import { setup, fs, sessionConfig, unsignCookie, signCookie } from '../test-helpers/index' - -test.group('Session', (group) => { - group.each.teardown(async () => { - MemoryDriver.sessions.clear() - await fs.cleanup() - }) - - test("initiate session with fresh session id when there isn't any session", async ({ - assert, - }) => { - const app = await setup() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - const driver = new MemoryDriver() - const session = new Session(ctx, sessionConfig, driver) - await session.initiate(false) - - assert.isTrue(session.fresh) - assert.isTrue(session.initiated) - res.end() - }) - - await supertest(server).get('/') - }) - - test('initiate session with empty store when session id exists', async ({ assert }) => { - const app = await setup() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - const driver = new MemoryDriver() - const session = new Session(ctx, sessionConfig, driver) - await session.initiate(false) - - assert.isFalse(session.fresh) - assert.equal(session.sessionId, '1234') - assert.isTrue(session.initiated) - res.end() - }) - - await supertest(server) - .get('/') - .set('cookie', signCookie(app, '1234', sessionConfig.cookieName)) - }) - - test('write session values with driver on commit', async ({ assert }) => { - const app = await setup() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - const driver = new MemoryDriver() - const session = new Session(ctx, sessionConfig, driver) - await session.initiate(false) - - session.put('user', { username: 'virk' }) - await session.commit() - ctx.response.send('') - ctx.response.finish() - }) - - const { header } = await supertest(server).get('/') - - const sessionId = unsignCookie(app, header, sessionConfig.cookieName) - assert.exists(sessionId) - const session = MemoryDriver.sessions.get(sessionId)! - assert.deepEqual(new Store(session).all(), { user: { username: 'virk' } }) - }) - - test('re-use existing session id', async ({ assert }) => { - const app = await setup() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - const driver = new MemoryDriver() - const session = new Session(ctx, sessionConfig, driver) - await session.initiate(false) - - session.put('user', { username: 'virk' }) - await session.commit() - ctx.response.send('') - ctx.response.finish() - }) - - const { header } = await supertest(server) - .get('/') - .set('cookie', signCookie(app, '1234', sessionConfig.cookieName)) - - const sessionId = unsignCookie(app, header, sessionConfig.cookieName) - assert.equal(sessionId, '1234') - - const session = MemoryDriver.sessions.get('1234')! - assert.deepEqual(new Store(session).all(), { user: { username: 'virk' } }) - }) - - test('retain driver existing values', async ({ assert }) => { - const app = await setup() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - const driver = new MemoryDriver() - const session = new Session(ctx, sessionConfig, driver) - await session.initiate(false) - - session.put('user.username', 'virk') - await session.commit() - ctx.response.send('') - ctx.response.finish() - }) - - /** - * Initial driver value - */ - const store = new Store(null) - store.set('user.age', 22) - MemoryDriver.sessions.set('1234', store.toJSON()) - - /** - * Request - */ - const { header } = await supertest(server) - .get('/') - .set('cookie', signCookie(app, '1234', sessionConfig.cookieName)) - - const sessionId = unsignCookie(app, header, sessionConfig.cookieName) - assert.equal(sessionId, '1234') - - /** - * Ensure driver has existing + new values - */ - const session = MemoryDriver.sessions.get('1234')! - assert.deepEqual(new Store(session).all(), { user: { username: 'virk', age: 22 } }) - }) - - test('regenerate session id when regenerate method is called', async ({ assert }) => { - const app = await setup() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - const driver = new MemoryDriver() - const session = new Session(ctx, sessionConfig, driver) - await session.initiate(false) - - session.regenerate() - - session.put('user.username', 'virk') - await session.commit() - ctx.response.send('') - ctx.response.finish() - }) - - /** - * Initial driver value - */ - const store = new Store(null) - store.set('user.age', 22) - MemoryDriver.sessions.set('1234', store.toJSON()) - - const { header } = await supertest(server) - .get('/') - .set('cookie', signCookie(app, '1234', sessionConfig.cookieName)) - - const sessionId = unsignCookie(app, header, sessionConfig.cookieName) - assert.exists(sessionId) - assert.notEqual(sessionId, '1234') - - const session = MemoryDriver.sessions.get(sessionId)! - assert.equal(MemoryDriver.sessions.size, 1) - assert.deepEqual(new Store(session).all(), { user: { username: 'virk', age: 22 } }) - - /** - * Ensure old values have been cleared - */ - assert.isUndefined(MemoryDriver.sessions.get('1234')) - }) - - test('get session value', async ({ assert }) => { - const app = await setup() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - const driver = new MemoryDriver() - const session = new Session(ctx, sessionConfig, driver) - await session.initiate(false) - ctx.response.send(session.get('user.age')) - await session.commit() - ctx.response.finish() - }) - - /** - * Initial driver value - */ - const store = new Store(null) - store.set('user.age', 22) - MemoryDriver.sessions.set('1234', store.toJSON()) - - /** - * Request - */ - const { text } = await supertest(server) - .get('/') - .set('cookie', signCookie(app, '1234', sessionConfig.cookieName)) - - assert.equal(text, '22') - }) - - test('get nested value using form input syntax', async ({ assert }) => { - const app = await setup() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - const driver = new MemoryDriver() - const session = new Session(ctx, sessionConfig, driver) - await session.initiate(false) - ctx.response.send(session.get('user[age]')) - await session.commit() - ctx.response.finish() - }) - - /** - * Initial driver value - */ - const store = new Store(null) - store.set('user.age', 22) - MemoryDriver.sessions.set('1234', store.toJSON()) - - /** - * Request - */ - const { text } = await supertest(server) - .get('/') - .set('cookie', signCookie(app, '1234', sessionConfig.cookieName)) - - assert.equal(text, '22') - }) -}) - -test.group('Session | Flash', (group) => { - group.each.teardown(async () => { - MemoryDriver.sessions.clear() - await fs.cleanup() - }) - - test('set custom flash messages', async ({ assert }) => { - const app = await setup() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - const driver = new MemoryDriver() - const session = new Session(ctx, sessionConfig, driver) - await session.initiate(false) - - session.flash('success', 'User created succesfully') - await session.commit() - ctx.response.send('') - ctx.response.finish() - }) - - const { header } = await supertest(server).get('/') - - /** - * Ensure session id is changed - */ - const sessionId = unsignCookie(app, header, sessionConfig.cookieName) - assert.exists(sessionId) - const session = MemoryDriver.sessions.get(sessionId)! - - assert.deepEqual(new Store(session).all(), { - __flash__: { - success: 'User created succesfully', - }, - }) - }) - - test('flash input values', async ({ assert }) => { - const app = await setup() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - ctx.request.setInitialBody({ username: 'virk', age: 28 }) - - const driver = new MemoryDriver() - const session = new Session(ctx, sessionConfig, driver) - await session.initiate(false) - - session.flashAll() - await session.commit() - ctx.response.send('') - ctx.response.finish() - }) - - const { header } = await supertest(server).get('/') - - const sessionId = unsignCookie(app, header, sessionConfig.cookieName) - assert.exists(sessionId) - const session = MemoryDriver.sessions.get(sessionId)! - - assert.deepEqual(new Store(session).all(), { - __flash__: { - username: 'virk', - age: 28, - }, - }) - }) - - test('flash selected input values', async ({ assert }) => { - const app = await setup() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - ctx.request.setInitialBody({ - username: 'virk', - age: 28, - profile: { - twitterHandle: '@AmanVirk1', - }, - }) - - const driver = new MemoryDriver() - const session = new Session(ctx, sessionConfig, driver) - await session.initiate(false) - - session.flashOnly(['username', 'profile.twitterHandle']) - await session.commit() - ctx.response.send('') - ctx.response.finish() - }) - - const { header } = await supertest(server).get('/') - - const sessionId = unsignCookie(app, header, sessionConfig.cookieName) - assert.exists(sessionId) - - const session = MemoryDriver.sessions.get(sessionId)! - assert.deepEqual(new Store(session).all(), { - __flash__: { - username: 'virk', - profile: { - twitterHandle: '@AmanVirk1', - }, - }, - }) - }) - - test("flash all input values except the defined one's", async ({ assert }) => { - const app = await setup() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - ctx.request.setInitialBody({ - username: 'virk', - age: 28, - profile: { - twitterHandle: '@AmanVirk1', - }, - }) - - const driver = new MemoryDriver() - const session = new Session(ctx, sessionConfig, driver) - await session.initiate(false) - - session.flashExcept(['age']) - await session.commit() - ctx.response.send('') - ctx.response.finish() - }) - - const { header } = await supertest(server).get('/') - - const sessionId = unsignCookie(app, header, sessionConfig.cookieName) - assert.exists(sessionId) - const session = MemoryDriver.sessions.get(sessionId)! - - assert.deepEqual(new Store(session).all(), { - __flash__: { - username: 'virk', - profile: { - twitterHandle: '@AmanVirk1', - }, - }, - }) - }) - - test('flash input along with custom messages', async ({ assert }) => { - const app = await setup() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - ctx.request.setInitialBody({ - username: 'virk', - age: 28, - }) - - const driver = new MemoryDriver() - const session = new Session(ctx, sessionConfig, driver) - await session.initiate(false) - - session.flashExcept(['age']) - session.flash('success', 'User created') - await session.commit() - ctx.response.send('') - ctx.response.finish() - }) - - const { header } = await supertest(server).get('/') - - const sessionId = unsignCookie(app, header, sessionConfig.cookieName) - assert.exists(sessionId) - const session = MemoryDriver.sessions.get(sessionId)! - - assert.deepEqual(new Store(session).all(), { - __flash__: { - username: 'virk', - success: 'User created', - }, - }) - }) - - test('read old flash values', async ({ assert }) => { - const app = await setup() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - const driver = new MemoryDriver() - const session = new Session(ctx, sessionConfig, driver) - await session.initiate(false) - - assert.deepEqual(session.flashMessages.all(), { - username: 'virk', - success: 'User created', - }) - - await session.commit() - ctx.response.send('') - ctx.response.finish() - }) - - /** - * Initial driver value - */ - const store = new Store(null) - store.set('__flash__', { - username: 'virk', - success: 'User created', - }) - - MemoryDriver.sessions.set('1234', store.toJSON()) - - const { header } = await supertest(server) - .get('/') - .set('cookie', signCookie(app, '1234', sessionConfig.cookieName)) - - const sessionId = unsignCookie(app, header, sessionConfig.cookieName) - assert.exists(sessionId) - const session = MemoryDriver.sessions.get(sessionId)! - assert.deepEqual(new Store(session).all(), {}) - }) - - test('read selected old values', async ({ assert }) => { - const app = await setup() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - const driver = new MemoryDriver() - const session = new Session(ctx, sessionConfig, driver) - await session.initiate(false) - - assert.deepEqual(session.flashMessages.get('username'), 'virk') - - await session.commit() - ctx.response.send('') - ctx.response.finish() - }) - - /** - * Initial driver value - */ - const store = new Store(null) - store.set('__flash__', { - username: 'virk', - success: 'User created', - }) - - MemoryDriver.sessions.set('1234', store.toJSON()) - - const { header } = await supertest(server) - .get('/') - .set('cookie', signCookie(app, '1234', sessionConfig.cookieName)) - - const sessionId = unsignCookie(app, header, sessionConfig.cookieName) - assert.exists(sessionId) - const session = MemoryDriver.sessions.get(sessionId)! - assert.deepEqual(new Store(session).all(), {}) - }) - - test('flash custom messages as an object', async ({ assert }) => { - const app = await setup() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - const driver = new MemoryDriver() - const session = new Session(ctx, sessionConfig, driver) - await session.initiate(false) - - session.flash({ success: 'User created succesfully' }) - session.flash({ error: 'There was an error too :wink' }) - await session.commit() - ctx.response.send('') - ctx.response.finish() - }) - - const { header } = await supertest(server).get('/') - - const sessionId = unsignCookie(app, header, sessionConfig.cookieName) - assert.exists(sessionId) - const session = MemoryDriver.sessions.get(sessionId)! - - assert.deepEqual(new Store(session).all(), { - __flash__: { - success: 'User created succesfully', - error: 'There was an error too :wink', - }, - }) - }) - - test('always flash original input values', async ({ assert }) => { - const app = await setup() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - ctx.request.setInitialBody({ username: 'virk', age: 28 }) - ctx.request.updateBody({ username: 'nikk', age: 22 }) - - const driver = new MemoryDriver() - const session = new Session(ctx, sessionConfig, driver) - await session.initiate(false) - - session.flashAll() - await session.commit() - ctx.response.send('') - ctx.response.finish() - }) - - const { header } = await supertest(server).get('/') - - const sessionId = unsignCookie(app, header, sessionConfig.cookieName) - assert.exists(sessionId) - const session = MemoryDriver.sessions.get(sessionId)! - - assert.deepEqual(new Store(session).all(), { - __flash__: { - username: 'virk', - age: 28, - }, - }) - }) - - test('do not attempt to commit when initiate raises an exception', async ({ assert }) => { - assert.plan(3) - const app = await setup() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - ctx.request.setInitialBody({ username: 'virk', age: 28 }) - ctx.request.updateBody({ username: 'nikk', age: 22 }) - - const driver = new MemoryDriver() - driver.read = function () { - throw new Error('Blowup') - } - - const session = new Session(ctx, sessionConfig, driver) - - try { - await session.initiate(false) - } catch (error) { - assert.equal(error.message, 'Blowup') - } - - await session.commit() - ctx.response.send('') - ctx.response.finish() - }) - - const { header } = await supertest(server).get('/') - - const sessionId = unsignCookie(app, header, sessionConfig.cookieName) - assert.exists(sessionId) - const session = MemoryDriver.sessions.get(sessionId)! - - assert.deepEqual(new Store(session).all(), {}) - }) - - test('reflash existing flash values', async ({ assert }) => { - const app = await setup() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - const driver = new MemoryDriver() - const session = new Session(ctx, sessionConfig, driver) - await session.initiate(false) - session.reflash() - await session.commit() - ctx.response.send('') - ctx.response.finish() - }) - - /** - * Initial driver value - */ - const store = new Store(null) - store.set('__flash__', { - username: 'virk', - success: 'User created', - }) - - MemoryDriver.sessions.set('1234', store.toJSON()) - - const { header } = await supertest(server) - .get('/') - .set('cookie', signCookie(app, '1234', sessionConfig.cookieName)) - - const sessionId = unsignCookie(app, header, sessionConfig.cookieName) - assert.exists(sessionId) - const session = MemoryDriver.sessions.get(sessionId)! - assert.deepEqual(new Store(session).all(), { - __flash__: { - username: 'virk', - success: 'User created', - }, - }) - }) - - test('cherry pick keys during reflash', async ({ assert }) => { - const app = await setup() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - const driver = new MemoryDriver() - const session = new Session(ctx, sessionConfig, driver) - await session.initiate(false) - - session.reflashOnly(['username']) - - await session.commit() - ctx.response.send('') - ctx.response.finish() - }) - - /** - * Initial driver value - */ - const store = new Store(null) - store.set('__flash__', { - username: 'virk', - success: 'User created', - }) - - MemoryDriver.sessions.set('1234', store.toJSON()) - - const { header } = await supertest(server) - .get('/') - .set('cookie', signCookie(app, '1234', sessionConfig.cookieName)) - - const sessionId = unsignCookie(app, header, sessionConfig.cookieName) - assert.exists(sessionId) - const session = MemoryDriver.sessions.get(sessionId)! - assert.deepEqual(new Store(session).all(), { - __flash__: { - username: 'virk', - }, - }) - }) - - test('ignore keys during reflash', async ({ assert }) => { - const app = await setup() - - const server = createServer(async (req, res) => { - const ctx = app.container.use('Adonis/Core/HttpContext').create('/', {}, req, res) - - const driver = new MemoryDriver() - const session = new Session(ctx, sessionConfig, driver) - await session.initiate(false) - - session.reflashExcept(['username']) - - await session.commit() - ctx.response.send('') - ctx.response.finish() - }) - - /** - * Initial driver value - */ - const store = new Store(null) - store.set('__flash__', { - username: 'virk', - success: 'User created', - }) - - MemoryDriver.sessions.set('1234', store.toJSON()) - - const { header } = await supertest(server) - .get('/') - .set('cookie', signCookie(app, '1234', sessionConfig.cookieName)) - - const sessionId = unsignCookie(app, header, sessionConfig.cookieName) - assert.exists(sessionId) - const session = MemoryDriver.sessions.get(sessionId)! - assert.deepEqual(new Store(session).all(), { - __flash__: { - success: 'User created', - }, - }) - }) -}) diff --git a/test/store.spec.ts b/test/store.spec.ts deleted file mode 100644 index d0ed277..0000000 --- a/test/store.spec.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* - * @adonisjs/session - * - * (c) Harminder Virk - * - * 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 { Store } from '../src/Store' - -test.group('Store', () => { - test('return empty object for empty store', ({ assert }) => { - const store = new Store(null) - assert.deepEqual(store.toJSON(), {}) - }) - - test('mutate values inside store', ({ assert }) => { - const store = new Store({}) - store.set('username', 'virk') - assert.deepEqual(store.toJSON(), { username: 'virk' }) - }) - - test('mutate nested values inside store', ({ assert }) => { - const store = new Store({}) - store.set('user.username', 'virk') - assert.deepEqual(store.toJSON(), { user: { username: 'virk' } }) - }) - - test('remove value from store', ({ assert }) => { - const store = new Store(null) - store.set('user.username', 'virk') - store.unset('user.username') - assert.deepEqual(store.toJSON(), { user: {} }) - }) - - test('increment value inside store', ({ assert }) => { - const store = new Store(null) - store.set('user.age', 22) - store.increment('user.age') - assert.deepEqual(store.toJSON(), { user: { age: 23 } }) - }) - - test('decrement value inside store', ({ assert }) => { - const store = new Store(null) - store.set('user.age', 22) - store.decrement('user.age') - assert.deepEqual(store.toJSON(), { user: { age: 21 } }) - }) - - test('find if value exists in the store', ({ assert }) => { - const store = new Store({}) - assert.isFalse(store.has('username')) - - store.update({ username: 'virk' }) - assert.isTrue(store.has('username')) - }) - - test('check for arrays length', ({ assert }) => { - const store = new Store({}) - assert.isFalse(store.has('users')) - - store.update({ users: [] }) - assert.isFalse(store.has('users')) - - store.update({ users: ['virk'] }) - assert.isTrue(store.has('users')) - }) - - test('do not check for array length when explicitly said no', ({ assert }) => { - const store = new Store({}) - assert.isFalse(store.has('users')) - - store.update({ users: [] }) - assert.isTrue(store.has('users', false)) - - store.update({ users: ['virk'] }) - assert.isTrue(store.has('users')) - }) -}) diff --git a/tests/concurrent_session.spec.ts b/tests/concurrent_session.spec.ts new file mode 100644 index 0000000..3365504 --- /dev/null +++ b/tests/concurrent_session.spec.ts @@ -0,0 +1,371 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import supertest from 'supertest' +import { test } from '@japa/runner' +import { cuid } from '@adonisjs/core/helpers' +import { defineConfig } from '@adonisjs/redis' +import setCookieParser from 'set-cookie-parser' +import { Emitter } from '@adonisjs/core/events' +import { setTimeout } from 'node:timers/promises' +import { EventsList } from '@adonisjs/core/types' +import { AppFactory } from '@adonisjs/core/factories/app' +import { IncomingMessage, ServerResponse } from 'node:http' +import { RedisManagerFactory } from '@adonisjs/redis/factories' +import { CookieClient, HttpContext } from '@adonisjs/core/http' +import { EncryptionFactory } from '@adonisjs/core/factories/encryption' +import { HttpContextFactory, RequestFactory, ResponseFactory } from '@adonisjs/core/factories/http' + +import { Session } from '../src/session.js' +import { FileStore } from '../src/stores/file.js' +import { RedisStore } from '../src/stores/redis.js' +import { httpServer } from '../tests_helpers/index.js' +import { CookieStore } from '../src/stores/cookie.js' +import type { SessionConfig, SessionStoreContract } from '../src/types.js' + +const app = new AppFactory().create(new URL('./', import.meta.url), () => {}) +const emitter = new Emitter(app) +const encryption = new EncryptionFactory().create() +const cookieClient = new CookieClient(encryption) +const sessionConfig: SessionConfig = { + enabled: true, + age: '2 hours', + clearWithBrowser: false, + cookieName: 'adonis_session', + cookie: {}, +} + +const redisConfig = defineConfig({ + connection: 'main', + connections: { + main: { + host: process.env.REDIS_HOST || '0.0.0.0', + port: process.env.REDIS_PORT || 6379, + }, + }, +}) + +const redis = new RedisManagerFactory(redisConfig).create() + +/** + * Re-usable request handler that creates different session scanerios + * based upon the request URL. + */ +async function requestHandler( + req: IncomingMessage, + res: ServerResponse, + driver: (ctx: HttpContext) => SessionStoreContract +) { + try { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, driver, emitter, ctx) + await session.initiate(false) + + if (req.url === '/read-data') { + await session.commit() + + response.json(session.all()) + return response.finish() + } + + if (req.url === '/read-data-slowly') { + await setTimeout(2000) + await session.commit() + + response.json(session.all()) + return response.finish() + } + + if (req.url === '/write-data') { + session.put('username', 'virk') + await session.commit() + + response.json(session.all()) + return response.finish() + } + + if (req.url === '/write-data-slowly') { + await setTimeout(2000) + + session.put('email', 'foo@bar.com') + await session.commit() + + response.json(session.all()) + return response.finish() + } + } catch (error) { + res.writeHead(500) + res.write(error.stack) + res.end() + } +} + +test.group('Concurrency | cookie driver', () => { + test('concurrently read and read slowly', async ({ assert }) => { + let sessionId = cuid() + + const server = httpServer.create((req, res) => + requestHandler(req, res, (ctx) => new CookieStore(sessionConfig.cookie, ctx)) + ) + + const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` + const sessionStoreCookie = `${sessionId}=${cookieClient.encrypt(sessionId, { age: 22 })}` + + const responses = await Promise.all([ + supertest(server) + .get('/read-data') + .set('Cookie', `${sessionIdCookie}; ${sessionStoreCookie}`), + + supertest(server) + .get('/read-data-slowly') + .set('Cookie', `${sessionIdCookie}; ${sessionStoreCookie}`), + ]) + + /** + * Asserting store data when using cookie driver + */ + const cookies = setCookieParser.parse(responses[0].headers['set-cookie'], { map: true }) + const cookies1 = setCookieParser.parse(responses[1].headers['set-cookie'], { map: true }) + assert.deepEqual(cookieClient.decrypt(sessionId!, cookies[sessionId!].value), { + age: 22, + }) + assert.deepEqual(cookieClient.decrypt(sessionId!, cookies1[sessionId!].value), { + age: 22, + }) + }).timeout(6000) + + test('HAS RACE CONDITION: concurrently write and read slowly', async ({ assert }) => { + let sessionId = cuid() + + const server = httpServer.create((req, res) => + requestHandler(req, res, (ctx) => new CookieStore(sessionConfig.cookie, ctx)) + ) + + const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` + const sessionStoreCookie = `${sessionId}=${cookieClient.encrypt(sessionId, { age: 22 })}` + + const responses = await Promise.all([ + supertest(server) + .get('/read-data-slowly') + .set('Cookie', `${sessionIdCookie}; ${sessionStoreCookie}`), + + supertest(server) + .get('/write-data') + .set('Cookie', `${sessionIdCookie}; ${sessionStoreCookie}`), + ]) + + const cookies = setCookieParser.parse(responses[0].headers['set-cookie'], { map: true }) + const cookies1 = setCookieParser.parse(responses[1].headers['set-cookie'], { map: true }) + + /** + * Since this request finishes afterwards, it will overwrite the mutations + * from the /write-data endpoint. THIS IS A CONCURRENCY CONCERN + */ + assert.deepEqual(cookieClient.decrypt(sessionId!, cookies[sessionId!].value), { + age: 22, + }) + + assert.deepEqual(cookieClient.decrypt(sessionId!, cookies1[sessionId!].value), { + age: 22, + username: 'virk', + }) + }).timeout(6000) + + test('HAS RACE CONDITION: concurrently write and write slowly', async ({ assert }) => { + let sessionId = cuid() + + const server = httpServer.create((req, res) => + requestHandler(req, res, (ctx) => new CookieStore(sessionConfig.cookie, ctx)) + ) + + const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` + const sessionStoreCookie = `${sessionId}=${cookieClient.encrypt(sessionId, { age: 22 })}` + + const responses = await Promise.all([ + supertest(server) + .get('/write-data-slowly') + .set('Cookie', `${sessionIdCookie}; ${sessionStoreCookie}`), + + supertest(server) + .get('/write-data') + .set('Cookie', `${sessionIdCookie}; ${sessionStoreCookie}`), + ]) + + const cookies = setCookieParser.parse(responses[0].headers['set-cookie'], { map: true }) + const cookies1 = setCookieParser.parse(responses[1].headers['set-cookie'], { map: true }) + + /** + * Since this request finishes afterwards, it will overwrite the mutations + * from the /write-data endpoint. THIS IS A CONCURRENCY CONCERN + */ + assert.deepEqual(cookieClient.decrypt(sessionId!, cookies[sessionId!].value), { + age: 22, + email: 'foo@bar.com', + }) + + /** + * Same applies here. In short two concurrent write requests will mess up + * all the time + */ + assert.deepEqual(cookieClient.decrypt(sessionId!, cookies1[sessionId!].value), { + age: 22, + username: 'virk', + }) + }).timeout(6000) +}) + +test.group('Concurrency | file driver', () => { + test('concurrently read and read slowly', async ({ fs, assert }) => { + let sessionId = cuid() + + const fileDriver = new FileStore({ location: fs.basePath }, sessionConfig.age) + await fileDriver.write(sessionId, { age: 22 }) + + const server = httpServer.create((req, res) => requestHandler(req, res, () => fileDriver)) + + const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` + await Promise.all([ + supertest(server).get('/read-data').set('Cookie', `${sessionIdCookie};`), + supertest(server).get('/read-data-slowly').set('Cookie', `${sessionIdCookie};`), + ]) + + /** + * Asserting store data when using file driver + */ + await assert.fileEquals( + `${sessionId}.txt`, + JSON.stringify({ + message: { age: 22 }, + purpose: sessionId, + }) + ) + }).timeout(6000) + + test('concurrently write and read slowly', async ({ fs, assert }) => { + let sessionId = cuid() + + const fileDriver = new FileStore({ location: fs.basePath }, sessionConfig.age) + await fileDriver.write(sessionId, { age: 22 }) + + const server = httpServer.create((req, res) => requestHandler(req, res, () => fileDriver)) + const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` + + await Promise.all([ + supertest(server).get('/read-data-slowly').set('Cookie', `${sessionIdCookie}`), + supertest(server).get('/write-data').set('Cookie', `${sessionIdCookie}`), + ]) + + await assert.fileEquals( + `${sessionId}.txt`, + JSON.stringify({ + message: { age: 22, username: 'virk' }, + purpose: sessionId, + }) + ) + }).timeout(6000) + + test('HAS RACE CONDITON: concurrently write and write slowly', async ({ fs, assert }) => { + let sessionId = cuid() + + const fileDriver = new FileStore({ location: fs.basePath }, sessionConfig.age) + await fileDriver.write(sessionId, { age: 22 }) + + const server = httpServer.create((req, res) => requestHandler(req, res, () => fileDriver)) + + const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` + + await Promise.all([ + supertest(server).get('/write-data-slowly').set('Cookie', `${sessionIdCookie}`), + supertest(server).get('/write-data').set('Cookie', `${sessionIdCookie}`), + ]) + + await assert.fileEquals( + `${sessionId}.txt`, + JSON.stringify({ + message: { age: 22, email: 'foo@bar.com' }, + purpose: sessionId, + }) + ) + }).timeout(6000) +}) + +test.group('Concurrency | redis driver', (group) => { + group.tap((t) => { + t.skip(!!process.env.NO_REDIS, 'Redis not available in windows env') + }) + + test('concurrently read and read slowly', async ({ assert, cleanup }) => { + let sessionId = cuid() + cleanup(async () => { + await redisDriver.destroy(sessionId) + }) + + const redisDriver = new RedisStore(redis.connection('main'), sessionConfig.age) + await redisDriver.write(sessionId, { age: 22 }) + + const server = httpServer.create((req, res) => requestHandler(req, res, () => redisDriver)) + + const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` + await Promise.all([ + supertest(server).get('/read-data').set('Cookie', `${sessionIdCookie};`), + supertest(server).get('/read-data-slowly').set('Cookie', `${sessionIdCookie};`), + ]) + + /** + * Asserting store data when using file driver + */ + assert.deepEqual(await redisDriver.read(sessionId), { + age: 22, + }) + }).timeout(6000) + + test('concurrently write and read slowly', async ({ assert, cleanup }) => { + let sessionId = cuid() + cleanup(async () => { + await redisDriver.destroy(sessionId) + }) + + const redisDriver = new RedisStore(redis.connection('main'), sessionConfig.age) + await redisDriver.write(sessionId, { age: 22 }) + + const server = httpServer.create((req, res) => requestHandler(req, res, () => redisDriver)) + const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` + + await Promise.all([ + supertest(server).get('/read-data-slowly').set('Cookie', `${sessionIdCookie}`), + supertest(server).get('/write-data').set('Cookie', `${sessionIdCookie}`), + ]) + + assert.deepEqual(await redisDriver.read(sessionId), { age: 22, username: 'virk' }) + }).timeout(6000) + + test('HAS RACE CONDITON: concurrently write and write slowly', async ({ assert, cleanup }) => { + let sessionId = cuid() + cleanup(async () => { + await redisDriver.destroy(sessionId) + }) + + const redisDriver = new RedisStore(redis.connection('main'), sessionConfig.age) + await redisDriver.write(sessionId, { age: 22 }) + + const server = httpServer.create((req, res) => requestHandler(req, res, () => redisDriver)) + + const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` + + await Promise.all([ + supertest(server).get('/write-data-slowly').set('Cookie', `${sessionIdCookie}`), + supertest(server).get('/write-data').set('Cookie', `${sessionIdCookie}`), + ]) + + assert.deepEqual(await redisDriver.read(sessionId), { age: 22, email: 'foo@bar.com' }) + }).timeout(6000) +}) diff --git a/tests/configure.spec.ts b/tests/configure.spec.ts new file mode 100644 index 0000000..9d69a30 --- /dev/null +++ b/tests/configure.spec.ts @@ -0,0 +1,61 @@ +/* + * @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 { fileURLToPath } from 'node:url' +import { IgnitorFactory } from '@adonisjs/core/factories' +import Configure from '@adonisjs/core/commands/configure' + +const BASE_URL = new URL('./tmp/', import.meta.url) + +test.group('Configure', (group) => { + group.each.setup(({ context }) => { + context.fs.baseUrl = BASE_URL + context.fs.basePath = fileURLToPath(BASE_URL) + }) + + test('create config file and register provider', async ({ fs, assert }) => { + const ignitor = new IgnitorFactory() + .withCoreProviders() + .withCoreConfig() + .create(BASE_URL, { + importer: (filePath) => { + if (filePath.startsWith('./') || filePath.startsWith('../')) { + return import(new URL(filePath, BASE_URL).href) + } + + return import(filePath) + }, + }) + + await fs.create('.env', '') + await fs.createJson('tsconfig.json', {}) + await fs.create('start/env.ts', `export default Env.create(new URL('./'), {})`) + await fs.create('start/kernel.ts', `router.use([])`) + await fs.create('adonisrc.ts', `export default defineConfig({}) {}`) + + const app = ignitor.createApp('web') + await app.init() + await app.boot() + + const ace = await app.container.make('ace') + const command = await ace.create(Configure, ['../../index.js']) + await command.exec() + + await assert.fileExists('config/session.ts') + await assert.fileExists('adonisrc.ts') + await assert.fileContains('adonisrc.ts', '@adonisjs/session/session_provider') + await assert.fileContains('config/session.ts', 'defineConfig') + await assert.fileContains('.env', 'SESSION_DRIVER=cookie') + await assert.fileContains( + 'start/env.ts', + `SESSION_DRIVER: Env.schema.enum(['cookie', 'memory'] as const)` + ) + }).timeout(60 * 1000) +}) diff --git a/tests/define_config.spec.ts b/tests/define_config.spec.ts new file mode 100644 index 0000000..f55f096 --- /dev/null +++ b/tests/define_config.spec.ts @@ -0,0 +1,185 @@ +/* + * @adonisjs/redis + * + * (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 { fileURLToPath } from 'node:url' +import { AppFactory } from '@adonisjs/core/factories/app' +import { defineConfig as redisConfig } from '@adonisjs/redis' +import type { ApplicationService } from '@adonisjs/core/types' +import { HttpContextFactory } from '@adonisjs/core/factories/http' + +import { FileStore } from '../src/stores/file.js' +import { RedisStore } from '../src/stores/redis.js' +import { CookieStore } from '../src/stores/cookie.js' +import { defineConfig, stores } from '../src/define_config.js' + +const BASE_URL = new URL('./', import.meta.url) +const app = new AppFactory().create(BASE_URL, () => {}) as ApplicationService + +test.group('Define config', () => { + test('throw error when store is not defined', async () => { + await defineConfig({} as any).resolver(app) + }).throws('Missing "store" property inside the session config') + + test('define maxAge when clearWithBrowser is not defined', async ({ assert }) => { + const config = await defineConfig({ store: 'memory', stores: {} }).resolver(app) + assert.equal(config.cookie.maxAge, 7200) + }) + + test('define maxAge when clearWithBrowser is not enabled', async ({ assert }) => { + const config = await defineConfig({ + clearWithBrowser: false, + store: 'memory', + stores: {}, + }).resolver(app) + assert.equal(config.cookie.maxAge, 7200) + }) + + test('define maxAge when clearWithBrowser is enabled', async ({ assert }) => { + const config = await defineConfig({ + clearWithBrowser: true, + store: 'memory', + stores: {}, + }).resolver(app) + + assert.isUndefined(config.cookie.maxAge) + }) + + test('transform config with no stores', async ({ assert }) => { + const config = await defineConfig({ store: 'memory', stores: {} }).resolver(app) + assert.snapshot(config).matchInline(` + { + "age": "2h", + "clearWithBrowser": false, + "cookie": { + "maxAge": 7200, + }, + "cookieName": "adonis_session", + "enabled": true, + "store": "memory", + "stores": { + "memory": [Function], + }, + } + `) + }) + + test('transform config with file store', async ({ assert }) => { + const config = await defineConfig({ + store: 'file', + stores: { + file: stores.file({ location: fileURLToPath(new URL('./sessions', BASE_URL)) }), + }, + }).resolver(app) + + assert.snapshot(config).matchInline(` + { + "age": "2h", + "clearWithBrowser": false, + "cookie": { + "maxAge": 7200, + }, + "cookieName": "adonis_session", + "enabled": true, + "store": "file", + "stores": { + "file": [Function], + "memory": [Function], + }, + } + `) + + const ctx = new HttpContextFactory().create() + assert.instanceOf(config.stores.file(ctx, config), FileStore) + }) + + test('transform config with redis store', async ({ assert }) => { + const appForRedis = new AppFactory().create(BASE_URL, () => {}) as ApplicationService + appForRedis.rcContents({ + providers: [ + () => import('@adonisjs/core/providers/app_provider'), + () => import('@adonisjs/redis/redis_provider'), + ], + }) + appForRedis.useConfig({ + logger: { + default: 'main', + loggers: { + main: {}, + }, + }, + redis: redisConfig({ + connection: 'main', + connections: { + main: {}, + }, + }), + }) + await appForRedis.init() + await appForRedis.boot() + + const config = await defineConfig({ + store: 'redis', + stores: { + redis: stores.redis({ + connection: 'main', + } as any), + }, + }).resolver(appForRedis) + + assert.snapshot(config).matchInline(` + { + "age": "2h", + "clearWithBrowser": false, + "cookie": { + "maxAge": 7200, + }, + "cookieName": "adonis_session", + "enabled": true, + "store": "redis", + "stores": { + "memory": [Function], + "redis": [Function], + }, + } + `) + + const ctx = new HttpContextFactory().create() + assert.instanceOf(config.stores.redis(ctx, config), RedisStore) + }) + + test('transform config with cookie store', async ({ assert }) => { + const config = await defineConfig({ + store: 'cookie', + stores: { + cookie: stores.cookie(), + }, + }).resolver(app) + + assert.snapshot(config).matchInline(` + { + "age": "2h", + "clearWithBrowser": false, + "cookie": { + "maxAge": 7200, + }, + "cookieName": "adonis_session", + "enabled": true, + "store": "cookie", + "stores": { + "cookie": [Function], + "memory": [Function], + }, + } + `) + + const ctx = new HttpContextFactory().create() + assert.instanceOf(config.stores.cookie(ctx, config), CookieStore) + }) +}) diff --git a/tests/plugins/api_client.spec.ts b/tests/plugins/api_client.spec.ts new file mode 100644 index 0000000..05b15b7 --- /dev/null +++ b/tests/plugins/api_client.spec.ts @@ -0,0 +1,205 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import getPort from 'get-port' +import { test } from '@japa/runner' +import { Emitter } from '@adonisjs/core/events' +import { AppFactory } from '@adonisjs/core/factories/app' +import { ApplicationService, EventsList } from '@adonisjs/core/types' +import { EncryptionFactory } from '@adonisjs/core/factories/encryption' +import { HttpContextFactory, RequestFactory, ResponseFactory } from '@adonisjs/core/factories/http' + +import { Session } from '../../src/session.js' +import { SessionConfig } from '../../src/types.js' +import { defineConfig } from '../../src/define_config.js' +import { MemoryStore } from '../../src/stores/memory.js' +import { httpServer, runJapaTest } from '../../tests_helpers/index.js' + +const app = new AppFactory().create(new URL('./', import.meta.url), () => {}) as ApplicationService + +const emitter = new Emitter(app) +const encryption = new EncryptionFactory().create() + +const sessionConfig: SessionConfig = { + enabled: true, + age: '2 hours', + clearWithBrowser: false, + cookieName: 'adonis_session', + cookie: {}, +} + +test.group('Api client', (group) => { + group.setup(async () => { + app.useConfig({ + session: defineConfig({ + store: 'memory', + stores: {}, + }), + }) + await app.init() + await app.boot() + app.container.singleton('encryption', () => encryption) + }) + + test('set session from the client', async ({ assert }) => { + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, () => new MemoryStore(), emitter, ctx) + + await session.initiate(false) + assert.deepEqual(session.all(), { username: 'virk' }) + + await session.commit() + response.finish() + }) + + const port = await getPort({ port: 3333 }) + const url = `http://localhost:${port}` + server.listen(port) + + await runJapaTest(app, async ({ client }) => { + await client.get(url).withSession({ username: 'virk' }) + assert.lengthOf(MemoryStore.sessions, 0) + }) + }) + + test('set flash messages from the client', async ({ assert }) => { + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, () => new MemoryStore(), emitter, ctx) + + await session.initiate(false) + assert.deepEqual(session.flashMessages.all(), { username: 'virk' }) + + await session.commit() + response.finish() + }) + + const port = await getPort({ port: 3333 }) + const url = `http://localhost:${port}` + server.listen(port) + + await runJapaTest(app, async ({ client }) => { + await client.get(url).withFlashMessages({ username: 'virk' }) + assert.lengthOf(MemoryStore.sessions, 0) + }) + }) + + test('read response session data', async ({ assert }) => { + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, () => new MemoryStore(), emitter, ctx) + + await session.initiate(false) + session.put('name', 'virk') + + await session.commit() + response.finish() + }) + + const port = await getPort({ port: 3333 }) + const url = `http://localhost:${port}` + server.listen(port) + + await runJapaTest(app, async ({ client }) => { + const response = await client.get(url) + assert.deepEqual(response.session(), { name: 'virk' }) + assert.lengthOf(MemoryStore.sessions, 0) + }) + }) + + test('read response flashMessages', async ({ assert }) => { + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, () => new MemoryStore(), emitter, ctx) + + await session.initiate(false) + session.flash('name', 'virk') + + await session.commit() + response.finish() + }) + + const port = await getPort({ port: 3333 }) + const url = `http://localhost:${port}` + server.listen(port) + + await runJapaTest(app, async ({ client }) => { + const response = await client.get(url) + assert.deepEqual(response.flashMessages(), { name: 'virk' }) + assert.lengthOf(MemoryStore.sessions, 0) + }) + }) + + test('assert session and flash messages', async ({ assert }) => { + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, () => new MemoryStore(), emitter, ctx) + + await session.initiate(false) + session.put('name', 'virk') + session.flash({ + succeed: false, + hasErrors: true, + errors: { username: ['field is required', 'field must be alpha numeric'] }, + }) + + await session.commit() + response.finish() + }) + + const port = await getPort({ port: 3333 }) + const url = `http://localhost:${port}` + server.listen(port) + + await runJapaTest(app, async ({ client }) => { + const response = await client.get(url) + assert.lengthOf(MemoryStore.sessions, 0) + + response.assertSession('name') + response.assertSession('name', 'virk') + response.assertSessionMissing('age') + + response.assertFlashMessage('succeed') + response.assertFlashMessage('hasErrors') + response.assertFlashMessage('hasErrors', true) + response.assertFlashMessage('succeed', false) + response.assertFlashMissing('notifications') + + response.assertValidationError('username', 'field is required') + response.assertValidationErrors('username', [ + 'field is required', + 'field must be alpha numeric', + ]) + response.assertDoesNotHaveValidationError('email') + + assert.throws(() => response.assertSession('name', 'foo')) + assert.throws(() => response.assertSessionMissing('name')) + assert.throws(() => response.assertFlashMissing('succeed')) + assert.throws(() => response.assertFlashMessage('succeed', true)) + assert.throws(() => response.assertDoesNotHaveValidationError('username')) + assert.throws(() => response.assertValidationError('username', 'field is missing')) + }) + }) +}) diff --git a/tests/plugins/browser_client.spec.ts b/tests/plugins/browser_client.spec.ts new file mode 100644 index 0000000..c6e4b77 --- /dev/null +++ b/tests/plugins/browser_client.spec.ts @@ -0,0 +1,178 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import getPort from 'get-port' +import { test } from '@japa/runner' +import { Emitter } from '@adonisjs/core/events' +import { AppFactory } from '@adonisjs/core/factories/app' +import { ApplicationService, EventsList } from '@adonisjs/core/types' +import { EncryptionFactory } from '@adonisjs/core/factories/encryption' +import { HttpContextFactory, RequestFactory, ResponseFactory } from '@adonisjs/core/factories/http' + +import { Session } from '../../src/session.js' +import { SessionConfig } from '../../src/types.js' +import { MemoryStore } from '../../src/stores/memory.js' +import { defineConfig } from '../../src/define_config.js' +import { httpServer, runJapaTest } from '../../tests_helpers/index.js' + +const app = new AppFactory().create(new URL('./', import.meta.url), () => {}) as ApplicationService + +const emitter = new Emitter(app) +const encryption = new EncryptionFactory().create() + +const sessionConfig: SessionConfig = { + enabled: true, + age: '2 hours', + clearWithBrowser: false, + cookieName: 'adonis_session', + cookie: {}, +} + +test.group('Browser client', (group) => { + group.setup(async () => { + app.useConfig({ + session: defineConfig({ + store: 'memory', + stores: {}, + }), + }) + await app.init() + await app.boot() + app.container.singleton('encryption', () => encryption) + }) + + test('set session from the client', async ({ assert }) => { + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, () => new MemoryStore(), emitter, ctx) + + await session.initiate(false) + assert.deepEqual(session.all(), { username: 'virk' }) + + await session.commit() + response.finish() + }) + + const port = await getPort({ port: 3333 }) + const url = `http://localhost:${port}` + server.listen(port) + + await runJapaTest(app, async ({ visit, browserContext }) => { + await browserContext.initiateSession({ domain: new URL(url).host, path: '/' }) + await browserContext.setSession({ username: 'virk' }) + await visit(url) + + assert.lengthOf(MemoryStore.sessions, 1) + await browserContext.close() + assert.lengthOf(MemoryStore.sessions, 0) + }) + }) + + test('set flash messages from the client', async ({ assert }) => { + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, () => new MemoryStore(), emitter, ctx) + + await session.initiate(false) + assert.deepEqual(session.flashMessages.all(), { username: 'virk' }) + + await session.commit() + response.finish() + }) + + const port = await getPort({ port: 3333 }) + const url = `http://localhost:${port}` + server.listen(port) + + await runJapaTest(app, async ({ browserContext, visit }) => { + await browserContext.initiateSession({ domain: new URL(url).host, path: '/' }) + await browserContext.setFlashMessages({ username: 'virk' }) + await visit(url) + + /** + * Since the server clears the session after + * reading the flash messages, the store + * should be empty post visit + */ + assert.lengthOf(MemoryStore.sessions, 0) + await browserContext.close() + assert.lengthOf(MemoryStore.sessions, 0) + }) + }) + + test('read response session data', async ({ assert }) => { + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, () => new MemoryStore(), emitter, ctx) + + await session.initiate(false) + session.put('name', 'virk') + + await session.commit() + response.finish() + }) + + const port = await getPort({ port: 3333 }) + const url = `http://localhost:${port}` + server.listen(port) + + await runJapaTest(app, async ({ browserContext, visit }) => { + await browserContext.initiateSession({ domain: new URL(url).host, path: '/' }) + await browserContext.setFlashMessages({ username: 'virk' }) + await visit(url) + + assert.deepEqual(await browserContext.getSession(), { name: 'virk' }) + + assert.lengthOf(MemoryStore.sessions, 1) + await browserContext.close() + assert.lengthOf(MemoryStore.sessions, 0) + }) + }) + + test('read response flashMessages', async ({ assert }) => { + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, () => new MemoryStore(), emitter, ctx) + + await session.initiate(false) + session.flash('name', 'virk') + + await session.commit() + response.finish() + }) + + const port = await getPort({ port: 3333 }) + const url = `http://localhost:${port}` + server.listen(port) + + await runJapaTest(app, async ({ browserContext, visit }) => { + await browserContext.initiateSession({ domain: new URL(url).host, path: '/' }) + await browserContext.setFlashMessages({ username: 'virk' }) + await visit(url) + + assert.deepEqual(await browserContext.getFlashMessages(), { name: 'virk' }) + + assert.lengthOf(MemoryStore.sessions, 1) + await browserContext.close() + assert.lengthOf(MemoryStore.sessions, 0) + }) + }) +}) diff --git a/tests/session.spec.ts b/tests/session.spec.ts new file mode 100644 index 0000000..b5fae14 --- /dev/null +++ b/tests/session.spec.ts @@ -0,0 +1,1422 @@ +/** + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import edge from 'edge.js' +import supertest from 'supertest' +import { test } from '@japa/runner' +import { cuid } from '@adonisjs/core/helpers' +import setCookieParser from 'set-cookie-parser' +import { Emitter } from '@adonisjs/core/events' +import { SimpleErrorReporter } from '@vinejs/vine' +import { CookieClient } from '@adonisjs/core/http' +import { fieldContext } from '@vinejs/vine/factories' +import { AppFactory } from '@adonisjs/core/factories/app' +import { ApplicationService, EventsList } from '@adonisjs/core/types' +import { EncryptionFactory } from '@adonisjs/core/factories/encryption' +import EdgeServiceProvider from '@adonisjs/core/providers/edge_provider' +import { + RouterFactory, + RequestFactory, + ResponseFactory, + HttpContextFactory, +} from '@adonisjs/core/factories/http' + +import { Session } from '../src/session.js' +import { httpServer } from '../tests_helpers/index.js' +import { CookieStore } from '../src/stores/cookie.js' +import SessionProvider from '../providers/session_provider.js' +import type { SessionConfig, SessionStoreFactory } from '../src/types.js' + +const app = new AppFactory().create(new URL('./', import.meta.url), () => {}) as ApplicationService +const emitter = new Emitter(app) +const encryption = new EncryptionFactory().create() +const cookieClient = new CookieClient(encryption) +const sessionConfig: SessionConfig = { + enabled: true, + age: '2 hours', + clearWithBrowser: false, + cookieName: 'adonis_session', + cookie: {}, +} +const cookieDriver: SessionStoreFactory = (ctx, config) => { + return new CookieStore(config.cookie, ctx) +} + +test.group('Session', (group) => { + group.setup(async () => { + const router = new RouterFactory().create() + await app.init() + + app.container.singleton('router', () => router) + + await new EdgeServiceProvider(app).boot() + }) + + test('do not define session id cookie when not initiated', async ({ assert }) => { + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + + assert.isFalse(session.initiated) + + await session.commit() + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + assert.deepEqual(cookies, {}) + }) + + test("initiate session with fresh session id when there isn't any session", async ({ + assert, + }) => { + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + assert.isTrue(session.fresh) + assert.isTrue(session.initiated) + + await session.commit() + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + assert.property(cookies, 'adonis_session') + }) + + test('do not commit to store when session store is empty', async ({ assert }) => { + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + assert.isTrue(session.fresh) + assert.isTrue(session.initiated) + + await session.commit() + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + assert.property(cookies, 'adonis_session') + assert.lengthOf(Object.keys(cookies), 1) + }) + + test('commit to store when session has data', async ({ assert }) => { + let sessionId: string | undefined + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + session.put('username', 'virk') + sessionId = session.sessionId + + await session.commit() + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + assert.property(cookies, 'adonis_session') + assert.property(cookies, sessionId!) + assert.equal(cookies[sessionId!].maxAge, 90) + assert.deepEqual(cookieClient.decrypt(sessionId!, cookies[sessionId!].value), { + username: 'virk', + }) + }) + + test('append to existing store', async ({ assert }) => { + let sessionId = cuid() + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + session.put('username', 'virk') + assert.isTrue(session.has('username')) + assert.isTrue(session.has('age')) + assert.deepEqual(session.all(), { username: 'virk', age: 22 }) + + await session.commit() + response.finish() + }) + + const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` + const sessionStoreCookie = `${sessionId}=${cookieClient.encrypt(sessionId, { age: 22 })}` + + const { headers } = await supertest(server) + .get('/') + .set('cookie', `${sessionIdCookie}; ${sessionStoreCookie}`) + + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + assert.property(cookies, 'adonis_session') + assert.property(cookies, sessionId!) + assert.equal(cookies[sessionId!].maxAge, 90) + assert.deepEqual(cookieClient.decrypt(sessionId!, cookies[sessionId!].value), { + username: 'virk', + age: 22, + }) + }) + + test('delete store when session store is empty', async ({ assert }) => { + let sessionId = cuid() + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + session.forget('age') + await session.commit() + response.finish() + }) + + const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` + const sessionStoreCookie = `${sessionId}=${cookieClient.encrypt(sessionId, { age: 22 })}` + + const { headers } = await supertest(server) + .get('/') + .set('cookie', `${sessionIdCookie}; ${sessionStoreCookie}`) + + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + assert.property(cookies, 'adonis_session') + assert.equal(cookies[sessionId].maxAge, -1) + assert.lengthOf(Object.keys(cookies), 2) + }) + + test('pull value from the session store', async ({ assert }) => { + let sessionId = cuid() + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + assert.equal(session.pull('age'), 22) + await session.commit() + response.finish() + }) + + const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` + const sessionStoreCookie = `${sessionId}=${cookieClient.encrypt(sessionId, { age: 22 })}` + + const { headers } = await supertest(server) + .get('/') + .set('cookie', `${sessionIdCookie}; ${sessionStoreCookie}`) + + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + assert.property(cookies, 'adonis_session') + assert.equal(cookies[sessionId].maxAge, -1) + assert.lengthOf(Object.keys(cookies), 2) + }) + + test('initiate value with 1 on increment', async ({ assert }) => { + let sessionId = cuid() + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + session.increment('visits') + await session.commit() + response.finish() + }) + + const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` + + const { headers } = await supertest(server).get('/').set('cookie', `${sessionIdCookie}`) + + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + assert.property(cookies, 'adonis_session') + assert.deepEqual(cookieClient.decrypt(sessionId!, cookies[sessionId!].value), { + visits: 1, + }) + }) + + test('initiate value with -1 on decrement', async ({ assert }) => { + let sessionId = cuid() + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + session.decrement('visits') + await session.commit() + response.finish() + }) + + const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` + + const { headers } = await supertest(server).get('/').set('cookie', `${sessionIdCookie}`) + + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + assert.property(cookies, 'adonis_session') + assert.deepEqual(cookieClient.decrypt(sessionId!, cookies[sessionId!].value), { + visits: -1, + }) + }) + + test('touch session store when not modified', async ({ assert }) => { + let sessionId = cuid() + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + await session.commit() + response.finish() + }) + + const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` + const sessionStoreCookie = `${sessionId}=${cookieClient.encrypt(sessionId, { age: 22 })}` + + const { headers } = await supertest(server) + .get('/') + .set('cookie', `${sessionIdCookie}; ${sessionStoreCookie}`) + + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + assert.property(cookies, 'adonis_session') + assert.property(cookies, sessionId!) + assert.equal(cookies[sessionId!].maxAge, 90) + assert.deepEqual(cookieClient.decrypt(sessionId!, cookies[sessionId!].value), { + age: 22, + }) + }) + + test('clear session store', async ({ assert }) => { + let sessionId = cuid() + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + session.clear() + await session.commit() + response.finish() + }) + + const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` + const sessionStoreCookie = `${sessionId}=${cookieClient.encrypt(sessionId, { age: 22 })}` + + const { headers } = await supertest(server) + .get('/') + .set('cookie', `${sessionIdCookie}; ${sessionStoreCookie}`) + + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + assert.property(cookies, 'adonis_session') + assert.equal(cookies[sessionId].maxAge, -1) + assert.lengthOf(Object.keys(cookies), 2) + }) + + test('throw error when trying to read from uninitiated store', async () => { + const ctx = new HttpContextFactory().create() + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + session.get('username') + }).throws( + 'Session store has not been initiated. Make sure you have registered the session middleware' + ) + + test('throw error when trying to write to a read only store', async ({ assert }) => { + const ctx = new HttpContextFactory().create() + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + + await session.initiate(true) + assert.isUndefined(session.get('username')) + + session.put('username', 'foo') + }).throws('Session store is in readonly mode and cannot be mutated') + + test('share session data with templates', async ({ assert }) => { + let sessionId = cuid() + + edge.registerTemplate('welcome', { + template: `The user age is {{ session.get('age') }}`, + }) + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + try { + response.send(await ctx.view.render('welcome')) + } catch (error) { + console.log(error) + } + + await session.commit() + response.finish() + }) + + const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` + const sessionStoreCookie = `${sessionId}=${cookieClient.encrypt(sessionId, { age: 22 })}` + + const { text } = await supertest(server) + .get('/') + .set('cookie', `${sessionIdCookie}; ${sessionStoreCookie}`) + + assert.equal(text, 'The user age is 22') + }) +}) + +test.group('Session | Regenerate', () => { + test("initiate session with fresh session id when there isn't any session", async ({ + assert, + }) => { + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + session.regenerate() + + assert.isTrue(session.fresh) + assert.isTrue(session.initiated) + assert.isFalse(session.hasRegeneratedSession) + + await session.commit() + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + assert.property(cookies, 'adonis_session') + }) + + test('do not commit to store when session store is empty', async ({ assert }) => { + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + session.regenerate() + + assert.isTrue(session.fresh) + assert.isTrue(session.initiated) + assert.isFalse(session.hasRegeneratedSession) + + await session.commit() + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + assert.property(cookies, 'adonis_session') + assert.lengthOf(Object.keys(cookies), 1) + }) + + test('commit to store when session has data', async ({ assert }) => { + let sessionId: string | undefined + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + session.regenerate() + assert.isFalse(session.hasRegeneratedSession) + + session.put('username', 'virk') + sessionId = session.sessionId + + await session.commit() + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + assert.property(cookies, 'adonis_session') + assert.property(cookies, sessionId!) + assert.equal(cookies[sessionId!].maxAge, 90) + assert.deepEqual(cookieClient.decrypt(sessionId!, cookies[sessionId!].value), { + username: 'virk', + }) + }) + + test('append to existing store', async ({ assert }) => { + let sessionId = cuid() + let newSessionId: string | undefined + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + session.put('username', 'virk') + session.regenerate() + newSessionId = session.sessionId + + assert.isTrue(session.hasRegeneratedSession) + + await session.commit() + response.finish() + }) + + const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` + const sessionStoreCookie = `${sessionId}=${cookieClient.encrypt(sessionId, { age: 22 })}` + + const { headers } = await supertest(server) + .get('/') + .set('cookie', `${sessionIdCookie}; ${sessionStoreCookie}`) + + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + assert.property(cookies, 'adonis_session') + assert.notEqual(newSessionId, sessionId) + assert.property(cookies, newSessionId!) + assert.equal(cookies[sessionId!].maxAge, -1) + assert.equal(cookies[newSessionId!].maxAge, 90) + assert.deepEqual(cookieClient.decrypt(newSessionId!, cookies[newSessionId!].value), { + username: 'virk', + age: 22, + }) + }) + + test('delete store when session store is empty', async ({ assert }) => { + let sessionId = cuid() + let newSessionId: string | undefined + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + session.forget('age') + session.regenerate() + newSessionId = session.sessionId + + assert.isTrue(session.hasRegeneratedSession) + + await session.commit() + response.finish() + }) + + const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` + const sessionStoreCookie = `${sessionId}=${cookieClient.encrypt(sessionId, { age: 22 })}` + + const { headers } = await supertest(server) + .get('/') + .set('cookie', `${sessionIdCookie}; ${sessionStoreCookie}`) + + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + assert.notEqual(newSessionId, sessionId) + assert.property(cookies, 'adonis_session') + assert.notProperty(cookies, newSessionId!) + assert.equal(cookies[sessionId].maxAge, -1) + assert.lengthOf(Object.keys(cookies), 2) + }) + + test('touch session store when not modified', async ({ assert }) => { + let sessionId = cuid() + let newSessionId: string | undefined + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + session.regenerate() + newSessionId = session.sessionId + + assert.isTrue(session.hasRegeneratedSession) + + await session.commit() + response.finish() + }) + + const sessionIdCookie = `adonis_session=${cookieClient.sign('adonis_session', sessionId)}` + const sessionStoreCookie = `${sessionId}=${cookieClient.encrypt(sessionId, { age: 22 })}` + + const { headers } = await supertest(server) + .get('/') + .set('cookie', `${sessionIdCookie}; ${sessionStoreCookie}`) + + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + assert.property(cookies, 'adonis_session') + assert.property(cookies, sessionId!) + assert.equal(cookies[sessionId!].maxAge, -1) + assert.property(cookies, newSessionId!) + assert.equal(cookies[newSessionId!].maxAge, 90) + assert.deepEqual(cookieClient.decrypt(newSessionId!, cookies[newSessionId!].value), { + age: 22, + }) + }) +}) + +test.group('Session | Flash', (group) => { + group.setup(async () => { + const router = new RouterFactory().create() + await app.init() + + app.container.singleton('router', () => router) + + await new EdgeServiceProvider(app).boot() + await new SessionProvider(app).boot() + }) + + group.each.setup(() => { + return () => { + edge.removeTemplate('flash_no_errors_messages') + edge.removeTemplate('flash_errors_messages') + } + }) + + test('flash data using the session store', async ({ assert }) => { + let sessionId: string | undefined + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + session.flash('status', 'Task created successfully') + sessionId = session.sessionId + + await session.commit() + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + assert.deepEqual(cookieClient.decrypt(sessionId!, cookies[sessionId!].value), { + __flash__: { + status: 'Task created successfully', + }, + }) + }) + + test('flash key-value pair', async ({ assert }) => { + let sessionId: string | undefined + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + session.flash({ status: 'Task created successfully' }) + sessionId = session.sessionId + + await session.commit() + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + assert.deepEqual(cookieClient.decrypt(sessionId!, cookies[sessionId!].value), { + __flash__: { + status: 'Task created successfully', + }, + }) + }) + + test('flash input values', async ({ assert }) => { + let sessionId: string | undefined + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + ctx.request.setInitialBody({ + username: 'virk', + }) + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + session.flash({ status: 'Task created successfully' }) + session.flashAll() + sessionId = session.sessionId + + await session.commit() + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + assert.deepEqual(cookieClient.decrypt(sessionId!, cookies[sessionId!].value), { + __flash__: { + username: 'virk', + status: 'Task created successfully', + }, + }) + }) + + test('flash selected input values', async ({ assert }) => { + let sessionId: string | undefined + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + ctx.request.setInitialBody({ + username: 'virk', + age: 22, + }) + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + session.flash({ status: 'Task created successfully' }) + + /** + * The last method call will overwrite others + */ + session.flashAll() + session.flashExcept(['username']) + session.flashOnly(['username']) + + sessionId = session.sessionId + + await session.commit() + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + assert.deepEqual(cookieClient.decrypt(sessionId!, cookies[sessionId!].value), { + __flash__: { + username: 'virk', + status: 'Task created successfully', + }, + }) + }) + + test('read flash messages from the request', async ({ assert }) => { + let sessionId: string | undefined + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + sessionId = session.sessionId + + if (request.url() === '/prg') { + response.json(session.flashMessages.all()) + await session.commit() + response.finish() + } else { + session.flash({ status: 'Task created successfully' }) + await session.commit() + } + + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + const { body, headers: newHeaders } = await supertest(server) + .get('/prg') + .set( + 'Cookie', + `adonis_session=${cookies.adonis_session.value}; ${sessionId}=${cookies[sessionId!].value}` + ) + + const newCookies = setCookieParser.parse(newHeaders['set-cookie'], { map: true }) + + assert.deepEqual(body, { status: 'Task created successfully' }) + assert.equal(newCookies[sessionId!].maxAge, -1) + }) + + test('reflash flash messages', async ({ assert }) => { + let sessionId: string | undefined + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + sessionId = session.sessionId + + if (request.url() === '/prg') { + response.json(session.flashMessages.all()) + await session.commit() + response.finish() + } else if (request.url() === '/reflash') { + session.reflash() + await session.commit() + } else { + session.flash({ status: 'Task created successfully' }) + await session.commit() + } + + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + const { headers: reflashedHeaders } = await supertest(server) + .get('/reflash') + .set( + 'Cookie', + `adonis_session=${cookies.adonis_session.value}; ${sessionId}=${cookies[sessionId!].value}` + ) + const reflashedCookies = setCookieParser.parse(reflashedHeaders['set-cookie'], { map: true }) + + const { body, headers: newHeaders } = await supertest(server) + .get('/prg') + .set( + 'Cookie', + `adonis_session=${reflashedCookies.adonis_session.value}; ${sessionId}=${ + reflashedCookies[sessionId!].value + }` + ) + + const newCookies = setCookieParser.parse(newHeaders['set-cookie'], { map: true }) + + assert.deepEqual(body, { status: 'Task created successfully' }) + assert.equal(newCookies[sessionId!].maxAge, -1) + }) + + test('reflash and flash together', async ({ assert }) => { + let sessionId: string | undefined + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + sessionId = session.sessionId + + if (request.url() === '/prg') { + response.json(session.flashMessages.all()) + await session.commit() + response.finish() + } else if (request.url() === '/reflash') { + session.reflash() + session.reflashExcept(['id']) + session.reflashOnly(['id']) + session.flash({ state: 'success' }) + await session.commit() + } else { + session.flash({ status: 'Task created successfully', id: 1 }) + await session.commit() + } + + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + const { headers: reflashedHeaders } = await supertest(server) + .get('/reflash') + .set( + 'Cookie', + `adonis_session=${cookies.adonis_session.value}; ${sessionId}=${cookies[sessionId!].value}` + ) + const reflashedCookies = setCookieParser.parse(reflashedHeaders['set-cookie'], { map: true }) + + const { body, headers: newHeaders } = await supertest(server) + .get('/prg') + .set( + 'Cookie', + `adonis_session=${reflashedCookies.adonis_session.value}; ${sessionId}=${ + reflashedCookies[sessionId!].value + }` + ) + + const newCookies = setCookieParser.parse(newHeaders['set-cookie'], { map: true }) + + assert.deepEqual(body, { id: 1, state: 'success' }) + assert.equal(newCookies[sessionId!].maxAge, -1) + }) + + test('throw error when trying to write to flash messages without initialization', async () => { + const ctx = new HttpContextFactory().create() + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + session.flash('username', 'virk') + }).throws( + 'Session store has not been initiated. Make sure you have registered the session middleware' + ) + + test('throw error when trying to write flash messages to a read only store', async () => { + const ctx = new HttpContextFactory().create() + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + + await session.initiate(true) + session.flash('username', 'foo') + }).throws('Session store is in readonly mode and cannot be mutated') + + test('flash validation error messages', async ({ assert }) => { + let sessionId: string | undefined + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + const errorReporter = new SimpleErrorReporter() + errorReporter.report('Invalid username', 'alpha', fieldContext.create('username', ''), {}) + errorReporter.report( + 'Username is required', + 'required', + fieldContext.create('username', ''), + {} + ) + errorReporter.report('Invalid email', 'email', fieldContext.create('email', ''), {}) + + session.flashValidationErrors(errorReporter.createError()) + sessionId = session.sessionId + + await session.commit() + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + assert.deepEqual(cookieClient.decrypt(sessionId!, cookies[sessionId!].value), { + __flash__: { + errors: { + email: ['Invalid email'], + username: ['Invalid username', 'Username is required'], + }, + inputErrorsBag: { + email: ['Invalid email'], + username: ['Invalid username', 'Username is required'], + }, + }, + }) + }) + + test("multiple calls to flashValidationErrors should keep the last one's", async ({ assert }) => { + let sessionId: string | undefined + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + const errorReporter = new SimpleErrorReporter() + const errorReporter1 = new SimpleErrorReporter() + errorReporter.report('Invalid username', 'alpha', fieldContext.create('username', ''), {}) + errorReporter.report( + 'Username is required', + 'required', + fieldContext.create('username', ''), + {} + ) + errorReporter.report('Invalid email', 'email', fieldContext.create('email', ''), {}) + + errorReporter1.report('Invalid name', 'alpha', fieldContext.create('name', ''), {}) + + session.flashValidationErrors(errorReporter.createError()) + session.flashValidationErrors(errorReporter1.createError()) + sessionId = session.sessionId + + await session.commit() + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + assert.deepEqual(cookieClient.decrypt(sessionId!, cookies[sessionId!].value), { + __flash__: { + errors: { + name: ['Invalid name'], + }, + inputErrorsBag: { + name: ['Invalid name'], + }, + }, + }) + }) + + test('flash collection of errors', async ({ assert }) => { + let sessionId: string | undefined + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + session.flashErrors({ + E_AUTHORIZATION_FAILED: 'Cannot access route', + }) + session.flashErrors({ + E_ACCESS_DENIED: 'Cannot access resource', + }) + + sessionId = session.sessionId + + await session.commit() + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + assert.deepEqual(cookieClient.decrypt(sessionId!, cookies[sessionId!].value), { + __flash__: { + errorsBag: { + E_AUTHORIZATION_FAILED: 'Cannot access route', + E_ACCESS_DENIED: 'Cannot access resource', + }, + }, + }) + }) + + test('access flash messages inside templates', async ({ assert }) => { + let sessionId: string | undefined + + edge.registerTemplate('flash_messages', { + template: `{{ old('status') }}`, + }) + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + sessionId = session.sessionId + + if (request.url() === '/prg') { + response.send(await ctx.view.render('flash_messages')) + await session.commit() + response.finish() + } else { + session.flash({ status: 'Task created successfully' }) + await session.commit() + } + + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + const { text } = await supertest(server) + .get('/prg') + .set( + 'Cookie', + `adonis_session=${cookies.adonis_session.value}; ${sessionId}=${cookies[sessionId!].value}` + ) + + assert.equal(text, 'Task created successfully') + }) + + test('access flash messages using the @flashMessage tag', async ({ assert }) => { + let sessionId: string | undefined + + edge.registerTemplate('flash_messages_via_tag', { + template: `@flashMessage('status') +

{{ $message }}

+ @end + @flashMessage('success') +

{{ $message }}

+ @else +

No success message

+ @end + `, + }) + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + sessionId = session.sessionId + + if (request.url() === '/prg') { + response.send(await ctx.view.render('flash_messages_via_tag')) + await session.commit() + response.finish() + } else { + session.flash({ status: 'Task created successfully' }) + await session.commit() + } + + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + const { text } = await supertest(server) + .get('/prg') + .set( + 'Cookie', + `adonis_session=${cookies.adonis_session.value}; ${sessionId}=${cookies[sessionId!].value}` + ) + + assert.deepEqual( + text.split('\n').map((line) => line.trim()), + ['

Task created successfully

', '

No success message

', ''] + ) + }) + + test('use inputError tag when there are no error message', async ({ assert }) => { + edge.registerTemplate('flash_no_errors_messages', { + template: ` + @inputError('username') + @each(message in $messages) +

{{ message }}

+ @end + @else +

No error message

+ @end + `, + }) + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + + response.send(await ctx.view.render('flash_no_errors_messages')) + await session.commit() + response.finish() + }) + + const { text } = await supertest(server).get('/prg') + + assert.deepEqual( + text.split('\n').map((line) => line.trim()), + ['

No error message

', ''] + ) + }) + + test('access input error messages using the @inputError tag', async ({ assert }) => { + let sessionId: string | undefined + + edge.registerTemplate('flash_errors_messages', { + template: ` + @inputError('username') + @each(message in $messages) +

{{ message }}

+ @end + @else +

No error message

+ @end + `, + }) + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + sessionId = session.sessionId + + if (request.url() === '/prg') { + response.send(await ctx.view.render('flash_errors_messages')) + await session.commit() + response.finish() + } else { + const errorReporter = new SimpleErrorReporter() + errorReporter.report('Invalid username', 'alpha', fieldContext.create('username', ''), {}) + errorReporter.report( + 'Username is required', + 'required', + fieldContext.create('username', ''), + {} + ) + + session.flashValidationErrors(errorReporter.createError()) + await session.commit() + } + + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + const { text } = await supertest(server) + .get('/prg') + .set( + 'Cookie', + `adonis_session=${cookies.adonis_session.value}; ${sessionId}=${cookies[sessionId!].value}` + ) + + assert.deepEqual( + text.split('\n').map((line) => line.trim()), + ['', '

Invalid username

', '

Username is required

', ''] + ) + }) + + test('define @inputError key as a variable', async ({ assert }) => { + let sessionId: string | undefined + + edge.registerTemplate('flash_errors_messages', { + template: ` + @inputError(field) + @each(message in $messages) +

{{ message }}

+ @end + @else +

No error message

+ @end + `, + }) + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + sessionId = session.sessionId + + if (request.url() === '/prg') { + response.send(await ctx.view.render('flash_errors_messages', { field: 'username' })) + await session.commit() + response.finish() + } else { + const errorReporter = new SimpleErrorReporter() + errorReporter.report('Invalid username', 'alpha', fieldContext.create('username', ''), {}) + errorReporter.report( + 'Username is required', + 'required', + fieldContext.create('username', ''), + {} + ) + + session.flashValidationErrors(errorReporter.createError()) + await session.commit() + } + + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + const { text } = await supertest(server) + .get('/prg') + .set( + 'Cookie', + `adonis_session=${cookies.adonis_session.value}; ${sessionId}=${cookies[sessionId!].value}` + ) + + assert.deepEqual( + text.split('\n').map((line) => line.trim()), + ['', '

Invalid username

', '

Username is required

', ''] + ) + }) + + test('access error messages using the @error tag', async ({ assert }) => { + let sessionId: string | undefined + + edge.registerTemplate('flash_errors_messages', { + template: ` + @error('E_ACCESS_DENIED') +

{{ $message }}

+ @else +

No error message

+ @end + `, + }) + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + sessionId = session.sessionId + + if (request.url() === '/prg') { + response.send(await ctx.view.render('flash_errors_messages')) + await session.commit() + response.finish() + } else { + session.flashErrors({ + E_ACCESS_DENIED: 'Access denied', + }) + await session.commit() + } + + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + const { text } = await supertest(server) + .get('/prg') + .set( + 'Cookie', + `adonis_session=${cookies.adonis_session.value}; ${sessionId}=${cookies[sessionId!].value}` + ) + + assert.deepEqual( + text.split('\n').map((line) => line.trim()), + ['

Access denied

', ''] + ) + }) + + test('define @error key from a variable', async ({ assert }) => { + let sessionId: string | undefined + + edge.registerTemplate('flash_errors_messages', { + template: ` + @error(errorCode) +

{{ $message }}

+ @else +

No error message

+ @end + `, + }) + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new Session(sessionConfig, cookieDriver, emitter, ctx) + await session.initiate(false) + sessionId = session.sessionId + + if (request.url() === '/prg') { + response.send( + await ctx.view.render('flash_errors_messages', { errorCode: 'E_ACCESS_DENIED' }) + ) + await session.commit() + response.finish() + } else { + session.flashErrors({ + E_ACCESS_DENIED: 'Access denied', + }) + await session.commit() + } + + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + + const { text } = await supertest(server) + .get('/prg') + .set( + 'Cookie', + `adonis_session=${cookies.adonis_session.value}; ${sessionId}=${cookies[sessionId!].value}` + ) + + assert.deepEqual( + text.split('\n').map((line) => line.trim()), + ['

Access denied

', ''] + ) + }) +}) diff --git a/tests/session_client.spec.ts b/tests/session_client.spec.ts new file mode 100644 index 0000000..cf758d9 --- /dev/null +++ b/tests/session_client.spec.ts @@ -0,0 +1,77 @@ +/** + * @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 { SessionClient } from '../src/client.js' +import { MemoryStore } from '../src/stores/memory.js' + +test.group('Session Client', (group) => { + group.each.teardown(async () => { + MemoryStore.sessions.clear() + }) + + test('define session data using session id', async ({ assert }) => { + const driver = new MemoryStore() + const client = new SessionClient(driver) + + client.merge({ foo: 'bar' }) + client.flash({ success: true }) + await client.commit() + + assert.deepEqual(driver.read(client.sessionId), { + foo: 'bar', + __flash__: { + success: true, + }, + }) + }) + + test('load data from the store', async ({ assert }) => { + const driver = new MemoryStore() + const client = new SessionClient(driver) + + client.merge({ foo: 'bar' }) + client.flash({ success: true }) + await client.commit() + + assert.deepEqual(await client.load(), { + values: { + foo: 'bar', + }, + flashMessages: { + success: true, + }, + }) + }) + + test('destroy session', async ({ assert }) => { + const driver = new MemoryStore() + const client = new SessionClient(driver) + + client.merge({ foo: 'bar' }) + client.flash({ success: true }) + await client.commit() + + assert.deepEqual(await client.load(), { + values: { + foo: 'bar', + }, + flashMessages: { + success: true, + }, + }) + + await client.destroy() + + assert.deepEqual(await client.load(), { + values: {}, + flashMessages: {}, + }) + }) +}) diff --git a/tests/session_middleware.spec.ts b/tests/session_middleware.spec.ts new file mode 100644 index 0000000..f44d54f --- /dev/null +++ b/tests/session_middleware.spec.ts @@ -0,0 +1,108 @@ +/** + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import supertest from 'supertest' +import { test } from '@japa/runner' +import setCookieParser from 'set-cookie-parser' +import { CookieClient } from '@adonisjs/core/http' +import { EncryptionFactory } from '@adonisjs/core/factories/encryption' +import { HttpContextFactory, RequestFactory, ResponseFactory } from '@adonisjs/core/factories/http' + +import { httpServer } from '../tests_helpers/index.js' +import { CookieStore } from '../src/stores/cookie.js' +import type { SessionConfig } from '../src/types.js' +import { SessionMiddlewareFactory } from '../factories/session_middleware_factory.js' + +const encryption = new EncryptionFactory().create() +const cookieClient = new CookieClient(encryption) +const sessionConfig: SessionConfig = { + enabled: true, + age: '2 hours', + clearWithBrowser: false, + cookieName: 'adonis_session', + cookie: {}, +} + +test.group('Session middleware', () => { + test('initiate and commit session around request', async ({ assert }) => { + let sessionId: string | undefined + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const middleware = await new SessionMiddlewareFactory() + .merge({ + config: Object.assign( + { + store: 'cookie', + stores: { + cookie: () => new CookieStore(sessionConfig.cookie, ctx), + }, + }, + sessionConfig + ), + }) + .create() + + await middleware.handle(ctx, () => { + sessionId = ctx.session.sessionId + ctx.session.put('username', 'virk') + ctx.session.flash({ status: 'Completed' }) + }) + + ctx.response.finish() + }) + + const { headers } = await supertest(server).get('/') + + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + assert.deepEqual(cookieClient.decrypt(sessionId!, cookies[sessionId!].value), { + username: 'virk', + __flash__: { + status: 'Completed', + }, + }) + }) + + test('do not initiate session when not enabled', async ({ assert }) => { + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const middleware = await new SessionMiddlewareFactory() + .merge({ + config: Object.assign( + { + store: 'cookie', + stores: { + cookie: () => new CookieStore(sessionConfig.cookie, ctx), + }, + }, + sessionConfig, + { + enabled: false, + } + ), + }) + .create() + + await middleware.handle(ctx, () => {}) + assert.isUndefined(ctx.session) + ctx.response.finish() + }) + + const { headers } = await supertest(server).get('/') + + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + assert.deepEqual(cookies, {}) + }) +}) diff --git a/tests/session_provider.spec.ts b/tests/session_provider.spec.ts new file mode 100644 index 0000000..3f3214c --- /dev/null +++ b/tests/session_provider.spec.ts @@ -0,0 +1,44 @@ +/* + * @adonisjs/redis + * + * (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 { IgnitorFactory } from '@adonisjs/core/factories' + +import { defineConfig } from '../index.js' +import SessionMiddleware from '../src/session_middleware.js' + +const BASE_URL = new URL('./tmp/', import.meta.url) + +test.group('Session Provider', () => { + test('register session provider', async ({ assert }) => { + const ignitor = new IgnitorFactory() + .merge({ + rcFileContents: { + providers: [() => import('../providers/session_provider.js')], + }, + }) + .withCoreConfig() + .withCoreProviders() + .merge({ + config: { + session: defineConfig({ + store: 'memory', + stores: {}, + }), + }, + }) + .create(BASE_URL) + + const app = ignitor.createApp('web') + await app.init() + await app.boot() + + assert.instanceOf(await app.container.make(SessionMiddleware), SessionMiddleware) + }) +}) diff --git a/tests/stores/cookie_store.spec.ts b/tests/stores/cookie_store.spec.ts new file mode 100644 index 0000000..43f00a8 --- /dev/null +++ b/tests/stores/cookie_store.spec.ts @@ -0,0 +1,159 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import supertest from 'supertest' +import { test } from '@japa/runner' +import setCookieParser from 'set-cookie-parser' +import { CookieClient } from '@adonisjs/core/http' +import type { CookieOptions } from '@adonisjs/core/types/http' +import { EncryptionFactory } from '@adonisjs/core/factories/encryption' +import { HttpContextFactory, RequestFactory, ResponseFactory } from '@adonisjs/core/factories/http' + +import { httpServer } from '../../tests_helpers/index.js' +import { CookieStore } from '../../src/stores/cookie.js' + +const encryption = new EncryptionFactory().create() +const cookieClient = new CookieClient(encryption) +const cookieConfig: Partial = { + sameSite: 'strict', + maxAge: '5mins', +} + +test.group('Cookie store', () => { + test('return null when session data cookie does not exists', async ({ assert }) => { + const sessionId = '1234' + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new CookieStore(cookieConfig, ctx) + const value = session.read(sessionId) + response.json(value) + response.finish() + }) + + const { body, text } = await supertest(server).get('/') + assert.deepEqual(body, {}) + assert.equal(text, '') + }) + + test('return session data from the cookie', async ({ assert }) => { + const sessionId = '1234' + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new CookieStore(cookieConfig, ctx) + const value = session.read(sessionId) + response.json(value) + response.finish() + }) + + const { body } = await supertest(server) + .get('/') + .set('cookie', `${sessionId}=${cookieClient.encrypt(sessionId, { visits: 1 })}`) + + assert.deepEqual(body, { visits: 1 }) + }) + + test('persist session data inside a cookie', async ({ assert }) => { + const sessionId = '1234' + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new CookieStore(cookieConfig, ctx) + session.write(sessionId, { visits: 0 }) + response.finish() + }) + + const { headers } = await supertest(server).get('/') + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + assert.deepEqual(cookieClient.decrypt(sessionId, cookies[sessionId].value), { + visits: 0, + }) + }) + + test('touch cookie by re-updating its attributes', async ({ assert }) => { + const sessionId = '1234' + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new CookieStore(cookieConfig, ctx) + session.touch(sessionId) + response.finish() + }) + + const { headers } = await supertest(server) + .get('/') + .set('Cookie', `${sessionId}=${cookieClient.encrypt(sessionId, { visits: 1 })}`) + + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + assert.deepEqual(cookieClient.decrypt(sessionId, cookies[sessionId].value), { + visits: 1, + }) + }) + + test('do not write cookie to response unless touch or write methods are called', async ({ + assert, + }) => { + const sessionId = '1234' + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new CookieStore(cookieConfig, ctx) + response.json(session.read(sessionId)) + response.finish() + }) + + const { headers, body } = await supertest(server) + .get('/') + .set('Cookie', `${sessionId}=${cookieClient.encrypt(sessionId, { visits: 1 })}`) + + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + assert.deepEqual(cookies, {}) + assert.deepEqual(body, { visits: 1 }) + }) + + test('delete session data cookie', async ({ assert }) => { + const sessionId = '1234' + + const server = httpServer.create(async (req, res) => { + const request = new RequestFactory().merge({ req, res, encryption }).create() + const response = new ResponseFactory().merge({ req, res, encryption }).create() + const ctx = new HttpContextFactory().merge({ request, response }).create() + + const session = new CookieStore(cookieConfig, ctx) + response.json(session.read(sessionId)) + session.destroy(sessionId) + response.finish() + }) + + const { headers, body } = await supertest(server) + .get('/') + .set('Cookie', `${sessionId}=${cookieClient.encrypt(sessionId, { visits: 1 })}`) + + const cookies = setCookieParser.parse(headers['set-cookie'], { map: true }) + assert.equal(cookies[sessionId].maxAge, -1) + assert.equal(cookies[sessionId].expires, new Date('1970-01-01').toString()) + assert.deepEqual(body, { visits: 1 }) + }) +}) diff --git a/tests/stores/file_store.spec.ts b/tests/stores/file_store.spec.ts new file mode 100644 index 0000000..e9a44ca --- /dev/null +++ b/tests/stores/file_store.spec.ts @@ -0,0 +1,154 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { join } from 'node:path' +import { test } from '@japa/runner' +import { stat } from 'node:fs/promises' +import { setTimeout } from 'node:timers/promises' + +import { FileStore } from '../../src/stores/file.js' + +test.group('File store', () => { + test('do not create file for a new session', async ({ fs, assert }) => { + const sessionId = '1234' + const session = new FileStore({ location: fs.basePath }, '2 hours') + + const value = await session.read(sessionId) + assert.isNull(value) + + await assert.fileNotExists('1234.txt') + }) + + test('create intermediate directories when missing', async ({ fs, assert }) => { + const sessionId = '1234' + const session = new FileStore( + { + location: join(fs.basePath, 'app/sessions'), + }, + '2 hours' + ) + + await session.write(sessionId, { message: 'hello-world' }) + + await assert.fileExists('app/sessions/1234.txt') + await assert.fileEquals( + 'app/sessions/1234.txt', + JSON.stringify({ message: { message: 'hello-world' }, purpose: '1234' }) + ) + }) + + test('update existing session', async ({ fs, assert }) => { + const sessionId = '1234' + const session = new FileStore( + { + location: fs.basePath, + }, + '2 hours' + ) + + await session.write(sessionId, { message: 'hello-world' }) + await assert.fileEquals( + '1234.txt', + JSON.stringify({ message: { message: 'hello-world' }, purpose: '1234' }) + ) + + await session.write(sessionId, { message: 'hi-world' }) + await assert.fileEquals( + '1234.txt', + JSON.stringify({ message: { message: 'hi-world' }, purpose: '1234' }) + ) + }) + + test('get session existing value', async ({ assert, fs }) => { + const sessionId = '1234' + const session = new FileStore({ location: fs.basePath }, '2 hours') + await session.write(sessionId, { message: 'hello-world' }) + + const value = await session.read(sessionId) + assert.deepEqual(value, { message: 'hello-world' }) + }) + + test('return null when session data is expired', async ({ assert, fs }) => { + const sessionId = '1234' + const session = new FileStore({ location: fs.basePath }, 1000) + await session.write(sessionId, { message: 'hello-world' }) + + await setTimeout(2000) + + const value = await session.read(sessionId) + assert.isNull(value) + }).disableTimeout() + + test('ignore malformed file contents', async ({ fs, assert }) => { + const sessionId = '1234' + const session = new FileStore({ location: fs.basePath }, '2 hours') + + await fs.create('1234.txt', '') + assert.isNull(await session.read(sessionId)) + + await fs.create('1234.txt', 'foo') + assert.isNull(await session.read(sessionId)) + + await fs.create('1234.txt', JSON.stringify({ foo: 'bar' })) + assert.isNull(await session.read(sessionId)) + }) + + test('remove file on destroy', async ({ assert, fs }) => { + const sessionId = '1234' + + const session = new FileStore({ location: fs.basePath }, '2 hours') + await session.write(sessionId, { message: 'hello-world' }) + await session.destroy(sessionId) + + await assert.fileNotExists('1234.txt') + }) + + test('do not fail when destroying a non-existing session', async ({ assert, fs }) => { + const sessionId = '1234' + + await assert.fileNotExists('1234.txt') + + const session = new FileStore({ location: fs.basePath }, '2 hours') + await session.destroy(sessionId) + + await assert.fileNotExists('1234.txt') + }) + + test('update session expiry on touch', async ({ assert, fs }) => { + const sessionId = '1234' + + const session = new FileStore({ location: fs.basePath }, '2 hours') + await session.write(sessionId, { message: 'hello-world' }) + + /** + * Waiting a bit + */ + await setTimeout(2000) + + /** + * Making sure the original mTime of the file was smaller + * than the current time after wait + */ + const { mtimeMs } = await stat(join(fs.basePath, '1234.txt')) + assert.isBelow(mtimeMs, Date.now()) + + await session.touch(sessionId) + + /** + * Ensuring the new mTime is greater than the old mTime + */ + let { mtimeMs: newMtimeMs } = await stat(join(fs.basePath, '1234.txt')) + assert.isAbove(newMtimeMs, mtimeMs) + + await assert.fileEquals( + '1234.txt', + JSON.stringify({ message: { message: 'hello-world' }, purpose: '1234' }) + ) + }).disableTimeout() +}) diff --git a/tests/stores/memory_store.spec.ts b/tests/stores/memory_store.spec.ts new file mode 100644 index 0000000..3e75419 --- /dev/null +++ b/tests/stores/memory_store.spec.ts @@ -0,0 +1,75 @@ +/* + * @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.js' + +test.group('Memory store', (group) => { + group.each.setup(() => { + return () => MemoryStore.sessions.clear() + }) + + test('return null when session does not exists', async ({ assert }) => { + const sessionId = '1234' + const session = new MemoryStore() + + assert.isNull(session.read(sessionId)) + }) + + test('write to session store', async ({ assert }) => { + const sessionId = '1234' + const session = new MemoryStore() + session.write(sessionId, { message: 'hello-world' }) + + assert.isTrue(MemoryStore.sessions.has(sessionId)) + assert.deepEqual(MemoryStore.sessions.get(sessionId), { message: 'hello-world' }) + }) + + test('update existing session', async ({ assert }) => { + const sessionId = '1234' + const session = new MemoryStore() + + session.write(sessionId, { message: 'hello-world' }) + assert.isTrue(MemoryStore.sessions.has(sessionId)) + assert.deepEqual(MemoryStore.sessions.get(sessionId), { message: 'hello-world' }) + + session.write(sessionId, { foo: 'bar' }) + assert.isTrue(MemoryStore.sessions.has(sessionId)) + assert.deepEqual(MemoryStore.sessions.get(sessionId), { foo: 'bar' }) + }) + + test('get session existing value', async ({ assert }) => { + const sessionId = '1234' + const session = new MemoryStore() + + session.write(sessionId, { message: 'hello-world' }) + assert.isTrue(MemoryStore.sessions.has(sessionId)) + assert.deepEqual(session.read(sessionId), { message: 'hello-world' }) + }) + + test('remove session on destroy', async ({ assert }) => { + const sessionId = '1234' + const session = new MemoryStore() + + session.write(sessionId, { message: 'hello-world' }) + session.destroy(sessionId) + + assert.isFalse(MemoryStore.sessions.has(sessionId)) + }) + + test('noop on touch', async ({ assert }) => { + const sessionId = '1234' + const session = new MemoryStore() + + session.write(sessionId, { message: 'hello-world' }) + session.touch() + + assert.deepEqual(MemoryStore.sessions.get(sessionId), { message: 'hello-world' }) + }) +}) diff --git a/tests/stores/redis_store.spec.ts b/tests/stores/redis_store.spec.ts new file mode 100644 index 0000000..bc7fdb5 --- /dev/null +++ b/tests/stores/redis_store.spec.ts @@ -0,0 +1,110 @@ +/* + * @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 { defineConfig } from '@adonisjs/redis' +import { setTimeout } from 'node:timers/promises' +import { RedisManagerFactory } from '@adonisjs/redis/factories' + +import { RedisStore } from '../../src/stores/redis.js' + +const sessionId = '1234' +const redisConfig = defineConfig({ + connection: 'main', + connections: { + main: { + host: process.env.REDIS_HOST || '0.0.0.0', + port: process.env.REDIS_PORT || 6379, + }, + }, +}) +const redis = new RedisManagerFactory(redisConfig).create() + +test.group('Redis store', (group) => { + group.tap((t) => { + t.skip(!!process.env.NO_REDIS, 'Redis not available in windows env') + }) + + group.each.setup(() => { + return async () => { + await redis.del(sessionId) + } + }) + + test('return null when value is missing', async ({ assert }) => { + const session = new RedisStore(redis.connection('main'), '2 hours') + const value = await session.read(sessionId) + assert.isNull(value) + }) + + test('save session data in a set', async ({ assert }) => { + const session = new RedisStore(redis.connection('main'), '2 hours') + await session.write(sessionId, { message: 'hello-world' }) + + assert.equal( + await redis.get(sessionId), + JSON.stringify({ + message: { message: 'hello-world' }, + purpose: sessionId, + }) + ) + }) + + test('return null when session data is expired', async ({ assert }) => { + const session = new RedisStore(redis.connection('main'), 1) + await session.write(sessionId, { message: 'hello-world' }) + + await setTimeout(2000) + + const value = await session.read(sessionId) + assert.isNull(value) + }).disableTimeout() + + test('ignore malformed contents', async ({ assert }) => { + const session = new RedisStore(redis.connection('main'), 1) + await redis.set(sessionId, 'foo') + + const value = await session.read(sessionId) + assert.isNull(value) + }) + + test('delete key on destroy', async ({ assert }) => { + const session = new RedisStore(redis.connection('main'), '2 hours') + + await session.write(sessionId, { message: 'hello-world' }) + await session.destroy(sessionId) + + assert.isNull(await redis.get(sessionId)) + }) + + test('update session expiry on touch', async ({ assert }) => { + const session = new RedisStore(redis.connection('main'), 10) + await session.write(sessionId, { message: 'hello-world' }) + + /** + * Waiting a bit + */ + await setTimeout(2000) + + /** + * Making sure the original mTime of the file was smaller + * than the current time after wait + */ + const expiry = await redis.ttl(sessionId) + assert.isBelow(expiry, 9) + + await session.touch(sessionId) + + /** + * Ensuring the new mTime is greater than the old mTime + */ + const expiryPostTouch = await redis.ttl(sessionId) + assert.isAtLeast(expiryPostTouch, 9) + }).disableTimeout() +}) diff --git a/tests/values_store.spec.ts b/tests/values_store.spec.ts new file mode 100644 index 0000000..11c9cd5 --- /dev/null +++ b/tests/values_store.spec.ts @@ -0,0 +1,164 @@ +/* + * @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 { ValuesStore } from '../src/values_store.js' + +test.group('Store', () => { + test('return empty object for empty store', ({ assert }) => { + const store = new ValuesStore(null) + assert.deepEqual(store.toJSON(), {}) + assert.isTrue(store.isEmpty) + assert.isFalse(store.hasBeenModified) + }) + + test('return default value when original value is null', ({ assert }) => { + const store = new ValuesStore({ title: null } as any) + assert.equal(store.get('title', ''), '') + }) + + test('mutate values inside store', ({ assert }) => { + const store = new ValuesStore({}) + store.set('username', 'virk') + + assert.isFalse(store.isEmpty) + assert.isTrue(store.hasBeenModified) + assert.deepEqual(store.toJSON(), { username: 'virk' }) + }) + + test('mutate nested values inside store', ({ assert }) => { + const store = new ValuesStore({}) + store.set('user.username', 'virk') + + assert.isFalse(store.isEmpty) + assert.isTrue(store.hasBeenModified) + assert.deepEqual(store.toJSON(), { user: { username: 'virk' } }) + }) + + test('remove value from store', ({ assert }) => { + const store = new ValuesStore(null) + store.set('user.username', 'virk') + store.unset('user.username') + + assert.isFalse(store.isEmpty) + assert.isTrue(store.hasBeenModified) + assert.deepEqual(store.toJSON(), { user: {} }) + }) + + test('increment value inside store', ({ assert }) => { + const store = new ValuesStore(null) + store.set('user.age', 22) + store.increment('user.age') + + assert.isFalse(store.isEmpty) + assert.isTrue(store.hasBeenModified) + assert.deepEqual(store.toJSON(), { user: { age: 23 } }) + }) + + test('throw when incrementing a non integer value', () => { + const store = new ValuesStore(null) + store.set('user.age', 'foo') + store.increment('user.age') + }).throws('Cannot increment "user.age". Existing value is not a number') + + test('decrement value inside store', ({ assert }) => { + const store = new ValuesStore(null) + store.set('user.age', 22) + store.decrement('user.age') + + assert.isFalse(store.isEmpty) + assert.isTrue(store.hasBeenModified) + assert.deepEqual(store.toJSON(), { user: { age: 21 } }) + }) + + test('throw when decrementing a non integer value', () => { + const store = new ValuesStore(null) + store.set('user.age', 'foo') + store.decrement('user.age') + }).throws('Cannot decrement "user.age". Existing value is not a number') + + test('find if value exists in the store', ({ assert }) => { + const store = new ValuesStore({}) + assert.isFalse(store.has('username')) + + store.update({ username: 'virk' }) + + assert.isTrue(store.has('username')) + assert.isFalse(store.isEmpty) + assert.isTrue(store.hasBeenModified) + }) + + test('check for arrays length', ({ assert }) => { + const store = new ValuesStore({}) + assert.isFalse(store.has('users')) + + store.update({ users: [] }) + assert.isFalse(store.has('users')) + + store.update({ users: ['virk'] }) + assert.isTrue(store.has('users')) + }) + + test('do not check for array length when explicitly said no', ({ assert }) => { + const store = new ValuesStore({}) + assert.isFalse(store.has('users')) + + store.update({ users: [] }) + assert.isTrue(store.has('users', false)) + + store.update({ users: ['virk'] }) + assert.isTrue(store.has('users')) + }) + + test('pull key from the store', ({ assert }) => { + const store = new ValuesStore({}) + store.set('username', 'virk') + + assert.equal(store.pull('username'), 'virk') + + assert.isTrue(store.isEmpty) + assert.isTrue(store.hasBeenModified) + assert.deepEqual(store.toJSON(), {}) + }) + + test('deep merge with existing values', ({ assert }) => { + const store = new ValuesStore({}) + store.set('user', { profile: { username: 'virk' }, id: 1 }) + store.merge({ user: { profile: { age: 32 } } }) + + assert.isFalse(store.isEmpty) + assert.isTrue(store.hasBeenModified) + + assert.deepEqual(store.toJSON(), { user: { id: 1, profile: { age: 32, username: 'virk' } } }) + }) + + test('clear store', ({ assert }) => { + const store = new ValuesStore({}) + store.set('user', { profile: { username: 'virk' }, id: 1 }) + store.clear() + + assert.isTrue(store.isEmpty) + assert.isTrue(store.hasBeenModified) + assert.deepEqual(store.toObject(), {}) + }) + + test('stringify store data object', ({ assert }) => { + const store = new ValuesStore({}) + store.set('user', { profile: { username: 'virk' }, id: 1 }) + store.merge({ user: { profile: { age: 32 } } }) + + assert.isFalse(store.isEmpty) + assert.isTrue(store.hasBeenModified) + + assert.equal( + store.toString(), + JSON.stringify({ user: { profile: { username: 'virk', age: 32 }, id: 1 } }) + ) + }) +}) diff --git a/tests_helpers/index.ts b/tests_helpers/index.ts new file mode 100644 index 0000000..03420f0 --- /dev/null +++ b/tests_helpers/index.ts @@ -0,0 +1,59 @@ +/* + * @adonisjs/session + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { getActiveTest } from '@japa/runner' +import type { Test } from '@japa/runner/core' +import { browserClient } from '@japa/browser-client' +import { pluginAdonisJS } from '@japa/plugin-adonisjs' +import { ApiClient, apiClient } from '@japa/api-client' +import { NamedReporterContract } from '@japa/runner/types' +import { runner, syncReporter } from '@japa/runner/factories' +import type { ApplicationService } from '@adonisjs/core/types' +import { IncomingMessage, ServerResponse, createServer } from 'node:http' + +import { sessionApiClient } from '../src/plugins/japa/api_client.js' +import { sessionBrowserClient } from '../src/plugins/japa/browser_client.js' + +export const httpServer = { + create(callback: (req: IncomingMessage, res: ServerResponse) => any) { + const server = createServer(callback) + getActiveTest()?.cleanup(async () => { + await new Promise((resolve) => { + server.close(() => resolve()) + }) + }) + return server + }, +} + +/** + * Runs a japa test in isolation + */ +export async function runJapaTest(app: ApplicationService, callback: Parameters[0]) { + ApiClient.clearSetupHooks() + ApiClient.clearTeardownHooks() + ApiClient.clearRequestHandlers() + + await runner() + .configure({ + reporters: { + activated: [syncReporter.name], + list: [syncReporter as NamedReporterContract], + }, + plugins: [ + apiClient(), + browserClient({}), + pluginAdonisJS(app), + sessionApiClient(app), + sessionBrowserClient(app), + ], + files: [], + }) + .runTest('testing japa integration', callback) +} diff --git a/tsconfig.json b/tsconfig.json index ec9a058..9d417c2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,7 @@ { - "extends": "./node_modules/@adonisjs/mrm-preset/_tsconfig", + "extends": "@adonisjs/tsconfig/tsconfig.package.json", "compilerOptions": { - "skipLibCheck": true + "rootDir": "./", + "outDir": "./build", }, - "files": [ - "./node_modules/@adonisjs/core/build/adonis-typings/index.d.ts", - "./node_modules/@japa/preset-adonis/build/adonis-typings/index.d.ts", - "./node_modules/@adonisjs/redis/build/adonis-typings/index.d.ts" - ] }