Skip to content
This repository has been archived by the owner on Apr 11, 2024. It is now read-only.

Add optional deleteSessions, findSessionsByShop #418

Merged
merged 6 commits into from
Jul 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

## Unreleased

- Add optional new methods `deleteSession` and `findSessionsByShop` to `SessionStorage`, with the corresponding implementations for the various session storage adapters [#418](https://github.com/Shopify/shopify-api-node/pull/418)

## [4.1.0] - 2022-07-14

- Add new method to construct the host app URL [#419](https://github.com/Shopify/shopify-api-node/pull/419)
Expand Down
10 changes: 8 additions & 2 deletions docs/issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@ By following this guide, you will have a fully functional Shopify app. However,

## Notes on session handling

Before you start writing your application, please note that the Shopify library stores some information for OAuth in sessions. Since each application may choose a different strategy to store information, the library cannot dictate any specific storage strategy. By default, `Shopify.Context` is initialized with `MemorySessionStorage`, which will enable you to start developing your app by storing sessions in memory. You can quickly get your app set up using it, but please keep the following in mind.
Before you start writing your application, please note that the Shopify library stores some information for OAuth in sessions. Since each application may choose a different strategy to store information, the library cannot dictate any specific storage strategy. By default, `Shopify.Context` is initialized with `SQLiteSessionStorage`, which will enable you to start developing your app by storing sessions using the file-based SQLite package. You can quickly get your app set up using it, but please keep the following in mind.

`MemorySessionStorage` is **purposely** designed to be a single-process, development-only solution. It **will leak** memory in most cases and delete all sessions when your app restarts. You should **never** use it in production apps. In order to use a `CustomSessionStorage` solution in your production app, you can reference our [usage example with redis](usage/customsessions.md) to get started.
`MemorySessionStorage` is **purposely** designed to be a single-process, development-only solution. It **will leak** memory in most cases and delete all sessions when your app restarts. You should **never** use it in production apps.

`SQLiteSessionStorage` (the library default) is designed to be a single-process solution and *may* be sufficient for your production needs depending on your applications design and needs.

The other storage adapters (`MongoDBSessionStorage`, `MySQLSessionStorage`, `PostgreSQLSessionStorage`, `RedisSessionStorage`) cover a variety of production-grade storage options.

If you wish to have an alternative storage solution, you must use a `CustomSessionStorage` solution in your production app- you can reference our [usage example with redis](usage/customsessions.md) to get started.

[Back to guide index](README.md)
21 changes: 17 additions & 4 deletions docs/usage/customsessions.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
# Create a `CustomSessionStorage` solution

This library comes with two session management options: `MemorySessionStorage` and `CustomSessionStorage`.
This library comes with various session management options:

`MemorySessionStorage` exists as an option to help you get started developing your apps as quickly as possible, and is the default storage option on `Shopify.Context`. It's perfect for working in your development and testing environments. However, this storage solution is not meant to be used in production [due to its limitations](../issues.md).
- `CustomSessionStorage` - to allow for a custom session storage solution (see below for details).
- `MemorySessionStorage` - uses memory exists as an option to help you get started developing your apps as quickly as possible. It's perfect for working in your development and testing environments. However, this storage solution is **not** meant to be used in production [due to its limitations](../issues.md).
- `MongoDBSessionStorage`
- `MySQLSessionStorage`
- `PostgreSQLSessionStorage`
- `RedisSessionStorage`
- `SQLiteSessionStorage` - uses the file-based SQLite package, and is the default storage option on `Shopify.Context`.

When you're ready to deploy your app and run it in production, you'll need to set up a `CustomSessionStorage`, which you can then use in initializing your `Shopify.Context`. The `CustomSessionStorage` class expects to be initialized with three callbacks that link to your chosen storage solution and map to the `storeSession`, `loadSession`, and `deleteSession` methods on the class.
If you wish to you an alternative session storage solution for production, you'll need to set up a `CustomSessionStorage`, which you can then use in initializing your `Shopify.Context`. The `CustomSessionStorage` class expects to be initialized with the following three mandatory callbacks that link to your chosen storage solution and map to the `storeSession`, `loadSession`, and `deleteSession` methods on the class.

## Callback methods

Expand All @@ -16,9 +22,16 @@ When you're ready to deploy your app and run it in production, you'll need to se
| `loadCallback` | `string` | `Promise<SessionInterface \| Record<string, unknown> \| undefined> ` | Takes in the id of the `Session` to load (as a `string`) and returns either an instance of a `Session`, an object to be used to instantiate a `Session`, or `undefined` if no record is found for the specified id. |
| `deleteCallback` | `string` | `Promise<boolean>` | Takes in the id of the `Session` to load (as a `string`) and returns a `booelan` (`true` if deleted successfully). |

- There are two optional callbacks methods that also be passed in during initialization:

| Optional Method | Arg type | Return type | Notes |
| ---------------------------- | ---------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| `deleteSessionsCallback` | `string[]` | `Promise<boolean>` | Takes in an array of ids of `Session`'s to be deleted (as an array of `string`), returns a `boolean` (`true` if deleted successfully). |
| `findSessionsByShopCallback` | `string` | `Promise<SessionInterface[]> ` | Takes in the shop domain (as a `string`) and returns an array of the sessions of that shop, or an empty array (`[]`) if none found. |

## Example usage

This is an example implementation of a `CustomSessionStorage` solution, using `redis` for storage.
This is an example implementation of a `CustomSessionStorage` solution, using `redis` for storage (mandatory callbacks only).

Before starting this tutorial, please first follow our [getting started guide](../getting_started.md).

Expand Down
14 changes: 14 additions & 0 deletions src/auth/session/session_storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ interface SessionStorage {
* @param id Id of the session to delete
*/
deleteSession(id: string): Promise<boolean>;

/**
* Deletes an array of sessions from storage.
*
* @param ids Array of session id's to delete
*/
deleteSessions?(ids: string[]): Promise<boolean>;

/**
* Return an array of sessions for a given shop (or [] if none found).
*
* @param shop shop of the session(s) to return
*/
findSessionsByShop?(shop: string): Promise<SessionInterface[]>;
}

export {SessionStorage};
65 changes: 65 additions & 0 deletions src/auth/session/storage/__tests__/battery-of-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import {Context} from '../../../../context';
import {Session} from '../../session';
import {sessionEqual} from '../../session-utils';
import {SessionStorage} from '../../session_storage';
import {SessionInterface} from '../../types';

import {sessionArraysEqual} from './session-test-utils';

export function batteryOfTests(storageFactory: () => Promise<SessionStorage>) {
it('can store and delete all kinds of sessions', async () => {
Expand Down Expand Up @@ -98,4 +101,66 @@ export function batteryOfTests(storageFactory: () => Promise<SessionStorage>) {
storage.loadSession('not_a_session_id'),
).resolves.toBeUndefined();
});

it('can find all the sessions for a given shop', async () => {
const storage = await storageFactory();
const prefix = 'find_sessions';
const sessions = [
new Session(`${prefix}_1`, 'find-shop1-sessions', 'state', true),
new Session(`${prefix}_2`, 'do-not-find-shop2-sessions', 'state', true),
new Session(`${prefix}_3`, 'find-shop1-sessions', 'state', true),
new Session(`${prefix}_4`, 'do-not-find-shop3-sessions', 'state', true),
];

for (const session of sessions) {
await expect(storage.storeSession(session)).resolves.toBe(true);
}
expect(storage.findSessionsByShop).toBeDefined();
if (storage.findSessionsByShop) {
const shop1Sessions = await storage.findSessionsByShop(
'find-shop1-sessions',
);
expect(shop1Sessions).toBeDefined();
if (shop1Sessions) {
expect(shop1Sessions.length).toBe(2);
expect(
sessionArraysEqual(shop1Sessions, [
sessions[0] as SessionInterface,
sessions[2] as SessionInterface,
]),
).toBe(true);
}
}
});

it('can delete the sessions for a given array of ids', async () => {
const storage = await storageFactory();
const prefix = 'delete_sessions';
const sessions = [
new Session(`${prefix}_1`, 'delete-shop1-sessions', 'state', true),
new Session(`${prefix}_2`, 'do-not-delete-shop2-sessions', 'state', true),
new Session(`${prefix}_3`, 'delete-shop1-sessions', 'state', true),
new Session(`${prefix}_4`, 'do-not-delete-shop3-sessions', 'state', true),
];

for (const session of sessions) {
await expect(storage.storeSession(session)).resolves.toBe(true);
}
expect(storage.deleteSessions).toBeDefined();
if (storage.deleteSessions && storage.findSessionsByShop) {
let shop1Sessions = await storage.findSessionsByShop(
'delete-shop1-sessions',
);
expect(shop1Sessions).toBeDefined();
if (shop1Sessions) {
expect(shop1Sessions.length).toBe(2);
const idsToDelete = shop1Sessions.map((session) => session.id);
await expect(storage.deleteSessions(idsToDelete)).resolves.toBe(true);
shop1Sessions = await storage.findSessionsByShop(
'delete-shop1-sessions',
);
expect(shop1Sessions).toEqual([]);
}
}
});
}
85 changes: 84 additions & 1 deletion src/auth/session/storage/__tests__/custom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {CustomSessionStorage} from '../custom';
import {SessionStorageError} from '../../../../error';

describe('custom session storage', () => {
test('can perform actions', async () => {
test('can perform core actions', async () => {
const sessionId = 'test_session';
let session: Session | undefined = new Session(
sessionId,
Expand Down Expand Up @@ -56,6 +56,89 @@ describe('custom session storage', () => {
expect(deleteCalled).toBe(true);
});

test('can perform optional actions', async () => {
const prefix = 'custom_sessions';
let sessions = [
new Session(`${prefix}_1`, 'shop1-sessions', 'state', true),
new Session(`${prefix}_2`, 'shop2-sessions', 'state', true),
new Session(`${prefix}_3`, 'shop1-sessions', 'state', true),
new Session(`${prefix}_4`, 'shop3-sessions', 'state', true),
];

let deleteSessionsCalled = false;
let findSessionsByShopCalled = false;
const storage = new CustomSessionStorage(
() => {
return Promise.resolve(true);
},
() => {
return Promise.resolve(sessions[0]);
},
() => {
return Promise.resolve(true);
},
() => {
deleteSessionsCalled = true;
sessions = [sessions[1], sessions[3]];
return Promise.resolve(true);
},
() => {
findSessionsByShopCalled = true;
if (deleteSessionsCalled) {
return Promise.resolve([]);
} else {
return Promise.resolve([sessions[0], sessions[2]]);
}
},
);

await expect(
storage.findSessionsByShop('shop1_sessinons'),
).resolves.toEqual([sessions[0], sessions[2]]);
expect(findSessionsByShopCalled).toBe(true);

await expect(
storage.deleteSessions([`${prefix}_1`, `${prefix}_3`]),
).resolves.toBe(true);
expect(deleteSessionsCalled).toBe(true);
expect(sessions.length).toBe(2);
await expect(
storage.findSessionsByShop('shop1_sessinons'),
).resolves.toEqual([]);
});

test('missing optional actions generate warnings', async () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation();
const prefix = 'custom_sessions';
const session = new Session(`${prefix}_1`, 'shop1-sessions', 'state', true);

const storage = new CustomSessionStorage(
() => {
return Promise.resolve(true);
},
() => {
return Promise.resolve(session);
},
() => {
return Promise.resolve(true);
},
);

await expect(
storage.findSessionsByShop('shop1_sessinons'),
).resolves.toEqual([]);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('findSessionsByShopCallback not defined.'),
);

await expect(
storage.deleteSessions([`${prefix}_1`, `${prefix}_3`]),
).resolves.toBe(false);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('deleteSessionsCallback not defined.'),
);
});

test('failures and exceptions are raised', () => {
const sessionId = 'test_session';
const session = new Session(sessionId, 'shop-url', 'state', true);
Expand Down
88 changes: 88 additions & 0 deletions src/auth/session/storage/__tests__/session-test-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import {Session} from '../../session';

import {sessionArraysEqual} from './session-test-utils';

describe('test sessionArraysEqual', () => {
it('returns true for two identically ordered arrays', () => {
const sessionsExpected = [
new Session('test_sessions_1', 'shop1-sessions', 'state', true),
new Session('test_sessions_2', 'shop2-sessions', 'state', true),
new Session('test_sessions_3', 'shop1-sessions', 'state', true),
new Session('test_sessions_4', 'shop3-sessions', 'state', true),
];
const sessionsToCompare = [
new Session('test_sessions_1', 'shop1-sessions', 'state', true),
new Session('test_sessions_2', 'shop2-sessions', 'state', true),
new Session('test_sessions_3', 'shop1-sessions', 'state', true),
new Session('test_sessions_4', 'shop3-sessions', 'state', true),
];

expect(sessionArraysEqual(sessionsToCompare, sessionsExpected)).toBe(true);
});

it('returns true for two arrays with same content but out of order', () => {
const sessionsExpected = [
new Session('test_sessions_1', 'shop1-sessions', 'state', true),
new Session('test_sessions_2', 'shop2-sessions', 'state', true),
new Session('test_sessions_3', 'shop1-sessions', 'state', true),
new Session('test_sessions_4', 'shop3-sessions', 'state', true),
];
const sessionsToCompare = [
new Session('test_sessions_1', 'shop1-sessions', 'state', true),
new Session('test_sessions_3', 'shop1-sessions', 'state', true),
new Session('test_sessions_2', 'shop2-sessions', 'state', true),
new Session('test_sessions_4', 'shop3-sessions', 'state', true),
];

expect(sessionArraysEqual(sessionsToCompare, sessionsExpected)).toBe(true);
});

it('returns false for two arrays not the same size', () => {
const sessionsExpected = [
new Session('test_sessions_1', 'shop1-sessions', 'state', true),
];
const sessionsToCompare = [
new Session('test_sessions_1', 'shop1-sessions', 'state', true),
new Session('test_sessions_3', 'shop1-sessions', 'state', true),
new Session('test_sessions_2', 'shop2-sessions', 'state', true),
new Session('test_sessions_4', 'shop3-sessions', 'state', true),
];

expect(sessionArraysEqual(sessionsToCompare, sessionsExpected)).toBe(false);
});

it('returns false for two arrays of the same size but different content', () => {
const sessionsExpected = [
new Session('test_sessions_1', 'shop1-sessions', 'state', true),
new Session('test_sessions_3', 'shop1-sessions', 'state', true),
new Session('test_sessions_2', 'shop2-sessions', 'state', true),
new Session('test_sessions_4', 'shop3-sessions', 'state', true),
];
let sessionsToCompare = [
new Session('test_sessions_1', 'shop1-sessions', 'state', true),
new Session('test_sessions_3', 'shop1-sessions', 'state', true),
new Session('test_sessions_2', 'shop2-sessions', 'state', true),
new Session('test_sessions_5', 'shop3-sessions', 'state', true),
];

expect(sessionArraysEqual(sessionsToCompare, sessionsExpected)).toBe(false);

sessionsToCompare = [
new Session('test_sessions_1', 'shop1-sessions', 'state', true),
new Session('test_sessions_3', 'shop1-sessions', 'state', true),
new Session('test_sessions_2', 'shop2-sessions', 'state', true),
new Session('test_sessions_4', 'shop4-sessions', 'state', true),
];

expect(sessionArraysEqual(sessionsToCompare, sessionsExpected)).toBe(false);

sessionsToCompare = [
new Session('test_sessions_1', 'shop1-sessions', 'state', true),
new Session('test_sessions_3', 'shop1-sessions', 'state', true),
new Session('test_sessions_2', 'shop2-sessions', 'state', true),
new Session('test_sessions_4', 'shop3-sessions', 'state', false),
];

expect(sessionArraysEqual(sessionsToCompare, sessionsExpected)).toBe(false);
});
});
25 changes: 25 additions & 0 deletions src/auth/session/storage/__tests__/session-test-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {sessionEqual} from '../../session-utils';
import {SessionInterface} from '../../types';

// compare two arrays of sessions that should contain
// the same sessions but may be in a different order
export function sessionArraysEqual(
sessionArray1: SessionInterface[],
sessionArray2: SessionInterface[],
): boolean {
if (sessionArray1.length !== sessionArray2.length) {
return false;
}

for (const session1 of sessionArray1) {
let found = false;
for (const session2 of sessionArray2) {
if (sessionEqual(session1, session2)) {
found = true;
continue;
}
}
if (!found) return false;
}
return true;
}
Loading