diff --git a/examples/common_nestjs_remix/apps/api/.env.example b/examples/common_nestjs_remix/apps/api/.env.example index 52c4d70..4a20d4d 100644 --- a/examples/common_nestjs_remix/apps/api/.env.example +++ b/examples/common_nestjs_remix/apps/api/.env.example @@ -1,3 +1,3 @@ DATABASE_URL="postgres://postgres:guidebook@localhost:5432/guidebook" JWT_SECRET= -REFRESH_SECRET= +JWT_REFRESH_SECRET= diff --git a/examples/common_nestjs_remix/apps/api/jest.config.ts b/examples/common_nestjs_remix/apps/api/jest.config.ts new file mode 100644 index 0000000..974b577 --- /dev/null +++ b/examples/common_nestjs_remix/apps/api/jest.config.ts @@ -0,0 +1,20 @@ +import type { Config } from "jest"; + +const config: Config = { + moduleFileExtensions: ["js", "json", "ts"], + rootDir: ".", + testRegex: ".*\\.spec\\.ts$", + transform: { + "^.+\\.(t|j)s$": "ts-jest", + }, + collectCoverageFrom: ["**/*.(t|j)s"], + coverageDirectory: "./coverage", + testEnvironment: "node", + setupFilesAfterEnv: ["/test/jest-setup.ts"], + moduleNameMapper: { + "^src/(.*)$": "/src/$1", + }, + modulePaths: ["."], +}; + +export default config; diff --git a/examples/common_nestjs_remix/apps/api/package.json b/examples/common_nestjs_remix/apps/api/package.json index db44e76..c0fb898 100644 --- a/examples/common_nestjs_remix/apps/api/package.json +++ b/examples/common_nestjs_remix/apps/api/package.json @@ -19,6 +19,7 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", + "test:e2e:watch": "jest --config ./test/jest-e2e.json --watch", "db:migrate": "drizzle-kit migrate", "db:generate": "drizzle-kit generate" }, @@ -35,6 +36,7 @@ "@sinclair/typebox": "^0.32.34", "add": "^2.0.6", "bcrypt": "^5.1.1", + "cookie": "^0.6.0", "cookie-parser": "^1.4.6", "drizzle-kit": "^0.22.8", "drizzle-orm": "^0.31.2", @@ -50,10 +52,12 @@ "uuid": "^10.0.0" }, "devDependencies": { + "@faker-js/faker": "^8.4.1", "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", "@types/bcrypt": "^5.0.2", + "@types/cookie": "^0.6.0", "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", @@ -68,31 +72,17 @@ "eslint": "^8.42.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", + "faker": "link:@types/@faker-js/faker", + "fishery": "^2.2.2", "jest": "^29.5.0", "prettier": "^3.0.0", "source-map-support": "^0.5.21", "supertest": "^6.3.3", + "testcontainers": "^10.10.3", "ts-jest": "^29.1.0", "ts-loader": "^9.4.3", "ts-node": "^10.9.1", "tsconfig-paths": "^4.2.0", "typescript": "^5.1.3" - }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coverageDirectory": "../coverage", - "testEnvironment": "node" } } diff --git a/examples/common_nestjs_remix/apps/api/src/app.module.ts b/examples/common_nestjs_remix/apps/api/src/app.module.ts index aee2739..2c63833 100644 --- a/examples/common_nestjs_remix/apps/api/src/app.module.ts +++ b/examples/common_nestjs_remix/apps/api/src/app.module.ts @@ -7,14 +7,13 @@ import { AuthModule } from "./auth/auth.module"; import { UsersModule } from "./users/users.module"; import { JwtModule } from "@nestjs/jwt"; import jwtConfig from "./common/configuration/jwt"; -import authConfig from "./common/configuration/auth"; import { APP_GUARD } from "@nestjs/core"; import { JwtAuthGuard } from "./common/guards/jwt-auth-guard"; @Module({ imports: [ ConfigModule.forRoot({ - load: [database, jwtConfig, authConfig], + load: [database, jwtConfig], isGlobal: true, }), DrizzlePostgresModule.registerAsync({ diff --git a/examples/common_nestjs_remix/apps/api/src/auth/__tests__/auth.controller.e2e-spec.ts b/examples/common_nestjs_remix/apps/api/src/auth/__tests__/auth.controller.e2e-spec.ts new file mode 100644 index 0000000..f45207c --- /dev/null +++ b/examples/common_nestjs_remix/apps/api/src/auth/__tests__/auth.controller.e2e-spec.ts @@ -0,0 +1,177 @@ +import { DatabasePg } from "../../common/index"; +import { INestApplication } from "@nestjs/common"; +import { isArray } from "lodash"; +import request from "supertest"; +import { createUserFactory } from "../../../test/factory/user.factory"; +import { createE2ETest } from "../../../test/create-e2e-test"; +import { AuthService } from "../auth.service"; +import * as cookie from "cookie"; + +describe("AuthController (e2e)", () => { + let app: INestApplication; + let authService: AuthService; + let db: DatabasePg; + let userFactory: ReturnType; + + beforeAll(async () => { + const { app: testApp } = await createE2ETest(); + app = testApp; + authService = app.get(AuthService); + db = app.get("DB"); + userFactory = createUserFactory(db); + }); + + describe("POST /auth/register", () => { + it("should register a new user", async () => { + const user = await userFactory + .withCredentials({ password: "password123" }) + .build(); + + const response = await request(app.getHttpServer()) + .post("/auth/register") + .set("Accept", "application/json") + .set("Content-Type", "application/json") + .send({ + email: user.email, + password: user.credentials?.password, + }); + + expect(response.status).toEqual(201); + expect(response.body.data).toHaveProperty("id"); + expect(response.body.data.email).toBe(user.email); + }); + + it("should return 409 if user already exists", async () => { + const existingUser = { + email: "existing@example.com", + password: "password123", + }; + + await authService.register(existingUser.email, existingUser.password); + + await request(app.getHttpServer()) + .post("/auth/register") + .send(existingUser) + .expect(409); + }); + }); + + describe("POST /auth/login", () => { + it("should login and return user data with cookies", async () => { + const user = await userFactory + .withCredentials({ + password: "password123", + }) + .create({ + email: "test@example.com", + }); + + const response = await request(app.getHttpServer()) + .post("/auth/login") + .send({ + email: user.email, + password: user.credentials?.password, + }); + + expect(response.status).toEqual(201); + expect(response.body.data).toHaveProperty("id"); + expect(response.body.data.email).toBe(user.email); + expect(response.headers["set-cookie"]).toBeDefined(); + expect(response.headers["set-cookie"].length).toBe(2); + }); + + it("should return 401 for invalid credentials", async () => { + await request(app.getHttpServer()) + .post("/auth/login") + .send({ + email: "wrong@example.com", + password: "wrongpassword", + }) + .expect(401); + }); + }); + + describe("POST /auth/logout", () => { + it("should clear token cookies for a logged-in user", async () => { + let accessToken = ""; + + const user = userFactory.build(); + const password = "password123"; + await authService.register(user.email, password); + + const loginResponse = await request(app.getHttpServer()) + .post("/auth/login") + .send({ + email: user.email, + password: password, + }); + + const cookies = loginResponse.headers["set-cookie"]; + + if (Array.isArray(cookies)) { + cookies.forEach((cookieString) => { + const parsedCookie = cookie.parse(cookieString); + if ("access_token" in parsedCookie) { + accessToken = parsedCookie.access_token; + } + }); + } + + const logoutResponse = await request(app.getHttpServer()) + .post("/auth/logout") + .set("Cookie", `access_token=${accessToken};`); + + const logoutCookies = logoutResponse.headers["set-cookie"]; + + expect(loginResponse.status).toBe(201); + expect(logoutResponse.status).toBe(201); + expect(logoutResponse.headers["set-cookie"]).toBeDefined(); + expect(logoutCookies.length).toBe(2); + expect(logoutCookies[0]).toContain("access_token=;"); + expect(logoutCookies[1]).toContain("refresh_token=;"); + }); + }); + + describe("POST /auth/refresh", () => { + it("should refresh tokens", async () => { + const user = await userFactory.build(); + const password = "password123"; + await authService.register(user.email, password); + + const loginResponse = await request(app.getHttpServer()) + .post("/auth/login") + .send({ + email: user.email, + password: password, + }) + .expect(201); + + const cookies = loginResponse.headers["set-cookie"]; + + let refreshToken = ""; + + if (isArray(cookies)) { + cookies.forEach((cookie) => { + if (cookie.startsWith("refresh_token=")) { + refreshToken = cookie; + } + }); + } + + const response = await request(app.getHttpServer()) + .post("/auth/refresh") + .set("Cookie", [refreshToken]) + .expect(201); + + expect(response.headers["set-cookie"]).toBeDefined(); + expect(response.headers["set-cookie"].length).toBe(2); + }); + + it("should return 401 for invalid refresh token", async () => { + await request(app.getHttpServer()) + .post("/auth/refresh") + .set("Cookie", ["refreshToken=invalid_token"]) + .expect(401); + }); + }); +}); diff --git a/examples/common_nestjs_remix/apps/api/src/auth/__tests__/auth.service.spec.ts b/examples/common_nestjs_remix/apps/api/src/auth/__tests__/auth.service.spec.ts new file mode 100644 index 0000000..3ffcff8 --- /dev/null +++ b/examples/common_nestjs_remix/apps/api/src/auth/__tests__/auth.service.spec.ts @@ -0,0 +1,139 @@ +import { ConflictException, UnauthorizedException } from "@nestjs/common"; +import * as bcrypt from "bcrypt"; +import { eq } from "drizzle-orm"; +import { AuthService } from "src/auth/auth.service"; +import { JwtService } from "@nestjs/jwt"; +import { credentials, users } from "../../storage/schema"; +import { DatabasePg } from "src/common"; +import { createUnitTest, TestContext } from "test/create-unit-test"; +import { createUserFactory } from "test/factory/user.factory"; +import { omit } from "lodash"; +import hashPassword from "src/common/helpers/hashPassword"; +import { truncateAllTables } from "test/helpers/test-helpers"; + +describe("AuthService", () => { + let testContext: TestContext; + let authService: AuthService; + let jwtService: JwtService; + let db: DatabasePg; + let userFactory: ReturnType; + + beforeAll(async () => { + testContext = await createUnitTest(); + authService = testContext.module.get(AuthService); + jwtService = testContext.module.get(JwtService); + db = testContext.db; + userFactory = createUserFactory(db); + }, 30000); + + afterEach(async () => { + await truncateAllTables(db); + }); + + describe("register", () => { + it("should register a new user successfully", async () => { + const user = userFactory.build(); + const password = "password123"; + + const result = await authService.register(user.email, password); + + const [savedUser] = await db + .select() + .from(users) + .where(eq(users.email, user.email)); + + const [savedCredentials] = await db + .select() + .from(credentials) + .where(eq(credentials.userId, savedUser.id)); + + expect(savedUser).toBeDefined(); + expect(result).toBeDefined(); + expect(result.email).toBe(user.email); + expect(savedCredentials).toBeDefined(); + expect(await bcrypt.compare(password, savedCredentials.password)).toBe( + true, + ); + }); + + it("should throw ConflictException if user already exists", async () => { + const email = "existing@example.com"; + const user = await userFactory.create({ email }); + + await expect( + authService.register(user.email, "password123"), + ).rejects.toThrow(ConflictException); + }); + }); + + describe("login", () => { + it("should login user successfully", async () => { + const password = "password123"; + const email = "example@test.com"; + const user = await userFactory + .withCredentials({ password }) + .create({ email }); + + const result = await authService.login({ + email: user.email, + password, + }); + + const decodedToken = await jwtService.verifyAsync(result.accessToken); + + expect(decodedToken.userId).toBe(user.id); + expect(result).toMatchObject({ + ...omit(user, "credentials"), + accessToken: expect.any(String), + refreshToken: expect.any(String), + }); + }); + + it("should throw UnauthorizedException for invalid email", async () => { + await expect( + authService.login({ + email: "nonexistent@example.com", + password: "password123", + }), + ).rejects.toThrow(UnauthorizedException); + }); + + it("should throw UnauthorizedException for invalid password", async () => { + const user = await userFactory.create({ email: "example@test.com" }); + + await expect( + authService.login({ + email: user.email, + password: "wrongpassword", + }), + ).rejects.toThrow(UnauthorizedException); + }); + }); + + describe("validateUser", () => { + it("should validate user successfully", async () => { + const email = "test@example.com"; + const password = "password123"; + const hashedPassword = await hashPassword(password); + + const [user] = await db.insert(users).values({ email }).returning(); + await db + .insert(credentials) + .values({ userId: user.id, password: hashedPassword }); + + const result = await authService.validateUser(email, password); + + expect(result).toBeDefined(); + expect(result!.email).toBe(email); + }); + + it("should return null for invalid credentials", async () => { + const email = "test@example.com"; + const password = "password123"; + + const result = await authService.validateUser(email, password); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/examples/common_nestjs_remix/apps/api/src/auth/api/auth.controller.ts b/examples/common_nestjs_remix/apps/api/src/auth/api/auth.controller.ts index f7c08d1..3216165 100644 --- a/examples/common_nestjs_remix/apps/api/src/auth/api/auth.controller.ts +++ b/examples/common_nestjs_remix/apps/api/src/auth/api/auth.controller.ts @@ -1,4 +1,3 @@ -import { UUIDType } from "src/common/index"; import { Body, Controller, @@ -15,6 +14,8 @@ import { Validate } from "nestjs-typebox"; import { baseResponse, BaseResponse, nullResponse } from "src/common"; import { Public } from "src/common/decorators/public.decorator"; import { RefreshTokenGuard } from "src/common/guards/refresh-token-guard"; +import { UUIDType } from "src/common/index"; +import { commonUserSchema } from "src/common/schemas/common-user.schema"; import { AuthService } from "../auth.service"; import { CreateAccountBody, @@ -22,7 +23,6 @@ import { } from "../schemas/create-account.schema"; import { LoginBody, loginSchema } from "../schemas/login.schema"; import { TokenService } from "../token.service"; -import { commonUserSchema } from "src/common/schemas/common-user.schema"; @Controller("auth") export class AuthController { diff --git a/examples/common_nestjs_remix/apps/api/src/auth/auth.service.ts b/examples/common_nestjs_remix/apps/api/src/auth/auth.service.ts index e640999..a8d472f 100644 --- a/examples/common_nestjs_remix/apps/api/src/auth/auth.service.ts +++ b/examples/common_nestjs_remix/apps/api/src/auth/auth.service.ts @@ -9,8 +9,9 @@ import { JwtService } from "@nestjs/jwt"; import * as bcrypt from "bcrypt"; import { eq } from "drizzle-orm"; import { DatabasePg } from "src/common"; -import { credentials, users } from "src/storage/schema"; -import { UsersService } from "src/users/users.service"; +import { credentials, users } from "../storage/schema"; +import { UsersService } from "../users/users.service"; +import hashPassword from "src/common/helpers/hashPassword"; @Injectable() export class AuthService { @@ -31,7 +32,7 @@ export class AuthService { throw new ConflictException("User already exists"); } - const hashedPassword = await bcrypt.hash(password, 10); + const hashedPassword = await hashPassword(password); return this.db.transaction(async (trx) => { const [newUser] = await trx.insert(users).values({ email }).returning(); @@ -63,18 +64,22 @@ export class AuthService { } public async refreshTokens(refreshToken: string) { - const payload = await this.jwtService.verifyAsync(refreshToken, { - secret: this.configService.get("jwt.refreshSecret"), - ignoreExpiration: false, - }); - - const user = await this.usersService.getUserById(payload.userId); - if (!user) { - throw new UnauthorizedException("User not found"); + try { + const payload = await this.jwtService.verifyAsync(refreshToken, { + secret: this.configService.get("jwt.refreshSecret"), + ignoreExpiration: false, + }); + + const user = await this.usersService.getUserById(payload.userId); + if (!user) { + throw new UnauthorizedException("User not found"); + } + + const tokens = await this.getTokens(user.id, user.email); + return tokens; + } catch (error) { + throw new UnauthorizedException("Invalid refresh token"); } - - const tokens = await this.getTokens(user.id, user.email); - return tokens; } public async validateUser(email: string, password: string) { diff --git a/examples/common_nestjs_remix/apps/api/src/auth/token.service.ts b/examples/common_nestjs_remix/apps/api/src/auth/token.service.ts index ef13d14..365be77 100644 --- a/examples/common_nestjs_remix/apps/api/src/auth/token.service.ts +++ b/examples/common_nestjs_remix/apps/api/src/auth/token.service.ts @@ -4,11 +4,9 @@ import { ACCESS_TOKEN_EXPIRATION_TIME, REFRESH_TOKEN_EXPIRATION_TIME, } from "./consts"; -import { ConfigService } from "@nestjs/config"; @Injectable() export class TokenService { - constructor(private readonly configService: ConfigService) {} setTokenCookies( response: Response, accessToken: string, @@ -26,10 +24,7 @@ export class TokenService { secure: true, sameSite: "strict", maxAge: REFRESH_TOKEN_EXPIRATION_TIME, - path: this.configService.get( - "auth.refreshTokenPath", - "/auth/refresh-token", - ), + path: "/auth/refresh", }); } diff --git a/examples/common_nestjs_remix/apps/api/src/common/configuration/auth.ts b/examples/common_nestjs_remix/apps/api/src/common/configuration/auth.ts deleted file mode 100644 index 8a1c6cf..0000000 --- a/examples/common_nestjs_remix/apps/api/src/common/configuration/auth.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { registerAs } from "@nestjs/config"; -import { Static, Type } from "@sinclair/typebox"; -import { Value } from "@sinclair/typebox/value"; - -const schema = Type.Object({ - refreshTokenPath: Type.String(), -}); - -type AuthConfig = Static; - -export default registerAs("auth", (): AuthConfig => { - const values = { - refreshTokenPath: process.env.REFRESH_TOKEN_PATH, - }; - - return Value.Decode(schema, values); -}); diff --git a/examples/common_nestjs_remix/apps/api/src/common/helpers/hashPassword.ts b/examples/common_nestjs_remix/apps/api/src/common/helpers/hashPassword.ts new file mode 100644 index 0000000..06ba730 --- /dev/null +++ b/examples/common_nestjs_remix/apps/api/src/common/helpers/hashPassword.ts @@ -0,0 +1,5 @@ +import * as bcrypt from "bcrypt"; + +export default function hashPassword(password: string): Promise { + return bcrypt.hash(password, 10); +} diff --git a/examples/common_nestjs_remix/apps/api/src/main.ts b/examples/common_nestjs_remix/apps/api/src/main.ts index 214599e..99c3cbf 100644 --- a/examples/common_nestjs_remix/apps/api/src/main.ts +++ b/examples/common_nestjs_remix/apps/api/src/main.ts @@ -4,7 +4,7 @@ import { patchNestJsSwagger, applyFormats } from "nestjs-typebox"; import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; import { exportSchemaToFile } from "./utils/save-swagger-to-file"; import { setupValidation } from "./utils/setup-validation"; -import * as cookieParser from "cookie-parser"; +import cookieParser from "cookie-parser"; patchNestJsSwagger(); applyFormats(); diff --git a/examples/common_nestjs_remix/apps/api/src/storage/migrations/0003_credentials_delete_cascade.sql b/examples/common_nestjs_remix/apps/api/src/storage/migrations/0003_credentials_delete_cascade.sql new file mode 100644 index 0000000..85ca0b7 --- /dev/null +++ b/examples/common_nestjs_remix/apps/api/src/storage/migrations/0003_credentials_delete_cascade.sql @@ -0,0 +1,9 @@ +ALTER TABLE "credentials" DROP CONSTRAINT "credentials_user_id_users_id_fk"; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "credentials" ADD CONSTRAINT "credentials_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +ALTER TABLE "credentials" DROP COLUMN IF EXISTS "refresh_token"; \ No newline at end of file diff --git a/examples/common_nestjs_remix/apps/api/src/storage/migrations/meta/0003_snapshot.json b/examples/common_nestjs_remix/apps/api/src/storage/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..36de07e --- /dev/null +++ b/examples/common_nestjs_remix/apps/api/src/storage/migrations/meta/0003_snapshot.json @@ -0,0 +1,117 @@ +{ + "id": "eabef35e-9f36-43a4-8943-3e92344b856d", + "prevId": "5f2c7f42-d248-479c-8935-ee79bd629b39", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "credentials_user_id_users_id_fk": { + "name": "credentials_user_id_users_id_fk", + "tableFrom": "credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(3) with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/examples/common_nestjs_remix/apps/api/src/storage/migrations/meta/_journal.json b/examples/common_nestjs_remix/apps/api/src/storage/migrations/meta/_journal.json index 46fa6c6..11c212a 100644 --- a/examples/common_nestjs_remix/apps/api/src/storage/migrations/meta/_journal.json +++ b/examples/common_nestjs_remix/apps/api/src/storage/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1720645366067, "tag": "0002_add_credentials_table", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1721046794234, + "tag": "0003_credentials_delete_cascade", + "breakpoints": true } ] } diff --git a/examples/common_nestjs_remix/apps/api/src/swagger/api-schema.json b/examples/common_nestjs_remix/apps/api/src/swagger/api-schema.json index 1c2fb8d..fc3d61b 100644 --- a/examples/common_nestjs_remix/apps/api/src/swagger/api-schema.json +++ b/examples/common_nestjs_remix/apps/api/src/swagger/api-schema.json @@ -453,14 +453,20 @@ "ChangePasswordBody": { "type": "object", "properties": { - "password": { + "newPassword": { + "minLength": 8, + "maxLength": 64, + "type": "string" + }, + "oldPassword": { "minLength": 8, "maxLength": 64, "type": "string" } }, "required": [ - "password" + "newPassword", + "oldPassword" ] }, "ChangePasswordResponse": { diff --git a/examples/common_nestjs_remix/apps/api/src/users/__tests__/user.controller.e2e-spec.ts b/examples/common_nestjs_remix/apps/api/src/users/__tests__/user.controller.e2e-spec.ts new file mode 100644 index 0000000..0958afd --- /dev/null +++ b/examples/common_nestjs_remix/apps/api/src/users/__tests__/user.controller.e2e-spec.ts @@ -0,0 +1,175 @@ +import { INestApplication } from "@nestjs/common"; +import request from "supertest"; +import { castArray, omit } from "lodash"; +import { AuthService } from "../../../src/auth/auth.service"; +import { createE2ETest } from "../../../test/create-e2e-test"; +import { + createUserFactory, + UserWithCredentials, +} from "../../../test/factory/user.factory"; +import { DatabasePg } from "../../../src/common"; + +describe("UsersController (e2e)", () => { + let app: INestApplication; + let authService: AuthService; + let testUser: UserWithCredentials; + let cookies: string; + const testPassword = "password123"; + let db: DatabasePg; + let userFactory: ReturnType; + + beforeAll(async () => { + const { app: testApp } = await createE2ETest(); + app = testApp; + authService = app.get(AuthService); + db = app.get("DB"); + userFactory = createUserFactory(db); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + testUser = await userFactory + .withCredentials({ password: testPassword }) + .create(); + + const loginResponse = await request(app.getHttpServer()) + .post("/auth/login") + .send({ + email: testUser.email, + password: testUser.credentials?.password, + }); + + cookies = loginResponse.headers["set-cookie"]; + }); + + describe("GET /users", () => { + it("should return all users", async () => { + const response = await request(app.getHttpServer()) + .get("/users") + .set("Cookie", cookies) + .expect(200); + + expect(response.body.data).toStrictEqual( + castArray(omit(testUser, "credentials")), + ); + expect(Array.isArray(response.body.data)).toBe(true); + }); + }); + + describe("GET /users/:id", () => { + it("should return a user by id", async () => { + const response = await request(app.getHttpServer()) + .get(`/users/${testUser.id}`) + .set("Cookie", cookies) + .expect(200); + + expect(response.body.data).toBeDefined(); + expect(response.body.data).toStrictEqual(omit(testUser, "credentials")); + }); + + it("should return 404 for non-existent user", async () => { + await request(app.getHttpServer()) + .get(`/users/${crypto.randomUUID()}`) + .set("Cookie", cookies) + .expect(404); + }); + }); + + describe("PATCH /users/:id", () => { + it("should update user", async () => { + const updateData = { email: "newemail@example.com" }; + const response = await request(app.getHttpServer()) + .patch(`/users/${testUser.id}`) + .set("Cookie", cookies) + .send(updateData) + .expect(200); + + expect(response.body.data.email).toBe(updateData.email); + }); + + it("should return 403 when updating another user", async () => { + const anotherUser = await authService.register( + "another@example.com", + "password123", + ); + await request(app.getHttpServer()) + .patch(`/users/${anotherUser.id}`) + .set("Cookie", cookies) + .send({ email: "newemail@example.com" }) + .expect(403); + }); + }); + + describe("PATCH /users/:id/change-password", () => { + it("should change password when old password is correct", async () => { + const newPassword = "newPassword123"; + + await request(app.getHttpServer()) + .patch(`/users/${testUser.id}/change-password`) + .set("Cookie", cookies) + .send({ oldPassword: testPassword, newPassword }) + .expect(200); + + const loginResponse = await request(app.getHttpServer()) + .post("/auth/login") + .send({ + email: testUser.email, + password: newPassword, + }) + .expect(201); + + expect(loginResponse.headers["set-cookie"]).toBeDefined(); + }); + + it("should return 401 when old password is incorrect", async () => { + const incorrectOldPassword = "wrongPassword"; + const newPassword = "newPassword123"; + + await request(app.getHttpServer()) + .patch(`/users/${testUser.id}/change-password`) + .set("Cookie", cookies) + .send({ oldPassword: incorrectOldPassword, newPassword }) + .expect(401); + }); + + it("should return 403 when changing another user's password", async () => { + const anotherUser = await authService.register( + "another2@example.com", + "password123", + ); + await request(app.getHttpServer()) + .patch(`/users/${anotherUser.id}/change-password`) + .set("Cookie", cookies) + .send({ oldPassword: "password123", newPassword: "newpassword" }) + .expect(403); + }); + }); + + describe("DELETE /users/:id", () => { + it("should delete user", async () => { + await request(app.getHttpServer()) + .delete(`/users/${testUser.id}`) + .set("Cookie", cookies) + .expect(200); + + await request(app.getHttpServer()) + .get(`/users/${testUser.id}`) + .set("Cookie", cookies) + .expect(404); + }); + + it("should return 403 when deleting another user", async () => { + const anotherUser = await authService.register( + "another3@example.com", + "password123", + ); + await request(app.getHttpServer()) + .delete(`/users/${anotherUser.id}`) + .set("Cookie", cookies) + .expect(403); + }); + }); +}); diff --git a/examples/common_nestjs_remix/apps/api/src/users/__tests__/users.service.spec.ts b/examples/common_nestjs_remix/apps/api/src/users/__tests__/users.service.spec.ts new file mode 100644 index 0000000..9342a96 --- /dev/null +++ b/examples/common_nestjs_remix/apps/api/src/users/__tests__/users.service.spec.ts @@ -0,0 +1,177 @@ +import { NotFoundException, UnauthorizedException } from "@nestjs/common"; +import * as bcrypt from "bcrypt"; +import { eq } from "drizzle-orm"; +import { credentials, users } from "../../storage/schema"; +import { DatabasePg } from "src/common"; +import { TestContext, createUnitTest } from "test/create-unit-test"; +import { UsersService } from "../users.service"; +import { createUserFactory } from "test/factory/user.factory"; +import { truncateAllTables } from "test/helpers/test-helpers"; + +describe("UsersService", () => { + let testContext: TestContext; + let usersService: UsersService; + let db: DatabasePg; + let userFactory: ReturnType; + + beforeAll(async () => { + testContext = await createUnitTest(); + usersService = testContext.module.get(UsersService); + db = testContext.db; + userFactory = createUserFactory(db); + }, 30000); + + afterAll(async () => { + await testContext.teardown(); + }); + + afterEach(async () => { + await truncateAllTables(db); + }); + + describe("getUsers", () => { + it("should return all users", async () => { + const testUsers = Array.from({ length: 2 }, () => userFactory.build()); + await db.insert(users).values(testUsers); + + const result = await usersService.getUsers(); + + expect(result).toHaveLength(2); + expect(result[0].email).toBe(testUsers[0].email); + expect(result[1].email).toBe(testUsers[1].email); + }); + }); + + describe("getUserById", () => { + it("should return a user by id", async () => { + const [testUser] = await db + .insert(users) + .values({ email: "test@example.com" }) + .returning(); + + const result = await usersService.getUserById(testUser.id); + + expect(result).toBeDefined(); + expect(result.email).toBe("test@example.com"); + }); + + it("should throw NotFoundException if user not found", async () => { + await expect( + usersService.getUserById(crypto.randomUUID()), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("updateUser", () => { + it("should update a user", async () => { + const [testUser] = await db + .insert(users) + .values({ email: "old@example.com" }) + .returning(); + + const updatedUser = await usersService.updateUser(testUser.id, { + email: "new@example.com", + }); + + const [dbUser] = await db + .select() + .from(users) + .where(eq(users.id, testUser.id)); + + expect(updatedUser).toBeDefined(); + expect(updatedUser.email).toBe("new@example.com"); + expect(dbUser.email).toBe("new@example.com"); + }); + + it("should throw NotFoundException if user not found", async () => { + await expect( + usersService.updateUser(crypto.randomUUID(), { + email: "new@example.com", + }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("changePassword", () => { + it("should change user password when old password is correct", async () => { + const oldPassword = "oldpassword"; + const newPassword = "newpassword"; + const oldHashedPassword = await bcrypt.hash(oldPassword, 10); + + const [testUser] = await db + .insert(users) + .values({ email: "test@example.com" }) + .returning(); + await db + .insert(credentials) + .values({ userId: testUser.id, password: oldHashedPassword }); + + await usersService.changePassword(testUser.id, oldPassword, newPassword); + + const [updatedCredentials] = await db + .select() + .from(credentials) + .where(eq(credentials.userId, testUser.id)); + expect(updatedCredentials).toBeDefined(); + expect( + await bcrypt.compare(newPassword, updatedCredentials.password), + ).toBe(true); + }); + + it("should throw UnauthorizedException if old password is incorrect", async () => { + const oldPassword = "oldpassword"; + const incorrectOldPassword = "wrongpassword"; + const newPassword = "newpassword"; + const oldHashedPassword = await bcrypt.hash(oldPassword, 10); + + const [testUser] = await db + .insert(users) + .values({ email: "test@example.com" }) + .returning(); + await db + .insert(credentials) + .values({ userId: testUser.id, password: oldHashedPassword }); + + await expect( + usersService.changePassword( + testUser.id, + incorrectOldPassword, + newPassword, + ), + ).rejects.toThrow(UnauthorizedException); + }); + + it("should throw NotFoundException if user not found", async () => { + await expect( + usersService.changePassword( + crypto.randomUUID(), + "oldpassword", + "newpassword", + ), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe("deleteUser", () => { + it("should delete a user", async () => { + const [testUser] = await db + .insert(users) + .values({ email: "test@example.com" }) + .returning(); + + await usersService.deleteUser(testUser.id); + + const [deletedUser] = await db + .select() + .from(users) + .where(eq(users.id, testUser.id)); + expect(deletedUser).toBeUndefined(); + }); + + it("should throw NotFoundException if user not found", async () => { + await expect( + usersService.deleteUser(crypto.randomUUID()), + ).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/examples/common_nestjs_remix/apps/api/src/users/api/users.controller.ts b/examples/common_nestjs_remix/apps/api/src/users/api/users.controller.ts index 4cab538..dc2fa46 100644 --- a/examples/common_nestjs_remix/apps/api/src/users/api/users.controller.ts +++ b/examples/common_nestjs_remix/apps/api/src/users/api/users.controller.ts @@ -70,10 +70,10 @@ export class UsersController { async updateUser( id: string, @Body() data: UpdateUserBody, - @CurrentUser() currentUser: { id: CommonUser["id"] }, + @CurrentUser() currentUser: { userId: CommonUser["id"] }, ): Promise>> { { - if (currentUser.id !== id) { + if (currentUser.userId !== id) { throw new ForbiddenException("You can only update your own account"); } @@ -99,7 +99,11 @@ export class UsersController { if (currentUser.userId !== id) { throw new ForbiddenException("You can only update your own account"); } - await this.usersService.changePassword(id, data.password); + await this.usersService.changePassword( + id, + data.oldPassword, + data.newPassword, + ); return null; } diff --git a/examples/common_nestjs_remix/apps/api/src/users/schemas/change-password.schema.ts b/examples/common_nestjs_remix/apps/api/src/users/schemas/change-password.schema.ts index cfe3f84..efe27d1 100644 --- a/examples/common_nestjs_remix/apps/api/src/users/schemas/change-password.schema.ts +++ b/examples/common_nestjs_remix/apps/api/src/users/schemas/change-password.schema.ts @@ -1,7 +1,8 @@ import { Static, Type } from "@sinclair/typebox"; export const changePasswordSchema = Type.Object({ - password: Type.String({ minLength: 8, maxLength: 64 }), + newPassword: Type.String({ minLength: 8, maxLength: 64 }), + oldPassword: Type.String({ minLength: 8, maxLength: 64 }), }); export type ChangePasswordBody = Static; diff --git a/examples/common_nestjs_remix/apps/api/src/users/users.service.ts b/examples/common_nestjs_remix/apps/api/src/users/users.service.ts index 24cff11..d677006 100644 --- a/examples/common_nestjs_remix/apps/api/src/users/users.service.ts +++ b/examples/common_nestjs_remix/apps/api/src/users/users.service.ts @@ -1,8 +1,14 @@ -import { Inject, Injectable, NotFoundException } from "@nestjs/common"; +import { + Inject, + Injectable, + NotFoundException, + UnauthorizedException, +} from "@nestjs/common"; import * as bcrypt from "bcrypt"; import { eq } from "drizzle-orm"; import { DatabasePg } from "src/common"; -import { credentials, users } from "src/storage/schema"; +import { credentials, users } from "../storage/schema"; +import hashPassword from "src/common/helpers/hashPassword"; @Injectable() export class UsersService { @@ -43,7 +49,7 @@ export class UsersService { return updatedUser; } - async changePassword(id: string, password: string) { + async changePassword(id: string, oldPassword: string, newPassword: string) { const [existingUser] = await this.db .select() .from(users) @@ -53,10 +59,27 @@ export class UsersService { throw new NotFoundException("User not found"); } - const hashedPassword = await bcrypt.hash(password, 10); + const [userCredentials] = await this.db + .select() + .from(credentials) + .where(eq(credentials.userId, id)); + + if (!userCredentials) { + throw new NotFoundException("User credentials not found"); + } + + const isOldPasswordValid = await bcrypt.compare( + oldPassword, + userCredentials.password, + ); + if (!isOldPasswordValid) { + throw new UnauthorizedException("Invalid old password"); + } + + const hashedNewPassword = await hashPassword(newPassword); await this.db .update(credentials) - .set({ password: hashedPassword }) + .set({ password: hashedNewPassword }) .where(eq(credentials.userId, id)); } diff --git a/examples/common_nestjs_remix/apps/api/test/app.e2e-spec.ts b/examples/common_nestjs_remix/apps/api/test/app.e2e-spec.ts deleted file mode 100644 index 50cda62..0000000 --- a/examples/common_nestjs_remix/apps/api/test/app.e2e-spec.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import * as request from 'supertest'; -import { AppModule } from './../src/app.module'; - -describe('AppController (e2e)', () => { - let app: INestApplication; - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - await app.init(); - }); - - it('/ (GET)', () => { - return request(app.getHttpServer()) - .get('/') - .expect(200) - .expect('Hello World!'); - }); -}); diff --git a/examples/common_nestjs_remix/apps/api/test/create-e2e-test.ts b/examples/common_nestjs_remix/apps/api/test/create-e2e-test.ts new file mode 100644 index 0000000..82de6cf --- /dev/null +++ b/examples/common_nestjs_remix/apps/api/test/create-e2e-test.ts @@ -0,0 +1,28 @@ +import { Provider } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import cookieParser from "cookie-parser"; +import { AppModule } from "../src/app.module"; +import { setupTestDatabase } from "./test-database"; + +export async function createE2ETest(customProviders: Provider[] = []) { + const { db, connectionString } = await setupTestDatabase(); + + process.env.DATABASE_URL = connectionString; + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + providers: [...customProviders], + }).compile(); + + const app = moduleFixture.createNestApplication(); + + app.use(cookieParser()); + + await app.init(); + + return { + app, + moduleFixture, + db, + }; +} diff --git a/examples/common_nestjs_remix/apps/api/test/create-unit-test.ts b/examples/common_nestjs_remix/apps/api/test/create-unit-test.ts new file mode 100644 index 0000000..4dd0719 --- /dev/null +++ b/examples/common_nestjs_remix/apps/api/test/create-unit-test.ts @@ -0,0 +1,39 @@ +import { Provider } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import { StartedTestContainer } from "testcontainers"; +import { AppModule } from "../src/app.module"; +import { DatabasePg } from "../src/common"; +import { setupTestDatabase } from "./test-database"; + +export interface TestContext { + module: TestingModule; + db: DatabasePg; + container: StartedTestContainer; + teardown: () => Promise; +} + +export async function createUnitTest( + customProviders: Provider[] = [], +): Promise { + const { db, container, connectionString } = await setupTestDatabase(); + + process.env.DATABASE_URL = connectionString; + + const module: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + providers: [...customProviders], + }).compile(); + + const teardown = async () => { + if (container) { + await container.stop(); + } + }; + + return { + module, + db, + container, + teardown, + }; +} diff --git a/examples/common_nestjs_remix/apps/api/test/factory/user.factory.ts b/examples/common_nestjs_remix/apps/api/test/factory/user.factory.ts new file mode 100644 index 0000000..9301f0c --- /dev/null +++ b/examples/common_nestjs_remix/apps/api/test/factory/user.factory.ts @@ -0,0 +1,62 @@ +import { faker } from "@faker-js/faker"; +import { InferInsertModel, InferSelectModel } from "drizzle-orm"; +import { Factory } from "fishery"; +import { credentials, users } from "../../src/storage/schema"; +import { DatabasePg } from "src/common"; +import hashPassword from "../../src/common/helpers/hashPassword"; + +type User = InferSelectModel; +export type UserWithCredentials = User & { credentials?: Credential }; +type Credential = InferInsertModel; + +export const credentialFactory = Factory.define(() => ({ + id: faker.string.uuid(), + userId: faker.string.uuid(), + password: faker.internet.password(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +})); + +class UserFactory extends Factory { + withCredentials(credential: { password: string }) { + return this.associations({ + credentials: credentialFactory.build(credential), + }); + } +} + +export const createUserFactory = (db: DatabasePg) => { + return UserFactory.define(({ onCreate, associations }) => { + onCreate(async (user) => { + const [inserted] = await db.insert(users).values(user).returning(); + + if (associations.credentials) { + const [insertedCredential] = await db + .insert(credentials) + .values({ + ...associations.credentials, + password: await hashPassword(associations.credentials.password), + userId: inserted.id, + }) + .returning(); + + return { + ...inserted, + credentials: { + ...insertedCredential, + password: associations.credentials.password, + }, + }; + } + + return inserted; + }); + + return { + id: faker.string.uuid(), + email: faker.internet.email(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + }); +}; diff --git a/examples/common_nestjs_remix/apps/api/test/helpers/test-helpers.ts b/examples/common_nestjs_remix/apps/api/test/helpers/test-helpers.ts new file mode 100644 index 0000000..ecb3e7f --- /dev/null +++ b/examples/common_nestjs_remix/apps/api/test/helpers/test-helpers.ts @@ -0,0 +1,50 @@ +import { DatabasePg } from "../../src/common"; +import { JwtService } from "@nestjs/jwt"; +import { sql } from "drizzle-orm"; + +type CamelToSnake = string extends T + ? string + : T extends `${infer C0}${infer R}` + ? CamelToSnake< + R, + `${P}${C0 extends Lowercase ? "" : "_"}${Lowercase}` + > + : P; + +type StringKeys = Extract; + +export function environmentVariablesFactory() { + return { + get: jest.fn((key: string) => { + switch (key) { + case "JWT_SECRET": + return "secret"; + case "DEBUG": + return "false"; + } + }), + }; +} + +export function signInAs(userId: string, jwtService: JwtService): string { + return jwtService.sign({ sub: userId }); +} + +export async function truncateAllTables(connection: DatabasePg): Promise { + const tables = connection._.tableNamesMap; + + for (const table of Object.keys(tables)) { + await connection.execute(sql.raw(`TRUNCATE TABLE "${table}" CASCADE;`)); + } +} + +export async function truncateTables( + connection: DatabasePg, + tables: Array< + CamelToSnake>> + >, +): Promise { + for (const table of tables) { + await connection.execute(sql.raw(`TRUNCATE TABLE "${table}" CASCADE;`)); + } +} diff --git a/examples/common_nestjs_remix/apps/api/test/jest-e2e-setup.ts b/examples/common_nestjs_remix/apps/api/test/jest-e2e-setup.ts new file mode 100644 index 0000000..b4de640 --- /dev/null +++ b/examples/common_nestjs_remix/apps/api/test/jest-e2e-setup.ts @@ -0,0 +1,12 @@ +import { applyFormats } from "nestjs-typebox"; +import { setupValidation } from "../src/utils/setup-validation"; +import { closeTestDatabase } from "./test-database"; + +beforeAll(async () => { + applyFormats(); + setupValidation(); +}); + +afterAll(async () => { + await closeTestDatabase(); +}); diff --git a/examples/common_nestjs_remix/apps/api/test/jest-e2e.json b/examples/common_nestjs_remix/apps/api/test/jest-e2e.json index e9d912f..9da190d 100644 --- a/examples/common_nestjs_remix/apps/api/test/jest-e2e.json +++ b/examples/common_nestjs_remix/apps/api/test/jest-e2e.json @@ -1,9 +1,20 @@ { - "moduleFileExtensions": ["js", "json", "ts"], - "rootDir": ".", + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "..", "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" - } + }, + "moduleNameMapper": { + "^src/(.*)$": "/src/$1" + }, + "setupFilesAfterEnv": [ + "/test/jest-e2e-setup.ts" + ], + "verbose": true } diff --git a/examples/common_nestjs_remix/apps/api/test/jest-setup.ts b/examples/common_nestjs_remix/apps/api/test/jest-setup.ts new file mode 100644 index 0000000..63679d1 --- /dev/null +++ b/examples/common_nestjs_remix/apps/api/test/jest-setup.ts @@ -0,0 +1,7 @@ +import { applyFormats } from "nestjs-typebox"; +import { setupValidation } from "../src/utils/setup-validation"; + +beforeAll(async () => { + applyFormats(); + setupValidation(); +}); diff --git a/examples/common_nestjs_remix/apps/api/test/test-database.ts b/examples/common_nestjs_remix/apps/api/test/test-database.ts new file mode 100644 index 0000000..95d559b --- /dev/null +++ b/examples/common_nestjs_remix/apps/api/test/test-database.ts @@ -0,0 +1,42 @@ +import { GenericContainer, StartedTestContainer } from "testcontainers"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import * as schema from "../src/storage/schema"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import { DatabasePg } from "../src/common"; +import path from "path"; + +let container: StartedTestContainer; +let sql: ReturnType; +let db: DatabasePg; + +export async function setupTestDatabase(): Promise<{ + db: DatabasePg; + container: StartedTestContainer; + connectionString: string; +}> { + container = await new GenericContainer("postgres:16") + .withExposedPorts(5432) + .withEnvironment({ + POSTGRES_DB: "testdb", + POSTGRES_USER: "testuser", + POSTGRES_PASSWORD: "testpass", + }) + .start(); + + const connectionString = `postgresql://testuser:testpass@${container.getHost()}:${container.getMappedPort(5432)}/testdb`; + + sql = postgres(connectionString); + db = drizzle(sql, { schema }) as DatabasePg; + + await migrate(db, { + migrationsFolder: path.join(__dirname, "../src/storage/migrations"), + }); + + return { db, container, connectionString }; +} + +export async function closeTestDatabase() { + if (sql) await sql.end(); + if (container) await container.stop(); +} diff --git a/examples/common_nestjs_remix/apps/api/tsconfig.json b/examples/common_nestjs_remix/apps/api/tsconfig.json index a1c778d..30e7cca 100644 --- a/examples/common_nestjs_remix/apps/api/tsconfig.json +++ b/examples/common_nestjs_remix/apps/api/tsconfig.json @@ -10,12 +10,17 @@ "sourceMap": true, "outDir": "./dist", "baseUrl": "./", + "paths": { + "src/*": ["src/*"] + }, "incremental": true, "skipLibCheck": true, "strictNullChecks": true, "noImplicitAny": true, "strictBindCallApply": true, "forceConsistentCasingInFileNames": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "resolveJsonModule": true } } diff --git a/examples/common_nestjs_remix/pnpm-lock.yaml b/examples/common_nestjs_remix/pnpm-lock.yaml index 83e5355..81ae351 100644 --- a/examples/common_nestjs_remix/pnpm-lock.yaml +++ b/examples/common_nestjs_remix/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: bcrypt: specifier: ^5.1.1 version: 5.1.1 + cookie: + specifier: ^0.6.0 + version: 0.6.0 cookie-parser: specifier: ^1.4.6 version: 1.4.6 @@ -99,6 +102,9 @@ importers: specifier: ^10.0.0 version: 10.0.0 devDependencies: + '@faker-js/faker': + specifier: ^8.4.1 + version: 8.4.1 '@nestjs/cli': specifier: ^10.0.0 version: 10.0.0(esbuild@0.19.12) @@ -111,6 +117,9 @@ importers: '@types/bcrypt': specifier: ^5.0.2 version: 5.0.2 + '@types/cookie': + specifier: ^0.6.0 + version: 0.6.0 '@types/cookie-parser': specifier: ^1.4.7 version: 1.4.7 @@ -153,6 +162,12 @@ importers: eslint-plugin-prettier: specifier: ^5.0.0 version: 5.0.0(eslint-config-prettier@9.1.0)(eslint@8.57.0)(prettier@3.2.5) + faker: + specifier: link:@types/@faker-js/faker + version: link:@types/@faker-js/faker + fishery: + specifier: ^2.2.2 + version: 2.2.2 jest: specifier: ^29.5.0 version: 29.5.0(@types/node@20.11.24)(ts-node@10.9.1) @@ -165,6 +180,9 @@ importers: supertest: specifier: ^6.3.3 version: 6.3.3 + testcontainers: + specifier: ^10.10.3 + version: 10.10.3 ts-jest: specifier: ^29.1.0 version: 29.1.0(@babel/core@7.24.7)(esbuild@0.19.12)(jest@29.5.0)(typescript@5.3.3) @@ -876,6 +894,10 @@ packages: to-fast-properties: 2.0.0 dev: true + /@balena/dockerignore@1.0.2: + resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} + dev: true + /@bcoe/v8-coverage@0.2.3: resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} dev: true @@ -1533,6 +1555,11 @@ packages: resolution: {integrity: sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==} dev: true + /@faker-js/faker@8.4.1: + resolution: {integrity: sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0, npm: '>=6.14.13'} + dev: true + /@humanwhocodes/config-array@0.11.14: resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -2753,6 +2780,21 @@ packages: '@types/ms': 0.7.34 dev: true + /@types/docker-modem@3.0.6: + resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==} + dependencies: + '@types/node': 20.14.10 + '@types/ssh2': 1.15.0 + dev: true + + /@types/dockerode@3.3.29: + resolution: {integrity: sha512-5PRRq/yt5OT/Jf77ltIdz4EiR9+VLnPF+HpU4xGFwUqmV24Co2HKBNW3w+slqZ1CYchbcDeqJASHDYWzZCcMiQ==} + dependencies: + '@types/docker-modem': 3.0.6 + '@types/node': 20.14.10 + '@types/ssh2': 1.15.0 + dev: true + /@types/eslint-scope@3.7.7: resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} dependencies: @@ -2904,6 +2946,12 @@ packages: resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} dev: true + /@types/node@18.19.39: + resolution: {integrity: sha512-nPwTRDKUctxw3di5b4TfT3I0sWDiWoPQCZjXhvdkINntwr8lcoVCKsTgnXeRubKIlfnV+eN/HYk6Jb40tbcEAQ==} + dependencies: + undici-types: 5.26.5 + dev: true + /@types/node@20.11.24: resolution: {integrity: sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==} dependencies: @@ -2993,6 +3041,25 @@ packages: '@types/send': 0.17.4 dev: true + /@types/ssh2-streams@0.1.12: + resolution: {integrity: sha512-Sy8tpEmCce4Tq0oSOYdfqaBpA3hDM8SoxoFh5vzFsu2oL+znzGz8oVWW7xb4K920yYMUY+PIG31qZnFMfPWNCg==} + dependencies: + '@types/node': 20.14.10 + dev: true + + /@types/ssh2@0.5.52: + resolution: {integrity: sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==} + dependencies: + '@types/node': 20.14.10 + '@types/ssh2-streams': 0.1.12 + dev: true + + /@types/ssh2@1.15.0: + resolution: {integrity: sha512-YcT8jP5F8NzWeevWvcyrrLB3zcneVjzYY9ZDSMAMboI+2zR1qYWFhwsyOFVzT7Jorn67vqxC0FRiw8YyG9P1ww==} + dependencies: + '@types/node': 18.19.39 + dev: true + /@types/stack-utils@2.0.3: resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} dev: true @@ -3676,6 +3743,51 @@ packages: resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} dev: false + /archiver-utils@2.1.0: + resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} + engines: {node: '>= 6'} + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 2.3.8 + dev: true + + /archiver-utils@3.0.4: + resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} + engines: {node: '>= 10'} + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + dev: true + + /archiver@5.3.2: + resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} + engines: {node: '>= 10'} + dependencies: + archiver-utils: 2.1.0 + async: 3.2.5 + buffer-crc32: 0.2.13 + readable-stream: 3.6.2 + readdir-glob: 1.1.3 + tar-stream: 2.2.0 + zip-stream: 4.1.1 + dev: true + /are-we-there-yet@2.0.0: resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==} engines: {node: '>=10'} @@ -3801,6 +3913,12 @@ packages: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} dev: true + /asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + dependencies: + safer-buffer: 2.1.2 + dev: true + /ast-types-flow@0.0.7: resolution: {integrity: sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==} dev: true @@ -3817,6 +3935,14 @@ packages: hasBin: true dev: true + /async-lock@1.4.1: + resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} + dev: true + + /async@3.2.5: + resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==} + dev: true + /asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -3862,6 +3988,10 @@ packages: engines: {node: '>= 0.4'} dev: true + /b4a@1.6.6: + resolution: {integrity: sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==} + dev: true + /babel-jest@29.7.0(@babel/core@7.24.7): resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3941,6 +4071,44 @@ packages: /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + /bare-events@2.4.2: + resolution: {integrity: sha512-qMKFd2qG/36aA4GwvKq8MxnPgCQAmBWmSyLWsJcbn8v03wvIPQ/hG1Ms8bPzndZxMDoHpxez5VOS+gC9Yi24/Q==} + requiresBuild: true + dev: true + optional: true + + /bare-fs@2.3.1: + resolution: {integrity: sha512-W/Hfxc/6VehXlsgFtbB5B4xFcsCl+pAh30cYhoFyXErf6oGrwjh8SwiPAdHgpmWonKuYpZgGywN0SXt7dgsADA==} + requiresBuild: true + dependencies: + bare-events: 2.4.2 + bare-path: 2.1.3 + bare-stream: 2.1.3 + dev: true + optional: true + + /bare-os@2.4.0: + resolution: {integrity: sha512-v8DTT08AS/G0F9xrhyLtepoo9EJBJ85FRSMbu1pQUlAf6A8T0tEEQGMVObWeqpjhSPXsE0VGlluFBJu2fdoTNg==} + requiresBuild: true + dev: true + optional: true + + /bare-path@2.1.3: + resolution: {integrity: sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==} + requiresBuild: true + dependencies: + bare-os: 2.4.0 + dev: true + optional: true + + /bare-stream@2.1.3: + resolution: {integrity: sha512-tiDAH9H/kP+tvNO5sczyn9ZAA7utrSMobyDchsnyyXBuUe2FSQWbxhtuHB8jwpHYYevVo2UJpcmvvjrbHboUUQ==} + requiresBuild: true + dependencies: + streamx: 2.18.0 + dev: true + optional: true + /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} dev: true @@ -3957,6 +4125,12 @@ packages: engines: {node: '>=10.0.0'} dev: true + /bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + dependencies: + tweetnacl: 0.14.5 + dev: true + /bcrypt@5.1.1: resolution: {integrity: sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==} engines: {node: '>= 10.0.0'} @@ -4069,6 +4243,10 @@ packages: node-int64: 0.4.0 dev: true + /buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + dev: true + /buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} dev: false @@ -4083,12 +4261,24 @@ packages: ieee754: 1.2.1 dev: true + /buildcheck@0.0.6: + resolution: {integrity: sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==} + engines: {node: '>=10.0.0'} + requiresBuild: true + dev: true + optional: true + /busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} dependencies: streamsearch: 1.1.0 + /byline@5.0.0: + resolution: {integrity: sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==} + engines: {node: '>=0.10.0'} + dev: true + /bytes@3.0.0: resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} engines: {node: '>= 0.8'} @@ -4411,6 +4601,16 @@ packages: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} dev: true + /compress-commons@4.1.2: + resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} + engines: {node: '>= 10'} + dependencies: + buffer-crc32: 0.2.13 + crc32-stream: 4.0.3 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + dev: true + /compressible@2.0.18: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} @@ -4551,6 +4751,30 @@ packages: typescript: 5.5.3 dev: true + /cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + requiresBuild: true + dependencies: + buildcheck: 0.0.6 + nan: 2.20.0 + dev: true + optional: true + + /crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + dev: true + + /crc32-stream@4.0.3: + resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} + engines: {node: '>= 10'} + dependencies: + crc-32: 1.2.2 + readable-stream: 3.6.2 + dev: true + /create-jest@29.7.0(@types/node@20.11.24)(ts-node@10.9.1): resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -4824,6 +5048,36 @@ packages: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} dev: true + /docker-compose@0.24.8: + resolution: {integrity: sha512-plizRs/Vf15H+GCVxq2EUvyPK7ei9b/cVesHvjnX4xaXjM9spHe2Ytq0BitndFgvTJ3E3NljPNUEl7BAN43iZw==} + engines: {node: '>= 6.0.0'} + dependencies: + yaml: 2.4.5 + dev: true + + /docker-modem@3.0.8: + resolution: {integrity: sha512-f0ReSURdM3pcKPNS30mxOHSbaFLcknGmQjwSfmbcdOw1XWKXVhukM3NJHhr7NpY9BIyyWQb0EBo3KQvvuU5egQ==} + engines: {node: '>= 8.0'} + dependencies: + debug: 4.3.5 + readable-stream: 3.6.2 + split-ca: 1.0.1 + ssh2: 1.15.0 + transitivePeerDependencies: + - supports-color + dev: true + + /dockerode@3.3.5: + resolution: {integrity: sha512-/0YNa3ZDNeLr/tSckmD69+Gq+qVNhvKfAHNeZJBnp7EOP6RGKV8ORrJHkUn20So5wU+xxT7+1n5u8PjHbfjbSA==} + engines: {node: '>= 8.0'} + dependencies: + '@balena/dockerignore': 1.0.2 + docker-modem: 3.0.8 + tar-fs: 2.0.1 + transitivePeerDependencies: + - supports-color + dev: true + /doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -5817,6 +6071,10 @@ packages: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} dev: true + /fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + dev: true + /fast-glob@3.3.2: resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} engines: {node: '>=8.6.0'} @@ -5908,6 +6166,12 @@ packages: path-exists: 4.0.0 dev: true + /fishery@2.2.2: + resolution: {integrity: sha512-jeU0nDhPHJkupmjX+r9niKgVMTBDB8X+U/pktoGHAiWOSyNlMd0HhmqnjrpjUOCDPJYaSSu4Ze16h6dZOKSp2w==} + dependencies: + lodash.mergewith: 4.6.2 + dev: true + /flat-cache@3.2.0: resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} engines: {node: ^10.12.0 || >=12.0.0} @@ -7592,6 +7856,13 @@ packages: language-subtag-registry: 0.3.23 dev: true + /lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + dependencies: + readable-stream: 2.3.8 + dev: true + /leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -7659,6 +7930,18 @@ packages: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} dev: true + /lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + dev: true + + /lodash.difference@4.5.0: + resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + dev: true + + /lodash.flatten@4.4.0: + resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + dev: true + /lodash.get@4.4.2: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} dev: true @@ -7681,7 +7964,6 @@ packages: /lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} - dev: false /lodash.isstring@4.0.1: resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} @@ -7695,10 +7977,18 @@ packages: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true + /lodash.mergewith@4.6.2: + resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} + dev: true + /lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} dev: false + /lodash.union@4.6.0: + resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} + dev: true + /lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -8266,6 +8556,13 @@ packages: dependencies: brace-expansion: 1.1.11 + /minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + dependencies: + brace-expansion: 2.0.1 + dev: true + /minimatch@8.0.4: resolution: {integrity: sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==} engines: {node: '>=16 || 14 >=14.17'} @@ -8421,6 +8718,12 @@ packages: thenify-all: 1.6.0 dev: true + /nan@2.20.0: + resolution: {integrity: sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==} + requiresBuild: true + dev: true + optional: true + /nanoid@3.3.7: resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -9323,6 +9626,21 @@ packages: react-is: 16.13.1 dev: true + /proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + dev: true + + /properties-reader@2.3.0: + resolution: {integrity: sha512-z597WicA7nDZxK12kZqHr2TcvwNU1GCfA5UwfDY/HDp3hXPoPlb5rlEx9bwGTiJnc0OqbBTkU975jDToth8Gxw==} + engines: {node: '>=14'} + dependencies: + mkdirp: 1.0.4 + dev: true + /property-information@6.5.0: resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} dev: true @@ -9401,6 +9719,10 @@ packages: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} dev: true + /queue-tick@1.0.1: + resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==} + dev: true + /randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} dependencies: @@ -9513,6 +9835,12 @@ packages: string_decoder: 1.3.0 util-deprecate: 1.0.2 + /readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + dependencies: + minimatch: 5.1.6 + dev: true + /readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -10075,6 +10403,10 @@ packages: resolution: {integrity: sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==} dev: true + /split-ca@1.0.1: + resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} + dev: true + /sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} dev: true @@ -10083,6 +10415,25 @@ packages: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} dev: true + /ssh-remote-port-forward@1.0.4: + resolution: {integrity: sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ==} + dependencies: + '@types/ssh2': 0.5.52 + ssh2: 1.15.0 + dev: true + + /ssh2@1.15.0: + resolution: {integrity: sha512-C0PHgX4h6lBxYx7hcXwu3QWdh4tg6tZZsTfXcdvc5caW/EMxaB4H9dWsl7qk+F7LAW762hp8VbXOX7x4xUYvEw==} + engines: {node: '>=10.16.0'} + requiresBuild: true + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.20.0 + dev: true + /ssri@10.0.6: resolution: {integrity: sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -10112,6 +10463,16 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} + /streamx@2.18.0: + resolution: {integrity: sha512-LLUC1TWdjVdn1weXGcSxyTR3T4+acB6tVGXT95y0nGbca4t4o/ng1wKAGTljm9VicuCVLvRlqFYXYy5GwgM7sQ==} + dependencies: + fast-fifo: 1.3.2 + queue-tick: 1.0.1 + text-decoder: 1.1.1 + optionalDependencies: + bare-events: 2.4.2 + dev: true + /string-hash@1.1.3: resolution: {integrity: sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==} dev: true @@ -10419,6 +10780,15 @@ packages: engines: {node: '>=6'} dev: true + /tar-fs@2.0.1: + resolution: {integrity: sha512-6tzWDMeroL87uF/+lin46k+Q+46rAJ0SyPGz7OW7wTgblI273hsBqk2C1j0/xNadNLKDTUL9BukSjB7cwgmlPA==} + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.0 + tar-stream: 2.2.0 + dev: true + /tar-fs@2.1.1: resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==} dependencies: @@ -10428,6 +10798,16 @@ packages: tar-stream: 2.2.0 dev: true + /tar-fs@3.0.6: + resolution: {integrity: sha512-iokBDQQkUyeXhgPYaZxmczGPhnhXZ0CmrqI+MOb/WFGS9DW5wnfrLgtjUJBvz50vQ3qfRwJ62QVoCFu8mPVu5w==} + dependencies: + pump: 3.0.0 + tar-stream: 3.1.7 + optionalDependencies: + bare-fs: 2.3.1 + bare-path: 2.1.3 + dev: true + /tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} @@ -10439,6 +10819,14 @@ packages: readable-stream: 3.6.2 dev: true + /tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + dependencies: + b4a: 1.6.6 + fast-fifo: 1.3.2 + streamx: 2.18.0 + dev: true + /tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} @@ -10520,6 +10908,35 @@ packages: minimatch: 3.1.2 dev: true + /testcontainers@10.10.3: + resolution: {integrity: sha512-QuHKgGbMo+rM+AvrHNzQFAu8/D37Od1sQCW8lNR5+KvGM82mDJndTkpPXiUaFpVIZ99wNQfhZbZwSTBULerUiQ==} + dependencies: + '@balena/dockerignore': 1.0.2 + '@types/dockerode': 3.3.29 + archiver: 5.3.2 + async-lock: 1.4.1 + byline: 5.0.0 + debug: 4.3.5 + docker-compose: 0.24.8 + dockerode: 3.3.5 + get-port: 5.1.1 + node-fetch: 2.7.0 + proper-lockfile: 4.1.2 + properties-reader: 2.3.0 + ssh-remote-port-forward: 1.0.4 + tar-fs: 3.0.6 + tmp: 0.2.3 + transitivePeerDependencies: + - encoding + - supports-color + dev: true + + /text-decoder@1.1.1: + resolution: {integrity: sha512-8zll7REEv4GDD3x4/0pW+ppIxSNs7H1J10IKFZsuOMscumCdM2a+toDGLPA3T+1+fLBql4zbt5z83GEQGGV5VA==} + dependencies: + b4a: 1.6.6 + dev: true + /text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} dev: true @@ -10573,6 +10990,11 @@ packages: os-tmpdir: 1.0.2 dev: true + /tmp@0.2.3: + resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} + engines: {node: '>=14.14'} + dev: true + /tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} dev: true @@ -10851,6 +11273,10 @@ packages: turbo-windows-arm64: 2.0.6 dev: true + /tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + dev: true + /type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -11587,6 +12013,15 @@ packages: engines: {node: '>=10'} dev: true + /zip-stream@4.1.1: + resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} + engines: {node: '>= 10'} + dependencies: + archiver-utils: 3.0.4 + compress-commons: 4.1.2 + readable-stream: 3.6.2 + dev: true + /zustand@4.5.4(@types/react@18.2.61)(react@18.3.1): resolution: {integrity: sha512-/BPMyLKJPtFEvVL0E9E9BTUM63MNyhPGlvxk1XjrfWTUlV+BR8jufjsovHzrtR6YNcBEcL7cMHovL1n9xHawEg==} engines: {node: '>=12.7.0'}