diff --git a/docs/modules/supertokens.md b/docs/modules/supertokens.md new file mode 100644 index 000000000..2258f1040 --- /dev/null +++ b/docs/modules/supertokens.md @@ -0,0 +1,43 @@ +# Supertokens Module + +[Supertokens](https://supertokens.com/): Open source alternative to Auth0 / Firebase Auth / AWS Cognito. + +## Install + +```bash +npm install @testcontainers/supertokens --save-dev +``` + +## Examples + +The following examples communicate with the [API](https://app.swaggerhub.com/apis/supertokens/CDI/4.0.2/) exposed by the SuperTokens Core, which are meant to be consumed by your backend only. + +Register a new user using email password: + +```javascript +const container = await SupertokensContainer().start() +const response = await fetch(`${container.getConnectionUri()}/recipe/signup`, { + method: "POST", + headers: { + rid: "emailpassword", + "Content-Type": "application/json", + }, + body: JSON.stringify({ email, password }), +}); +const user = (await response.json()).user; +``` + +Sign in an existing user: + +```javascript +const container = await SupertokensContainer().start() +const response = await fetch(`${container.getConnectionUri()}/recipe/signin`, { + method: "POST", + headers: { + rid: "emailpassword", + "Content-Type": "application/json", + }, + body: JSON.stringify({ email, password }), +}); +const user = (await response.json()).user; +``` diff --git a/mkdocs.yml b/mkdocs.yml index 312724c25..4203e0b61 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -56,4 +56,5 @@ nav: - PostgreSQL: modules/postgresql.md - Redis: modules/redis.md - Selenium: modules/selenium.md + - Supertokens: modules/supertokens.md - Configuration: configuration.md diff --git a/package-lock.json b/package-lock.json index e1768dd73..ea71a9036 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4336,6 +4336,10 @@ "resolved": "packages/modules/selenium", "link": true }, + "node_modules/@testcontainers/supertokens": { + "resolved": "packages/modules/supertokens", + "link": true + }, "node_modules/@tokenizer/token": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", @@ -17154,6 +17158,13 @@ "selenium-webdriver": "^4.14.0" } }, + "packages/modules/supertokens": { + "version": "10.5.0", + "license": "MIT", + "dependencies": { + "testcontainers": "^10.5.0" + } + }, "packages/testcontainers": { "version": "10.7.1", "license": "MIT", diff --git a/packages/modules/supertokens/jest.config.ts b/packages/modules/supertokens/jest.config.ts new file mode 100644 index 000000000..1f677baaf --- /dev/null +++ b/packages/modules/supertokens/jest.config.ts @@ -0,0 +1,11 @@ +import type { Config } from "jest"; +import * as path from "path"; + +const config: Config = { + preset: "ts-jest", + moduleNameMapper: { + "^testcontainers$": path.resolve(__dirname, "../../testcontainers/src"), + }, +}; + +export default config; diff --git a/packages/modules/supertokens/package.json b/packages/modules/supertokens/package.json new file mode 100644 index 000000000..90ef6427b --- /dev/null +++ b/packages/modules/supertokens/package.json @@ -0,0 +1,34 @@ +{ + "name": "@testcontainers/supertokens", + "version": "10.5.0", + "license": "MIT", + "keywords": [ + "supertokens", + "testing", + "docker", + "testcontainers" + ], + "description": "Supertokens module for Testcontainers", + "homepage": "https://github.com/testcontainers/testcontainers-node#readme", + "repository": { + "type": "git", + "url": "https://github.com/testcontainers/testcontainers-node" + }, + "bugs": { + "url": "https://github.com/testcontainers/testcontainers-node/issues" + }, + "main": "build/index.js", + "files": [ + "build" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "prepack": "shx cp ../../../README.md . && shx cp ../../../LICENSE .", + "build": "tsc --project tsconfig.build.json" + }, + "dependencies": { + "testcontainers": "^10.5.0" + } +} diff --git a/packages/modules/supertokens/src/index.ts b/packages/modules/supertokens/src/index.ts new file mode 100644 index 000000000..762a6edba --- /dev/null +++ b/packages/modules/supertokens/src/index.ts @@ -0,0 +1 @@ +export { SupertokensContainer, StartedSupertokensContainer } from "./supertokens-container"; diff --git a/packages/modules/supertokens/src/supertokens-container.test.ts b/packages/modules/supertokens/src/supertokens-container.test.ts new file mode 100644 index 000000000..92b8b857e --- /dev/null +++ b/packages/modules/supertokens/src/supertokens-container.test.ts @@ -0,0 +1,98 @@ +import { StartedSupertokensContainer, SupertokensContainer } from "./supertokens-container"; + +describe("SupertokensContainer", () => { + jest.setTimeout(180_000); + let container: StartedSupertokensContainer; + let baseUrl: string; + + beforeAll(async () => { + container = await new SupertokensContainer().start(); + baseUrl = container.getConnectionUri(); + }); + + afterAll(async () => { + await container.stop(); + }); + + const signUpUser = async (email: string, password: string) => { + const response = await fetch(`${baseUrl}/recipe/signup`, { + method: "POST", + headers: { + rid: "emailpassword", + "Content-Type": "application/json", + }, + body: JSON.stringify({ email, password }), + }); + return await response.json(); + }; + + const signInUser = async (email: string, password: string) => { + const response = await fetch(`${baseUrl}/recipe/signin`, { + method: "POST", + headers: { + rid: "emailpassword", + "Content-Type": "application/json", + }, + body: JSON.stringify({ email, password }), + }); + return await response.json(); + }; + + const verifyEmail = async (userId: string, email: string) => { + const emailTokenResponse = await fetch(`${baseUrl}/recipe/user/email/verify/token`, { + method: "POST", + headers: { + rid: "emailverification", + "Content-Type": "application/json", + }, + body: JSON.stringify({ userId, email }), + }); + const token = (await emailTokenResponse.json()).token; + const res = await fetch(`${baseUrl}/recipe/user/email/verify`, { + method: "POST", + headers: { + rid: "emailverification", + "Content-Type": "application/json", + }, + body: JSON.stringify({ method: "token", token }), + }); + return await res.json(); + }; + + it("should register a new user", async () => { + const email = "newuser@example.com"; + const password = "password123"; + const signUpResponse = await signUpUser(email, password); + + expect(signUpResponse.status).toBe("OK"); + expect(signUpResponse.user.emails[0]).toBe(email); + }); + + it("should sign in an existing user", async () => { + const email = "existinguser@example.com"; + const password = "password123"; + await signUpUser(email, password); + const signInResponse = await signInUser(email, password); + + expect(signInResponse.status).toBe("OK"); + expect(signInResponse.user.emails[0]).toBe(email); + }); + + it("should verify a user's email", async () => { + const email = "verifyemail@example.com"; + const password = "password123"; + const signUpResponse = await signUpUser(email, password); + const verifyResponse = await verifyEmail(signUpResponse.user.id, email); + + expect(verifyResponse.status).toBe("OK"); + }); + + it("should not register a user with an existing email", async () => { + const email = "duplicate@example.com"; + const password = "password123"; + await signUpUser(email, password); + const secondSignUpResponse = await signUpUser(email, password); + + expect(secondSignUpResponse.status).toBe("EMAIL_ALREADY_EXISTS_ERROR"); + }); +}); diff --git a/packages/modules/supertokens/src/supertokens-container.ts b/packages/modules/supertokens/src/supertokens-container.ts new file mode 100644 index 000000000..6e76049b8 --- /dev/null +++ b/packages/modules/supertokens/src/supertokens-container.ts @@ -0,0 +1,33 @@ +import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers"; + +export const SUPERTOKENS_PORT = 3567; + +export class SupertokensContainer extends GenericContainer { + constructor(image = "registry.supertokens.io/supertokens/supertokens-postgresql:7.0") { + super(image); + } + + protected override async beforeContainerCreated(): Promise { + this.withExposedPorts(...(this.exposedPorts ?? [SUPERTOKENS_PORT])) + .withWaitStrategy(Wait.forHttp("/hello", SUPERTOKENS_PORT)) + .withStartupTimeout(120_000); + } + + public override async start(): Promise { + return new StartedSupertokensContainer(await super.start()); + } +} + +export class StartedSupertokensContainer extends AbstractStartedContainer { + constructor(startedTestContainer: StartedTestContainer) { + super(startedTestContainer); + } + + public getPort(): number { + return this.startedTestContainer.getMappedPort(SUPERTOKENS_PORT); + } + + public getConnectionUri(): string { + return `http://${this.getHost()}:${this.getPort().toString()}`; + } +} diff --git a/packages/modules/supertokens/tsconfig.build.json b/packages/modules/supertokens/tsconfig.build.json new file mode 100644 index 000000000..82b71d92e --- /dev/null +++ b/packages/modules/supertokens/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["build", "jest.config.ts", "src/**/*.test.ts"], + "references": [ + { + "path": "../../testcontainers" + } + ] +} diff --git a/packages/modules/supertokens/tsconfig.json b/packages/modules/supertokens/tsconfig.json new file mode 100644 index 000000000..9ac528252 --- /dev/null +++ b/packages/modules/supertokens/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "build", + "paths": { + "testcontainers": ["../../testcontainers/src"] + } + }, + "exclude": ["build", "jest.config.ts"], + "references": [ + { + "path": "../../testcontainers" + } + ] +}