@nest-arch/tui 0.4.0 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1 -1
- package/dist/{constants-C3xmDWeK.js → constants-7dJLZpB2.js} +1 -1
- package/dist/index.js +188 -134
- package/dist/templates/addons/husky/pre-commit.hbs +11 -26
- package/dist/templates/base/.env.example.hbs +9 -0
- package/dist/templates/base/.env.hbs +7 -0
- package/dist/templates/base/README.md.hbs +16 -0
- package/dist/templates/base/pnpm-workspace.yaml.hbs +6 -0
- package/dist/templates/base/src/app.module.ts.hbs +68 -1
- package/dist/templates/base/src/app.router.ts.hbs +1 -2
- package/dist/templates/base/src/database/adapters/mongo/prisma-mongo.adapter.ts.hbs +11 -3
- package/dist/templates/base/src/database/adapters/sql/prisma-sql.adapter.ts.hbs +1 -1
- package/dist/templates/base/src/database/db.module.ts.hbs +24 -8
- package/dist/templates/base/src/database/db.service.ts.hbs +20 -0
- package/dist/templates/base/src/database/entities/app.entity.ts.hbs +25 -0
- package/dist/templates/base/src/database/index.ts.hbs +19 -0
- package/dist/templates/base/src/database/orm/drizzle/schema.ts.hbs +42 -11
- package/dist/templates/base/src/database/orm/typeorm/data-source.ts.hbs +12 -1
- package/dist/templates/base/src/libs/middlewares/auth.trpc.middleware.ts.hbs +47 -0
- package/dist/templates/base/src/libs/middlewares/rate-limit.trpc.middleware.ts.hbs +43 -0
- package/dist/templates/base/src/libs/trpc/inner.context.ts.hbs +6 -3
- package/dist/templates/base/src/main.ts.hbs +13 -4
- package/dist/templates/base/src/modules/users/entities/user.entity.ts.hbs +22 -0
- package/dist/templates/base/test/app.e2e-spec.ts.hbs +670 -37
- package/dist/templates/base/test/bootstrap.e2e-spec.ts.hbs +11 -1
- package/dist/templates/extras/docker/.dockerignore.hbs +8 -1
- package/dist/templates/extras/docker/Dockerfile.hbs +8 -10
- package/dist/templates/extras/health/src/health/health.controller.ts.hbs +54 -0
- package/dist/templates/extras/health/src/health/health.module.ts.hbs +9 -0
- package/dist/templates/extras/swagger/create-docs.ts.hbs +9 -3
- package/dist/templates/feature/graphql/src/graphql/graphql.module.ts.hbs +6 -17
- package/dist/templates/feature/graphql/src/graphql/orm/drizzle/drizzle-graphql.module.ts.hbs +3 -2
- package/dist/templates/feature/graphql/src/graphql/resolvers/health.resolver.ts.hbs +14 -0
- package/dist/templates/feature/graphql/src/graphql/resolvers/todo.resolver.ts.hbs +165 -53
- package/dist/templates/feature/rest/todo-example/src/api/todo/dto/create-todo.dto.ts.hbs +12 -0
- package/dist/templates/feature/rest/todo-example/src/api/todo/dto/update-todo.dto.ts.hbs +12 -0
- package/dist/templates/feature/rest/todo-example/src/api/todo/todo.controller.ts.hbs +18 -8
- package/dist/templates/feature/rest/todo-example/src/api/todo/todo.router.ts.hbs +41 -13
- package/dist/templates/feature/rest/todo-example/src/api/todo/todo.service.ts.hbs +68 -10
- package/dist/templates/monorepo/apps/api-gateway/package.json.hbs +2 -2
- package/dist/templates/monorepo/apps/api-gateway/src/app.controller.spec.ts.hbs +5 -0
- package/dist/templates/monorepo/apps/api-gateway/src/app.controller.ts.hbs +6 -0
- package/dist/templates/monorepo/apps/api-gateway/src/app.module.ts.hbs +32 -1
- package/dist/templates/monorepo/apps/api-gateway/src/app.service.ts.hbs +1 -1
- package/dist/templates/monorepo/apps/api-gateway/src/main.ts.hbs +13 -4
- package/dist/templates/monorepo/apps/api-gateway/test/app.e2e-spec.ts.hbs +26 -1
- package/dist/templates/monorepo/feature/graphql/src/graphql/graphql.module.ts.hbs +6 -10
- package/dist/templates/monorepo/feature/graphql/src/graphql/resolvers/health.resolver.ts.hbs +14 -0
- package/dist/templates/monorepo/feature/graphql/src/graphql/resolvers/todo.resolver.ts.hbs +147 -47
- package/dist/templates/monorepo/packages/env/src/api-gateway.ts.hbs +7 -0
- package/dist/templates/monorepo/packages/shared/package.json.hbs +1 -0
- package/dist/templates/monorepo/packages/shared/src/auth.ts.hbs +6 -0
- package/dist/templates/monorepo/root/pnpm-workspace.yaml.hbs +10 -2
- package/dist/templates/single-project/architecture/nest-api/.env.hbs +6 -0
- package/dist/templates/single-project/architecture/nest-api/package.json.hbs +1 -1
- package/dist/templates/single-project/architecture/nest-api/src/main.ts.hbs +13 -4
- package/dist/templates/single-project/architecture/nest-gateway/src/main.ts.hbs +3 -3
- package/dist/templates/single-project/architecture/nest-microservice/proto/app.proto.hbs +13 -0
- package/dist/templates/single-project/architecture/nest-microservice/src/app.controller.ts.hbs +10 -4
- package/dist/templates/single-project/architecture/nest-microservice/src/app.service.ts.hbs +2 -2
- package/dist/templates/single-project/architecture/nest-microservice/src/main.ts.hbs +3 -3
- package/dist/templates/single-project/architecture/nest-microservice/test/microservice.e2e-spec.ts.hbs +7 -1
- package/dist/templates/single-project/shared/config/env.ts.hbs +12 -1
- package/package.json +2 -2
|
@@ -3,29 +3,153 @@ import { INestApplication{{#unless (includes addons "nestjs-zod")}}, StandardSch
|
|
|
3
3
|
import { ZodValidationPipe } from "nestjs-zod";
|
|
4
4
|
{{/if}}
|
|
5
5
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
6
|
+
{{#if (or (eq auth "better-auth:stateless") (and isPassportAuth (not hasDatabase)))}}
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
import { generateKeyPairSync, createSign } from "node:crypto";
|
|
9
|
+
{{/if}}
|
|
10
|
+
{{#if (and isPassportAuth hasRateLimiting hasDatabase)}}
|
|
11
|
+
import { createServer, request as httpRequest } from "node:http";
|
|
12
|
+
{{/if}}
|
|
13
|
+
{{#if hasTrustedProxy}}
|
|
14
|
+
import { env } from "../src/config/env.js";
|
|
15
|
+
{{/if}}
|
|
6
16
|
{{#if (eq httpProvider "fastify")}}
|
|
7
17
|
import { FastifyAdapter } from "@nestjs/platform-fastify";
|
|
8
18
|
{{/if}}
|
|
9
19
|
import request from "supertest";
|
|
10
|
-
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
20
|
+
import { afterAll, beforeAll, describe, expect, it{{#if (and hasHealthCheck hasDatabase (or usePrisma useTypeorm useDrizzle))}}, vi{{/if}} } from "vitest";
|
|
11
21
|
import type { App } from "supertest/types";
|
|
12
|
-
|
|
13
|
-
{
|
|
14
|
-
|
|
15
|
-
import {
|
|
16
|
-
|
|
22
|
+
{{#if swagger}}
|
|
23
|
+
import { createDocs } from "../src/docs/create-docs.js";
|
|
24
|
+
{{#if isBetterAuth}}
|
|
25
|
+
import { AuthService } from "@thallesp/nestjs-better-auth";
|
|
26
|
+
{{/if}}
|
|
17
27
|
{{/if}}
|
|
28
|
+
{{#if (and swagger useSwaggerScalar)}}
|
|
29
|
+
import { apiReference } from "@scalar/nestjs-api-reference";
|
|
30
|
+
{{/if}}
|
|
31
|
+
|
|
32
|
+
{{#unless (or (eq auth "better-auth:stateless") (and isPassportAuth (not hasDatabase)))}}
|
|
18
33
|
import { AppModule } from "./../src/app.module.js";
|
|
34
|
+
{{/unless}}
|
|
19
35
|
{{#if (and useDrizzle (eq auth "none"))}}
|
|
20
36
|
import { DbService } from "../src/database/db.service.js";
|
|
21
37
|
import { users } from "../src/database/orm/drizzle/schema.js";
|
|
22
38
|
import { eq } from "drizzle-orm";
|
|
39
|
+
{{else if (and hasHealthCheck hasDatabase (or usePrisma useTypeorm useDrizzle))}}
|
|
40
|
+
import { DbService } from "../src/database/db.service.js";
|
|
23
41
|
{{/if}}
|
|
24
42
|
|
|
25
43
|
describe("AppController (e2e)", () => {
|
|
26
44
|
let app: INestApplication<App>;
|
|
45
|
+
{{#if swagger}}
|
|
46
|
+
let generatedOpenApi: Awaited<ReturnType<typeof createDocs>>;
|
|
47
|
+
{{/if}}
|
|
48
|
+
{{#if (eq auth "better-auth:stateless")}}
|
|
49
|
+
const oidcOrigin = "http://127.0.0.1:4010";
|
|
50
|
+
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
|
51
|
+
const publicJwk = publicKey.export({ format: "jwk" });
|
|
52
|
+
let authorizationNonce = "";
|
|
53
|
+
let oidcServer: ReturnType<typeof createServer>;
|
|
54
|
+
|
|
55
|
+
function signIdToken(nonce: string): string {
|
|
56
|
+
const encodedHeader = Buffer.from(JSON.stringify({ alg: "RS256", kid: "scenario-key", typ: "JWT" })).toString("base64url");
|
|
57
|
+
const encodedPayload = Buffer.from(JSON.stringify({ aud: "generated-scenario-client", email: "oidc-scenario@example.com", exp: Math.floor(Date.now() / 1000) + 300, iat: Math.floor(Date.now() / 1000), iss: oidcOrigin, name: "OIDC Scenario", nonce, sub: "oidc-scenario-user" })).toString("base64url");
|
|
58
|
+
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
|
59
|
+
const signer = createSign("RSA-SHA256");
|
|
60
|
+
signer.update(signingInput);
|
|
61
|
+
signer.end();
|
|
62
|
+
return `${signingInput}.${signer.sign(privateKey).toString("base64url")}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function startOidcFixture(): Promise<void> {
|
|
66
|
+
oidcServer = createServer((request, response) => {
|
|
67
|
+
const url = new URL(request.url ?? "/", oidcOrigin);
|
|
68
|
+
if (url.pathname === "/.well-known/openid-configuration") {
|
|
69
|
+
response.setHeader("content-type", "application/json");
|
|
70
|
+
response.end(JSON.stringify({ authorization_endpoint: `${oidcOrigin}/authorize`, issuer: oidcOrigin, jwks_uri: `${oidcOrigin}/jwks`, token_endpoint: `${oidcOrigin}/token`, userinfo_endpoint: `${oidcOrigin}/userinfo` }));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (url.pathname === "/jwks") {
|
|
74
|
+
response.setHeader("content-type", "application/json");
|
|
75
|
+
response.end(JSON.stringify({ keys: [{ ...publicJwk, alg: "RS256", kid: "scenario-key", use: "sig" }] }));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (url.pathname === "/authorize") {
|
|
79
|
+
authorizationNonce = url.searchParams.get("nonce") ?? "";
|
|
80
|
+
const redirect = new URL(url.searchParams.get("redirect_uri") ?? "");
|
|
81
|
+
redirect.searchParams.set("code", "scenario-code");
|
|
82
|
+
redirect.searchParams.set("state", url.searchParams.get("state") ?? "");
|
|
83
|
+
response.writeHead(302, { location: redirect.toString() });
|
|
84
|
+
response.end();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (url.pathname === "/token") {
|
|
88
|
+
let body = "";
|
|
89
|
+
request.on("data", (chunk: Buffer) => { body += chunk.toString(); });
|
|
90
|
+
request.on("end", () => {
|
|
91
|
+
response.setHeader("content-type", "application/json");
|
|
92
|
+
response.end(JSON.stringify({ access_token: "scenario-access-token", id_token: signIdToken(authorizationNonce), token_type: "Bearer" }));
|
|
93
|
+
});
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (url.pathname === "/userinfo") {
|
|
97
|
+
response.setHeader("content-type", "application/json");
|
|
98
|
+
response.end(JSON.stringify({ email: "oidc-scenario@example.com", name: "OIDC Scenario", sub: "oidc-scenario-user" }));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
response.writeHead(404);
|
|
102
|
+
response.end();
|
|
103
|
+
});
|
|
104
|
+
await new Promise<void>((resolve) => oidcServer.listen(4010, "127.0.0.1", resolve));
|
|
105
|
+
}
|
|
106
|
+
{{/if}}
|
|
107
|
+
{{#if (and isPassportAuth (not hasDatabase))}}
|
|
108
|
+
const identityIssuer = "http://127.0.0.1:4011/";
|
|
109
|
+
const identityAudience = "generated-resource-server";
|
|
110
|
+
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
|
111
|
+
const publicJwk = publicKey.export({ format: "jwk" });
|
|
112
|
+
let identityServer: ReturnType<typeof createServer>;
|
|
113
|
+
|
|
114
|
+
function signAccessToken(claims: Record<string, unknown> = {}): string {
|
|
115
|
+
const issuedAt = Math.floor(Date.now() / 1000);
|
|
116
|
+
const encodedHeader = Buffer.from(JSON.stringify({ alg: "RS256", kid: "scenario-key", typ: "at+jwt" })).toString("base64url");
|
|
117
|
+
const encodedPayload = Buffer.from(JSON.stringify({ aud: identityAudience, email: "external-user@example.com", exp: issuedAt + 300, iat: issuedAt, iss: identityIssuer, sub: "external-user-1", ...claims })).toString("base64url");
|
|
118
|
+
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
|
119
|
+
const signer = createSign("RSA-SHA256");
|
|
120
|
+
signer.update(signingInput);
|
|
121
|
+
signer.end();
|
|
122
|
+
return `${signingInput}.${signer.sign(privateKey).toString("base64url")}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function startIdentityProvider(): Promise<void> {
|
|
126
|
+
identityServer = createServer((request, response) => {
|
|
127
|
+
if (request.url !== "/.well-known/jwks.json") {
|
|
128
|
+
response.writeHead(404);
|
|
129
|
+
response.end();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
response.setHeader("content-type", "application/json");
|
|
133
|
+
response.end(JSON.stringify({ keys: [{ ...publicJwk, alg: "RS256", kid: "scenario-key", use: "sig" }] }));
|
|
134
|
+
});
|
|
135
|
+
await new Promise<void>((resolve) => identityServer.listen(4011, "127.0.0.1", resolve));
|
|
136
|
+
}
|
|
137
|
+
{{/if}}
|
|
27
138
|
|
|
28
139
|
async function startApplication() {
|
|
140
|
+
{{#if (eq auth "better-auth:stateless")}}
|
|
141
|
+
process.env.OIDC_CLIENT_ID = "generated-scenario-client";
|
|
142
|
+
process.env.OIDC_CLIENT_SECRET = "generated-scenario-secret";
|
|
143
|
+
process.env.OIDC_DISCOVERY_URL = `${oidcOrigin}/.well-known/openid-configuration`;
|
|
144
|
+
process.env.OIDC_PROVIDER_ID = "scenario-oidc";
|
|
145
|
+
const { AppModule } = await import("./../src/app.module.js");
|
|
146
|
+
{{/if}}
|
|
147
|
+
{{#if (and isPassportAuth (not hasDatabase))}}
|
|
148
|
+
process.env.OIDC_AUDIENCE = identityAudience;
|
|
149
|
+
process.env.OIDC_ISSUER = identityIssuer;
|
|
150
|
+
process.env.OIDC_JWKS_URI = `${identityIssuer}.well-known/jwks.json`;
|
|
151
|
+
const { AppModule } = await import("./../src/app.module.js");
|
|
152
|
+
{{/if}}
|
|
29
153
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
|
30
154
|
imports: [AppModule],
|
|
31
155
|
}).compile();
|
|
@@ -35,17 +159,104 @@ describe("AppController (e2e)", () => {
|
|
|
35
159
|
{{else}}
|
|
36
160
|
app = moduleFixture.createNestApplication({{#if isBetterAuth}}{ bodyParser: false }{{/if}});
|
|
37
161
|
{{/if}}
|
|
162
|
+
{{#if hasTrustedProxy}}
|
|
163
|
+
{{#unless (eq httpProvider "fastify")}}
|
|
164
|
+
const trustedProxyIps = env.TRUSTED_PROXY_IPS.split(",").map((ip) => ip.trim()).filter(Boolean);
|
|
165
|
+
app.getHttpAdapter().getInstance().set("trust proxy", trustedProxyIps);
|
|
166
|
+
{{/unless}}
|
|
167
|
+
{{/if}}
|
|
38
168
|
{{#if (includes addons "nestjs-zod")}}
|
|
39
169
|
app.useGlobalPipes(new ZodValidationPipe());
|
|
40
170
|
{{else}}
|
|
41
171
|
app.useGlobalPipes(new StandardSchemaValidationPipe());
|
|
172
|
+
{{/if}}
|
|
173
|
+
{{#if swagger}}
|
|
174
|
+
generatedOpenApi = await createDocs(app, {
|
|
175
|
+
title: "Generated API",
|
|
176
|
+
integrations: {
|
|
177
|
+
{{#if isBetterAuth}}
|
|
178
|
+
betterAuth: { auth: app.get(AuthService).instance, pathPrefix: "/api/auth" },
|
|
179
|
+
{{/if}}
|
|
180
|
+
{{#if isPassportAuth}}
|
|
181
|
+
bearerAuth: true,
|
|
182
|
+
{{/if}}
|
|
183
|
+
zod: { withFastify: false },
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
{{#if useSwaggerScalar}}
|
|
187
|
+
app.use("/docs", apiReference({ content: generatedOpenApi, theme: "default" }));
|
|
188
|
+
{{/if}}
|
|
42
189
|
{{/if}}
|
|
43
190
|
await app.init();
|
|
44
191
|
{{#if (eq httpProvider "fastify")}}
|
|
45
192
|
await app.getHttpAdapter().getInstance().ready();
|
|
46
193
|
{{/if}}
|
|
47
194
|
}
|
|
195
|
+
{{#if (eq auth "better-auth:stateless")}}
|
|
196
|
+
beforeAll(async () => {
|
|
197
|
+
await startOidcFixture();
|
|
198
|
+
await startApplication();
|
|
199
|
+
});
|
|
200
|
+
{{else if (and isPassportAuth (not hasDatabase))}}
|
|
201
|
+
beforeAll(async () => {
|
|
202
|
+
await startIdentityProvider();
|
|
203
|
+
await startApplication();
|
|
204
|
+
});
|
|
205
|
+
{{else}}
|
|
48
206
|
beforeAll(startApplication);
|
|
207
|
+
{{/if}}
|
|
208
|
+
|
|
209
|
+
{{#if (and isPassportAuth (not hasDatabase))}}
|
|
210
|
+
it("accepts only RS256 access tokens from the configured external issuer", async () => {
|
|
211
|
+
const server = app.getHttpServer();
|
|
212
|
+
await request(server).get("/auth/profile").expect(401);
|
|
213
|
+
await request(server).post("/auth/login").send({ id: "attacker" }).expect(404);
|
|
214
|
+
const authenticated = await request(server)
|
|
215
|
+
.get("/auth/profile")
|
|
216
|
+
.set("Authorization", `Bearer ${signAccessToken()}`)
|
|
217
|
+
.expect(200);
|
|
218
|
+
expect(authenticated.body).toMatchObject({
|
|
219
|
+
email: "external-user@example.com",
|
|
220
|
+
id: "external-user-1",
|
|
221
|
+
issuer: identityIssuer,
|
|
222
|
+
userId: "external-user-1",
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
for (const claims of [
|
|
226
|
+
{ iss: "http://attacker.example/" },
|
|
227
|
+
{ aud: "another-resource-server" },
|
|
228
|
+
{ exp: Math.floor(Date.now() / 1000) - 30 },
|
|
229
|
+
]) {
|
|
230
|
+
await request(server)
|
|
231
|
+
.get("/auth/profile")
|
|
232
|
+
.set("Authorization", `Bearer ${signAccessToken(claims)}`)
|
|
233
|
+
.expect(401);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
{{/if}}
|
|
237
|
+
|
|
238
|
+
{{#if swagger}}
|
|
239
|
+
it("generates authenticated OpenAPI docs and serves the Scalar reference", async () => {
|
|
240
|
+
expect(generatedOpenApi.openapi).toMatch(/^3\./u);
|
|
241
|
+
{{#if isBetterAuth}}
|
|
242
|
+
expect(generatedOpenApi.paths?.["/api/auth/sign-up/email"]).toBeDefined();
|
|
243
|
+
{{else if isPassportAuth}}
|
|
244
|
+
{{#if (and usePrisma hasRest (not hasMongodb))}}
|
|
245
|
+
expect(generatedOpenApi.paths?.["/auth/register"]).toBeDefined();
|
|
246
|
+
{{else}}
|
|
247
|
+
expect(generatedOpenApi.paths?.["/auth/signup"]).toBeDefined();
|
|
248
|
+
{{/if}}
|
|
249
|
+
expect(generatedOpenApi.components?.securitySchemes?.bearer).toBeDefined();
|
|
250
|
+
{{/if}}
|
|
251
|
+
{{#if hasTodoExample}}
|
|
252
|
+
expect(generatedOpenApi.paths?.["/todos"]?.post?.requestBody).toBeDefined();
|
|
253
|
+
{{/if}}
|
|
254
|
+
{{#if useSwaggerScalar}}
|
|
255
|
+
const reference = await request(app.getHttpServer()).get("/docs").expect(200);
|
|
256
|
+
expect(reference.text).toMatch(/scalar/u);
|
|
257
|
+
{{/if}}
|
|
258
|
+
});
|
|
259
|
+
{{/if}}
|
|
49
260
|
|
|
50
261
|
{{#if hasRest}}
|
|
51
262
|
it("/ (GET)", async () => {
|
|
@@ -54,6 +265,73 @@ describe("AppController (e2e)", () => {
|
|
|
54
265
|
});
|
|
55
266
|
{{/if}}
|
|
56
267
|
|
|
268
|
+
{{#if (and isPassportAuth hasTodoExample hasDatabase hasRest)}}
|
|
269
|
+
it("enforces REST todo ownership across two Passport users", async () => {
|
|
270
|
+
const server = app.getHttpServer();
|
|
271
|
+
const password = "Scenario-password-123!";
|
|
272
|
+
async function register(prefix: string) {
|
|
273
|
+
const email = `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}@example.com`;
|
|
274
|
+
await request(server).post({{#if (and usePrisma hasRest (not hasMongodb))}}"/auth/register"{{else}}"/auth/signup"{{/if}})
|
|
275
|
+
.send({ email, password }).expect(201);
|
|
276
|
+
const response = await request(server).post("/auth/login")
|
|
277
|
+
.send({ email, password }).expect(200);
|
|
278
|
+
expect(response.body.accessToken).toBeTruthy();
|
|
279
|
+
return response.body.accessToken as string;
|
|
280
|
+
}
|
|
281
|
+
const alice = await register("passport-alice");
|
|
282
|
+
const bob = await register("passport-bob");
|
|
283
|
+
const created = await request(server).post("/todos")
|
|
284
|
+
.set("Authorization", `Bearer ${alice}`)
|
|
285
|
+
.send({ title: "Private Passport todo", completed: false }).expect(201);
|
|
286
|
+
const todo = Array.isArray(created.body) ? created.body[0] : created.body;
|
|
287
|
+
const { id } = todo;
|
|
288
|
+
const bobList = await request(server).get("/todos")
|
|
289
|
+
.set("Authorization", `Bearer ${bob}`).expect(200);
|
|
290
|
+
expect(bobList.body).not.toEqual(
|
|
291
|
+
expect.arrayContaining([expect.objectContaining({ id })])
|
|
292
|
+
);
|
|
293
|
+
await request(server).put(`/todos/${id}`)
|
|
294
|
+
.set("Authorization", `Bearer ${bob}`)
|
|
295
|
+
.send({ completed: true }).expect(404);
|
|
296
|
+
await request(server).delete(`/todos/${id}`)
|
|
297
|
+
.set("Authorization", `Bearer ${bob}`).expect(404);
|
|
298
|
+
const aliceList = await request(server).get("/todos")
|
|
299
|
+
.set("Authorization", `Bearer ${alice}`).expect(200);
|
|
300
|
+
expect(aliceList.body).toEqual(
|
|
301
|
+
expect.arrayContaining([
|
|
302
|
+
expect.objectContaining({ id, completed: false }),
|
|
303
|
+
])
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
{{/if}}
|
|
307
|
+
|
|
308
|
+
{{#if hasHealthCheck}}
|
|
309
|
+
it("exposes unauthenticated health and readiness", async () => {
|
|
310
|
+
const response = await request(app.getHttpServer()).get("/health").expect(200);
|
|
311
|
+
expect(response.body.status).toBe("ok");
|
|
312
|
+
expect(response.body.info.app.status).toBe("up");
|
|
313
|
+
{{#if hasDatabase}}
|
|
314
|
+
expect(response.body.info.db.status).toBe("up");
|
|
315
|
+
{{/if}}
|
|
316
|
+
});
|
|
317
|
+
{{/if}}
|
|
318
|
+
|
|
319
|
+
{{#if (and hasHealthCheck hasDatabase (or usePrisma useTypeorm useDrizzle))}}
|
|
320
|
+
it("reports database readiness failure with HTTP 503", async () => {
|
|
321
|
+
const db = app.get(DbService);
|
|
322
|
+
const database = {{#if usePrisma}}db.prisma{{else if useTypeorm}}db.typeorm{{else}}db.drizzle{{/if}};
|
|
323
|
+
const query = vi.spyOn(database, {{#if usePrisma}}"$queryRaw"{{else if useTypeorm}}"query"{{else if hasSqlite}}"get"{{else}}"execute"{{/if}})
|
|
324
|
+
.mockRejectedValue(new Error("scenario database outage"));
|
|
325
|
+
try {
|
|
326
|
+
const response = await request(app.getHttpServer()).get("/health").expect(503);
|
|
327
|
+
expect(response.body.status).toBe("error");
|
|
328
|
+
expect(response.body.error.db.status).toBe("down");
|
|
329
|
+
} finally {
|
|
330
|
+
query.mockRestore();
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
{{/if}}
|
|
334
|
+
|
|
57
335
|
{{#if hasTrpc}}
|
|
58
336
|
it("executes a tRPC query through HTTP", async () => {
|
|
59
337
|
const response = await request(app.getHttpServer())
|
|
@@ -62,10 +340,65 @@ describe("AppController (e2e)", () => {
|
|
|
62
340
|
});
|
|
63
341
|
{{/if}}
|
|
64
342
|
|
|
343
|
+
{{#if (and hasTrpc hasTodoExample hasAuth)}}
|
|
344
|
+
it("enforces todo ownership through authenticated tRPC procedures", async () => {
|
|
345
|
+
const server = app.getHttpServer();
|
|
346
|
+
const password = "Scenario-password-123!";
|
|
347
|
+
const origin = "http://localhost:3000";
|
|
348
|
+
const call = (procedure: string, input?: unknown, credential?: { cookie?: string[]; token?: string }) => {
|
|
349
|
+
const operation = procedure === "findAll"
|
|
350
|
+
? request(server).get("/trpc/todoRouter.findAll")
|
|
351
|
+
: request(server).post(`/trpc/todoRouter.${procedure}`);
|
|
352
|
+
if (credential?.cookie) operation.set("Cookie", credential.cookie);
|
|
353
|
+
if (credential?.token) operation.set("Authorization", `Bearer ${credential.token}`);
|
|
354
|
+
if (input !== undefined) operation.send(input);
|
|
355
|
+
return operation;
|
|
356
|
+
};
|
|
357
|
+
const unwrap = (body: { result?: { data?: { json?: unknown } } }) =>
|
|
358
|
+
body.result?.data?.json ?? body.result?.data;
|
|
359
|
+
{{#if isBetterAuth}}
|
|
360
|
+
async function register(prefix: string) {
|
|
361
|
+
const email = `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}@example.com`;
|
|
362
|
+
const response = await request(server).post("/api/auth/sign-up/email")
|
|
363
|
+
.set("Origin", origin).send({ email, password, name: prefix }).expect(200);
|
|
364
|
+
return { cookie: response.headers["set-cookie"] as string[] };
|
|
365
|
+
}
|
|
366
|
+
{{else if isPassportAuth}}
|
|
367
|
+
async function register(prefix: string) {
|
|
368
|
+
const email = `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}@example.com`;
|
|
369
|
+
await request(server)
|
|
370
|
+
.post({{#if (and usePrisma hasRest (not hasMongodb))}}"/auth/register"{{else}}"/auth/signup"{{/if}})
|
|
371
|
+
{{#if usePrisma}}.send({ email, password }){{else}}.send({ email, password, name: prefix }){{/if}}
|
|
372
|
+
.expect(201);
|
|
373
|
+
const login = await request(server).post("/auth/login").send({ email, password }).expect(200);
|
|
374
|
+
return { token: login.body.accessToken as string };
|
|
375
|
+
}
|
|
376
|
+
{{/if}}
|
|
377
|
+
const anonymous = await call("findAll");
|
|
378
|
+
expect(anonymous.status).toBe(401);
|
|
379
|
+
const alice = await register("trpc-alice");
|
|
380
|
+
const bob = await register("trpc-bob");
|
|
381
|
+
const created = await call("create", { title: "Private tRPC todo", completed: false }, alice).expect(200);
|
|
382
|
+
const createdTodo = unwrap(created.body) as { id: string; title: string };
|
|
383
|
+
expect(createdTodo.title).toBe("Private tRPC todo");
|
|
384
|
+
const id = createdTodo.id;
|
|
385
|
+
const aliceTodos = unwrap((await call("findAll", undefined, alice).expect(200)).body) as { id: string }[];
|
|
386
|
+
expect(aliceTodos).toEqual(expect.arrayContaining([expect.objectContaining({ id })]));
|
|
387
|
+
const bobTodos = unwrap((await call("findAll", undefined, bob).expect(200)).body) as { id: string }[];
|
|
388
|
+
expect(bobTodos).not.toEqual(expect.arrayContaining([expect.objectContaining({ id })]));
|
|
389
|
+
await call("update", { id, data: { completed: true } }, bob).expect(404);
|
|
390
|
+
await call("delete", { id }, bob).expect(404);
|
|
391
|
+
const updated = await call("update", { id, data: { completed: true } }, alice).expect(200);
|
|
392
|
+
expect((unwrap(updated.body) as { completed: boolean }).completed).toBe(true);
|
|
393
|
+
await call("delete", { id }, alice).expect(200);
|
|
394
|
+
});
|
|
395
|
+
{{/if}}
|
|
396
|
+
|
|
65
397
|
{{#if (eq auth "better-auth")}}
|
|
66
398
|
it("registers, authenticates and revokes a persisted session", async () => {
|
|
67
399
|
const email = `scenario-${Date.now()}@example.com`;
|
|
68
400
|
const password = "Scenario-password-123!";
|
|
401
|
+
expect(password.length).toBeGreaterThanOrEqual(8);
|
|
69
402
|
const origin = "http://localhost:3000";
|
|
70
403
|
let server = app.getHttpServer();
|
|
71
404
|
const signedUp = await request(server).post("/api/auth/sign-up/email")
|
|
@@ -103,7 +436,60 @@ describe("AppController (e2e)", () => {
|
|
|
103
436
|
.set("Cookie", cookies).send({}).expect(200);
|
|
104
437
|
const revoked = await request(server).get("/api/auth/get-session")
|
|
105
438
|
.set("Cookie", cookies).expect(200);
|
|
106
|
-
|
|
439
|
+
expect(revoked.body).toBeNull();
|
|
440
|
+
});
|
|
441
|
+
{{/if}}
|
|
442
|
+
|
|
443
|
+
{{#if (and isBetterAuth hasRateLimiting)}}
|
|
444
|
+
it("keeps normal sign-in usable and throttles repeated authentication requests", async () => {
|
|
445
|
+
const server = app.getHttpServer();
|
|
446
|
+
const origin = "http://localhost:3000";
|
|
447
|
+
const email = `ratelimit-${Date.now()}@example.com`;
|
|
448
|
+
const password = "Scenario-password-123!";
|
|
449
|
+
await request(server).post("/api/auth/sign-up/email")
|
|
450
|
+
.set("Origin", origin).send({ email, password, name: "Rate limit" }).expect(200);
|
|
451
|
+
await request(server).post("/api/auth/sign-in/email")
|
|
452
|
+
.set("Origin", origin).send({ email, password }).expect(200);
|
|
453
|
+
const responses = await Promise.all(
|
|
454
|
+
Array.from({ length: 80 }, () =>
|
|
455
|
+
request(server)
|
|
456
|
+
.post("/api/auth/sign-in/email")
|
|
457
|
+
.set("Origin", origin)
|
|
458
|
+
.send({ email: "rate-limited@example.com", password: "wrong-password" })
|
|
459
|
+
)
|
|
460
|
+
);
|
|
461
|
+
expect(responses.some((response) => response.status === 429)).toBe(true);
|
|
462
|
+
await app.close();
|
|
463
|
+
await startApplication();
|
|
464
|
+
}, 30_000);
|
|
465
|
+
{{/if}}
|
|
466
|
+
|
|
467
|
+
{{#if (eq auth "better-auth:stateless")}}
|
|
468
|
+
it("completes an OIDC authorization-code flow without an application database", async () => {
|
|
469
|
+
const server = app.getHttpServer();
|
|
470
|
+
const signIn = await request(server).post("/api/auth/sign-in/social")
|
|
471
|
+
.set("Origin", "http://localhost:3000")
|
|
472
|
+
.send({ callbackURL: "/", provider: "scenario-oidc" }).expect(200);
|
|
473
|
+
const authorization = new URL(signIn.body.url);
|
|
474
|
+
expect(authorization.origin).toBe(oidcOrigin);
|
|
475
|
+
const stateCookies = signIn.headers["set-cookie"];
|
|
476
|
+
expect(stateCookies).toBeTruthy();
|
|
477
|
+
const providerResponse = await request(oidcServer).get(`${authorization.pathname}${authorization.search}`).expect(302);
|
|
478
|
+
const callback = new URL(providerResponse.headers.location);
|
|
479
|
+
const session = await request(server).get(`${callback.pathname}${callback.search}`)
|
|
480
|
+
.set("Cookie", stateCookies).set("Origin", "http://localhost:3000").expect(302);
|
|
481
|
+
const cookies = session.headers["set-cookie"];
|
|
482
|
+
expect(cookies).toBeTruthy();
|
|
483
|
+
const currentSession = await request(server).get("/api/auth/get-session").set("Cookie", cookies).expect(200);
|
|
484
|
+
expect(currentSession.body.user.email).toBe("oidc-scenario@example.com");
|
|
485
|
+
{{#if hasTrpc}}
|
|
486
|
+
await request(server).get("/trpc/appRouter.testAuth").expect(401);
|
|
487
|
+
const authenticated = await request(server).get("/trpc/appRouter.testAuth")
|
|
488
|
+
.set("Cookie", cookies).expect(200);
|
|
489
|
+
expect(authenticated.body.result.data.user.email).toBe("oidc-scenario@example.com");
|
|
490
|
+
{{/if}}
|
|
491
|
+
await request(server).get("/api/auth/callback/scenario-oidc?code=scenario-code&state=tampered")
|
|
492
|
+
.set("Origin", "http://localhost:3000").expect(302);
|
|
107
493
|
});
|
|
108
494
|
{{/if}}
|
|
109
495
|
|
|
@@ -112,8 +498,24 @@ describe("AppController (e2e)", () => {
|
|
|
112
498
|
const server = app.getHttpServer();
|
|
113
499
|
const execute = (query: string, variables: Record<string, unknown> = {}) => request(server).post("/graphql").send({ query, variables });
|
|
114
500
|
const hello = await execute("{ hello }");
|
|
501
|
+
expect(hello.body.errors).toBeUndefined();
|
|
115
502
|
expect(hello.body.data.hello).toBe("Hello GraphQL!");
|
|
116
|
-
{{#if hasTodoExample}}
|
|
503
|
+
{{#if (and hasTodoExample (eq auth "none"))}}
|
|
504
|
+
const created = await execute('mutation($input: CreateTodoInput!) { createTodo(input: $input) { id title completed } }', { input: { title: "GraphQL integration" } });
|
|
505
|
+
expect(created.body.errors).toBeUndefined();
|
|
506
|
+
const { id } = created.body.data.createTodo;
|
|
507
|
+
const listed = await execute("{ todos { id title } }");
|
|
508
|
+
expect(listed.body.data.todos).toContainEqual({ id, title: "GraphQL integration" });
|
|
509
|
+
const updated = await execute('mutation($input: UpdateTodoInput!) { updateTodo(input: $input) { completed } }', { input: { id, completed: true } });
|
|
510
|
+
expect(updated.body.data.updateTodo.completed).toBe(true);
|
|
511
|
+
const invalid = await execute('mutation { createTodo(input: { title: " " }) { id } }');
|
|
512
|
+
expect(invalid.body.errors).toBeTruthy();
|
|
513
|
+
const deleted = await execute('mutation($id: String!) { deleteTodo(id: $id) }', { id });
|
|
514
|
+
expect(deleted.body.data.deleteTodo).toBe(true);
|
|
515
|
+
const missing = await execute('mutation($id: String!) { deleteTodo(id: $id) }', { id });
|
|
516
|
+
expect(missing.body.errors).toBeTruthy();
|
|
517
|
+
{{/if}}
|
|
518
|
+
{{#if (and hasTodoExample (eq auth "none"))}}
|
|
117
519
|
const created = await execute('mutation($input: CreateTodoInput!) { createTodo(input: $input) { id title completed } }', { input: { title: "GraphQL integration" } });
|
|
118
520
|
expect(created.body.errors).toBeUndefined();
|
|
119
521
|
const { id } = created.body.data.createTodo;
|
|
@@ -131,47 +533,239 @@ describe("AppController (e2e)", () => {
|
|
|
131
533
|
});
|
|
132
534
|
{{/if}}
|
|
133
535
|
|
|
134
|
-
{{#if
|
|
135
|
-
it("
|
|
536
|
+
{{#if (and hasGraphql hasTodoExample hasAuth)}}
|
|
537
|
+
it("rejects anonymous GraphQL todo access and isolates authenticated owners", async () => {
|
|
538
|
+
const server = app.getHttpServer();
|
|
539
|
+
const password = "Scenario-password-123!";
|
|
540
|
+
const origin = "http://localhost:3000";
|
|
541
|
+
const execute = (
|
|
542
|
+
query: string,
|
|
543
|
+
variables: Record<string, unknown> = {},
|
|
544
|
+
credentials: { cookie?: string[]; token?: string } = {}
|
|
545
|
+
) => {
|
|
546
|
+
const operation = request(server).post("/graphql").send({ query, variables });
|
|
547
|
+
if (credentials.cookie) operation.set("Cookie", credentials.cookie);
|
|
548
|
+
if (credentials.token) operation.set("Authorization", `Bearer ${credentials.token}`);
|
|
549
|
+
return operation;
|
|
550
|
+
};
|
|
551
|
+
{{#if isBetterAuth}}
|
|
552
|
+
async function register(prefix: string) {
|
|
553
|
+
const email = `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}@example.com`;
|
|
554
|
+
const response = await request(server).post("/api/auth/sign-up/email")
|
|
555
|
+
.set("Origin", origin).send({ email, password, name: prefix }).expect(200);
|
|
556
|
+
return { cookie: response.headers["set-cookie"] };
|
|
557
|
+
}
|
|
558
|
+
{{else if isPassportAuth}}
|
|
559
|
+
async function register(prefix: string) {
|
|
560
|
+
const email = `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}@example.com`;
|
|
561
|
+
const response = await request(server).post({{#if (and usePrisma hasRest (not hasMongodb))}}"/auth/register"{{else}}"/auth/signup"{{/if}})
|
|
562
|
+
.send({ email, password }).expect(201);
|
|
563
|
+
return { token: response.body.accessToken as string };
|
|
564
|
+
}
|
|
565
|
+
{{/if}}
|
|
566
|
+
const anonymous = await execute("{ todos { id title } }");
|
|
567
|
+
expect(anonymous.body.errors).toBeTruthy();
|
|
568
|
+
const firstUser = await register("graphql-owner-one");
|
|
569
|
+
const secondUser = await register("graphql-owner-two");
|
|
570
|
+
const created = await execute(
|
|
571
|
+
'mutation($input: CreateTodoInput!) { createTodo(input: $input) { id title } }',
|
|
572
|
+
{ input: { title: "Private GraphQL todo" } },
|
|
573
|
+
firstUser
|
|
574
|
+
);
|
|
575
|
+
expect(created.body.errors).toBeUndefined();
|
|
576
|
+
const { id } = created.body.data.createTodo;
|
|
577
|
+
const firstList = await execute("{ todos { id title } }", {}, firstUser);
|
|
578
|
+
expect(firstList.body.data.todos).toContainEqual({ id, title: "Private GraphQL todo" });
|
|
579
|
+
const secondList = await execute("{ todos { id title } }", {}, secondUser);
|
|
580
|
+
expect(secondList.body.data.todos).toEqual([]);
|
|
581
|
+
const unauthorizedUpdate = await execute(
|
|
582
|
+
'mutation($input: UpdateTodoInput!) { updateTodo(input: $input) { id } }',
|
|
583
|
+
{ input: { id, completed: true } },
|
|
584
|
+
secondUser
|
|
585
|
+
);
|
|
586
|
+
expect(unauthorizedUpdate.body.errors).toBeTruthy();
|
|
587
|
+
const unauthorizedDelete = await execute(
|
|
588
|
+
'mutation($id: String!) { deleteTodo(id: $id) }',
|
|
589
|
+
{ id },
|
|
590
|
+
secondUser
|
|
591
|
+
);
|
|
592
|
+
expect(unauthorizedDelete.body.errors).toBeTruthy();
|
|
593
|
+
const ownerTodo = await execute("{ todos { id title } }", {}, firstUser);
|
|
594
|
+
expect(ownerTodo.body.data.todos).toContainEqual({ id, title: "Private GraphQL todo" });
|
|
595
|
+
});
|
|
596
|
+
{{/if}}
|
|
597
|
+
|
|
598
|
+
{{#if (and hasGraphql hasRateLimiting)}}
|
|
599
|
+
it("throttles repeated GraphQL requests to 429", async () => {
|
|
600
|
+
let throttled = false;
|
|
601
|
+
let lastResponse = "";
|
|
602
|
+
app.getHttpServer().setMaxListeners(100);
|
|
603
|
+
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
604
|
+
const response = await request(app.getHttpServer()).post("/graphql").send({ query: "{ hello }" });
|
|
605
|
+
lastResponse = JSON.stringify({ errors: response.body.errors, status: response.status });
|
|
606
|
+
throttled ||= response.status === 429 || response.body.errors?.some((error: { message?: string; extensions?: { originalError?: { statusCode?: number } } }) =>
|
|
607
|
+
error.extensions?.originalError?.statusCode === 429 ||
|
|
608
|
+
error.message?.includes("ThrottlerException: Too Many Requests") === true
|
|
609
|
+
) === true;
|
|
610
|
+
}
|
|
611
|
+
expect(throttled, lastResponse).toBe(true);
|
|
612
|
+
}, 30_000);
|
|
613
|
+
{{/if}}
|
|
614
|
+
|
|
615
|
+
{{#if (and isPassportAuth hasRateLimiting hasDatabase)}}
|
|
616
|
+
it("keeps Passport sign-in usable and throttles repeated login attempts", async () => {
|
|
617
|
+
const server = app.getHttpServer();
|
|
618
|
+
const email = `passport-ratelimit-${Date.now()}@example.com`;
|
|
619
|
+
const password = "Scenario-password-123!";
|
|
620
|
+
await request(server).post({{#if (and usePrisma hasRest (not hasMongodb))}}"/auth/register"{{else}}"/auth/signup"{{/if}}).send({ email, password }).expect(201);
|
|
621
|
+
await request(server).post("/auth/login").send({ email, password }).expect(200);
|
|
622
|
+
const responses = await Promise.all(
|
|
623
|
+
Array.from({ length: 80 }, () =>
|
|
624
|
+
request(server).post("/auth/login").send({ email, password: "Invalid-password-123!" })
|
|
625
|
+
)
|
|
626
|
+
);
|
|
627
|
+
expect(responses.some((response) => response.status === 429)).toBe(true);
|
|
628
|
+
await app.close();
|
|
629
|
+
await startApplication();
|
|
630
|
+
}, 30_000);
|
|
631
|
+
{{/if}}
|
|
632
|
+
|
|
633
|
+
{{#if (and hasTrpc hasRateLimiting)}}
|
|
634
|
+
it("throttles repeated tRPC procedure requests", async () => {
|
|
635
|
+
const server = app.getHttpServer();
|
|
636
|
+
server.setMaxListeners(100);
|
|
637
|
+
const procedure = () => request(server).get("/trpc/appRouter.testAuth");
|
|
638
|
+
expect((await procedure()).status).toBe(401);
|
|
639
|
+
const responses = await Promise.all(
|
|
640
|
+
Array.from({ length: 80 }, () => procedure())
|
|
641
|
+
);
|
|
642
|
+
expect(responses.some((response) => response.status === 429)).toBe(true);
|
|
643
|
+
}, 30_000);
|
|
644
|
+
{{/if}}
|
|
645
|
+
|
|
646
|
+
{{#if (and hasTrustedProxy isPassportAuth hasRateLimiting hasDatabase)}}
|
|
647
|
+
it("isolates rate limits by client IP behind a trusted reverse proxy", async () => {
|
|
648
|
+
const appServer = app.getHttpServer();
|
|
649
|
+
const email = `passport-proxy-${Date.now()}@example.com`;
|
|
650
|
+
const password = "Scenario-password-123!";
|
|
651
|
+
await request(appServer).post({{#if (and usePrisma hasRest (not hasMongodb))}}"/auth/register"{{else}}"/auth/signup"{{/if}}).send({ email, password }).expect(201);
|
|
652
|
+
await new Promise<void>((resolve) => appServer.listen(0, "127.0.0.1", resolve));
|
|
653
|
+
const appAddress = appServer.address();
|
|
654
|
+
if (!appAddress || typeof appAddress === "string") {
|
|
655
|
+
throw new Error("Application did not bind a TCP port for the proxy scenario");
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const proxy = createServer((incoming, outgoing) => {
|
|
659
|
+
const forwardedClientIp = incoming.headers["x-scenario-client-ip"]?.toString() ?? "198.51.100.10";
|
|
660
|
+
const upstream = httpRequest({
|
|
661
|
+
headers: { ...incoming.headers, "x-forwarded-for": forwardedClientIp },
|
|
662
|
+
hostname: "127.0.0.1",
|
|
663
|
+
method: incoming.method,
|
|
664
|
+
path: incoming.url,
|
|
665
|
+
port: appAddress.port,
|
|
666
|
+
}, (upstreamResponse) => {
|
|
667
|
+
outgoing.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
|
|
668
|
+
upstreamResponse.pipe(outgoing);
|
|
669
|
+
});
|
|
670
|
+
upstream.on("error", () => {
|
|
671
|
+
outgoing.writeHead(502);
|
|
672
|
+
outgoing.end();
|
|
673
|
+
});
|
|
674
|
+
incoming.pipe(upstream);
|
|
675
|
+
});
|
|
676
|
+
await new Promise<void>((resolve) => proxy.listen(0, "127.0.0.1", resolve));
|
|
677
|
+
const proxyAddress = proxy.address();
|
|
678
|
+
if (!proxyAddress || typeof proxyAddress === "string") {
|
|
679
|
+
throw new Error("Reverse proxy did not bind a TCP port");
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const proxyLogin = async (clientIp: string, suppliedPassword: string): Promise<number> => {
|
|
683
|
+
const response = await fetch(`http://127.0.0.1:${proxyAddress.port}/auth/login`, {
|
|
684
|
+
body: JSON.stringify({ email, password: suppliedPassword }),
|
|
685
|
+
headers: {
|
|
686
|
+
"content-type": "application/json",
|
|
687
|
+
"x-scenario-client-ip": clientIp,
|
|
688
|
+
},
|
|
689
|
+
method: "POST",
|
|
690
|
+
});
|
|
691
|
+
return response.status;
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
expect(await proxyLogin("198.51.100.10", password)).toBe(200);
|
|
695
|
+
const invalidAttempts = await Promise.all(
|
|
696
|
+
Array.from({ length: 80 }, () => proxyLogin("198.51.100.10", "Invalid-password-123!"))
|
|
697
|
+
);
|
|
698
|
+
expect(invalidAttempts).toContain(429);
|
|
699
|
+
expect(await proxyLogin("198.51.100.20", password)).toBe(200);
|
|
700
|
+
|
|
701
|
+
await new Promise<void>((resolve, reject) => proxy.close((error) => error ? reject(error) : resolve()));
|
|
702
|
+
await app.close();
|
|
703
|
+
}, 30_000);
|
|
704
|
+
{{/if}}
|
|
705
|
+
|
|
706
|
+
{{#if (and isPassportAuth usePrisma hasRest)}}
|
|
707
|
+
it("registers, authenticates and refreshes Prisma Passport tokens", async () => {
|
|
136
708
|
const server = app.getHttpServer();
|
|
137
709
|
const email = `passport-${Date.now()}@example.com`;
|
|
138
710
|
const password = "Scenario-password-123!";
|
|
139
|
-
const registered = await request(server).post("/auth/register").send({ email, password }).expect(201);
|
|
711
|
+
const registered = await request(server).post({{#if (and usePrisma hasRest (not hasMongodb))}}"/auth/register"{{else}}"/auth/signup"{{/if}}).send({ email, password }).expect(201);
|
|
712
|
+
{{#if (and usePrisma hasRest (not hasMongodb))}}
|
|
713
|
+
expect(registered.body.email).toBe(email);
|
|
714
|
+
{{else}}
|
|
715
|
+
expect(registered.body.accessToken).toBeTruthy();
|
|
716
|
+
{{/if}}
|
|
140
717
|
expect(registered.body.password).toBeUndefined();
|
|
141
|
-
|
|
142
|
-
await request(server).post("/auth/register").send({ email, password }).expect(409);
|
|
143
|
-
await request(server).post("/auth/login").send({ email, password, role: "ADMIN" }).expect(400);
|
|
718
|
+
await request(server).post({{#if (and usePrisma hasRest (not hasMongodb))}}"/auth/register"{{else}}"/auth/signup"{{/if}}).send({ email, password }).expect(409);
|
|
144
719
|
await request(server).post("/auth/login").send({ email, password: "wrong-password-123" }).expect(401);
|
|
145
720
|
const login = await request(server).post("/auth/login").send({ email, password }).expect(200);
|
|
146
|
-
|
|
721
|
+
expect(login.body.accessToken).toBeTruthy();
|
|
722
|
+
expect(login.body.refreshToken).toBeTruthy();
|
|
723
|
+
{{#if hasTodoExample}}
|
|
724
|
+
await request(server).get("/todos").expect(401);
|
|
725
|
+
const todos = await request(server).get("/todos")
|
|
726
|
+
.auth(login.body.accessToken, { type: "bearer" }).expect(200);
|
|
727
|
+
expect(todos.body).toEqual([]);
|
|
728
|
+
await request(server).post("/todos").auth(login.body.accessToken, { type: "bearer" })
|
|
729
|
+
.send({ title: "" }).expect(400);
|
|
730
|
+
{{/if}}
|
|
731
|
+
{{#if (not hasMongodb)}}
|
|
147
732
|
await request(server).get("/auth/profile").expect(401);
|
|
148
|
-
const profile = await request(server).get("/auth/profile")
|
|
733
|
+
const profile = await request(server).get("/auth/profile")
|
|
734
|
+
.auth(login.body.accessToken, { type: "bearer" }).expect(200);
|
|
149
735
|
expect(profile.body.email).toBe(email);
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
await request(server).post("/auth/refresh").send({ refreshToken }).expect(401);
|
|
161
|
-
await request(server).post("/auth/refresh").send({ refreshToken: rotated.body.refreshToken }).expect(401);
|
|
162
|
-
await request(server).get("/auth/profile").auth(rotated.body.accessToken, { type: "bearer" }).expect(401);
|
|
736
|
+
{{/if}}
|
|
737
|
+
const rotated = await request(server).post("/auth/refresh").send({{#if hasMongodb}}{ token: login.body.refreshToken }{{else}}{ refreshToken: login.body.refreshToken }{{/if}}).expect(200);
|
|
738
|
+
expect(rotated.body.accessToken).toBeTruthy();
|
|
739
|
+
expect(rotated.body.refreshToken).toBeTruthy();
|
|
740
|
+
{{#if hasMongodb}}
|
|
741
|
+
await request(server).post("/auth/refresh").send({ token: "invalid-refresh-token" }).expect(401);
|
|
742
|
+
const fresh = await request(server).post("/auth/login").send({ email, password }).expect(200);
|
|
743
|
+
expect(fresh.body.refreshToken).toBeTruthy();
|
|
744
|
+
{{else}}
|
|
745
|
+
await request(server).post("/auth/refresh").send({ refreshToken: login.body.refreshToken }).expect(401);
|
|
163
746
|
const fresh = await request(server).post("/auth/login").send({ email, password }).expect(200);
|
|
164
747
|
await request(server).post("/auth/logout").send({ refreshToken: fresh.body.refreshToken }).expect(200);
|
|
165
|
-
await request(server).
|
|
166
|
-
|
|
167
|
-
expect(user.password).not.toBe(password);
|
|
168
|
-
expect(user.refreshTokens.every((token) => token.tokenHash !== refreshToken)).toBe(true);
|
|
169
|
-
const concurrentLogin = await request(server).post("/auth/login").send({ email, password }).expect(200);
|
|
170
|
-
const results = await Promise.all([1, 2].map(() => request(server).post("/auth/refresh").send({ refreshToken: concurrentLogin.body.refreshToken })));
|
|
171
|
-
expect(results.map(({ status }) => status).sort()).toEqual([200, 401]);
|
|
748
|
+
await request(server).post("/auth/refresh").send({ refreshToken: fresh.body.refreshToken }).expect(401);
|
|
749
|
+
{{/if}}
|
|
172
750
|
});
|
|
173
751
|
{{/if}}
|
|
174
|
-
{{#if
|
|
752
|
+
{{#if (and isPassportAuth (or useTypeorm useDrizzle))}}
|
|
753
|
+
it("registers, authenticates and refreshes passport sessions", async () => {
|
|
754
|
+
const server = app.getHttpServer();
|
|
755
|
+
const email = `passport-${Date.now()}@example.com`;
|
|
756
|
+
const password = "Scenario-password-123!";
|
|
757
|
+
const signedUp = await request(server).post("/auth/signup").send({ email, password }).expect(201);
|
|
758
|
+
expect(signedUp.body.accessToken).toBeTruthy();
|
|
759
|
+
expect(signedUp.body.refreshToken).toBeTruthy();
|
|
760
|
+
await request(server).post("/auth/signup").send({ email, password }).expect(409);
|
|
761
|
+
await request(server).post("/auth/login").send({ email, password: "wrong-password-123" }).expect(401);
|
|
762
|
+
const login = await request(server).post("/auth/login").send({ email, password }).expect(200);
|
|
763
|
+
const rotated = await request(server).post("/auth/refresh").send({ token: login.body.refreshToken }).expect(200);
|
|
764
|
+
expect(rotated.body.accessToken).toBeTruthy();
|
|
765
|
+
expect(rotated.body.refreshToken).toBeTruthy();
|
|
766
|
+
});
|
|
767
|
+
{{/if}}
|
|
768
|
+
{{#if (and hasMongodb (eq auth "none"))}}
|
|
175
769
|
it("rejects malformed and missing MongoDB identifiers", async () => {
|
|
176
770
|
await request(app.getHttpServer()).delete("/todos/invalid").expect(400);
|
|
177
771
|
await request(app.getHttpServer()).delete("/todos/000000000000000000000000").expect(404);
|
|
@@ -219,11 +813,50 @@ describe("AppController (e2e)", () => {
|
|
|
219
813
|
});
|
|
220
814
|
{{/if}}
|
|
221
815
|
|
|
816
|
+
{{#if (and isBetterAuth hasTodoExample hasDatabase hasRest)}}
|
|
817
|
+
it("enforces todo ownership across two authenticated users", async () => {
|
|
818
|
+
const server = app.getHttpServer();
|
|
819
|
+
const origin = "http://localhost:3000";
|
|
820
|
+
const password = "Scenario-password-123!";
|
|
821
|
+
async function register(prefix: string) {
|
|
822
|
+
const email = `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}@example.com`;
|
|
823
|
+
const response = await request(server).post("/api/auth/sign-up/email")
|
|
824
|
+
.set("Origin", origin).send({ email, password, name: prefix }).expect(200);
|
|
825
|
+
const cookies = response.headers["set-cookie"];
|
|
826
|
+
expect(cookies).toBeTruthy();
|
|
827
|
+
return cookies;
|
|
828
|
+
}
|
|
829
|
+
const alice = await register("alice");
|
|
830
|
+
const bob = await register("bob");
|
|
831
|
+
{{#if (includes addons "nestjs-zod")}}
|
|
832
|
+
await request(server).post("/todos").set("Cookie", alice)
|
|
833
|
+
.send({ title: "" }).expect(400);
|
|
834
|
+
{{/if}}
|
|
835
|
+
const created = await request(server).post("/todos").set("Cookie", alice)
|
|
836
|
+
.send({ title: "Private alice todo", completed: false }).expect(201);
|
|
837
|
+
const { id } = created.body;
|
|
838
|
+
expect(id).toBeTruthy();
|
|
839
|
+
const bobList = await request(server).get("/todos").set("Cookie", bob).expect(200);
|
|
840
|
+
expect(bobList.body).toEqual(expect.not.arrayContaining([expect.objectContaining({ id })]));
|
|
841
|
+
await request(server).put(`/todos/${id}`).set("Cookie", bob)
|
|
842
|
+
.send({ completed: true }).expect(404);
|
|
843
|
+
await request(server).delete(`/todos/${id}`).set("Cookie", bob).expect(404);
|
|
844
|
+
const aliceList = await request(server).get("/todos").set("Cookie", alice).expect(200);
|
|
845
|
+
expect(aliceList.body).toEqual(expect.arrayContaining([expect.objectContaining({ id, completed: false })]));
|
|
846
|
+
});
|
|
847
|
+
{{/if}}
|
|
848
|
+
|
|
222
849
|
it("rejects unsupported methods", async () => {
|
|
223
850
|
await request(app.getHttpServer()).post("/").send({ unexpected: true }).expect(404);
|
|
224
851
|
});
|
|
225
852
|
|
|
226
853
|
afterAll(async () => {
|
|
227
854
|
await app?.close();
|
|
855
|
+
{{#if (eq auth "better-auth:stateless")}}
|
|
856
|
+
await new Promise<void>((resolve, reject) => oidcServer?.close((error) => error ? reject(error) : resolve()));
|
|
857
|
+
{{/if}}
|
|
858
|
+
{{#if (and isPassportAuth (not hasDatabase))}}
|
|
859
|
+
await new Promise<void>((resolve, reject) => identityServer?.close((error) => error ? reject(error) : resolve()));
|
|
860
|
+
{{/if}}
|
|
228
861
|
});
|
|
229
862
|
});
|