@sundaysf/cli-v3 0.0.1
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/README.md +302 -0
- package/dist/cli.js +1327 -0
- package/package.json +55 -0
- package/templates/api/.claude/agents/knex-table-implementer.md +36 -0
- package/templates/api/.claude/agents/sundays-backend-builder.md +32 -0
- package/templates/api/.env.example +25 -0
- package/templates/api/.github/workflows/ci.yaml +45 -0
- package/templates/api/.github/workflows/deploy.yaml +72 -0
- package/templates/api/.prettierignore +5 -0
- package/templates/api/.prettierrc +9 -0
- package/templates/api/.sundaysrc +8 -0
- package/templates/api/CLAUDE.md +81 -0
- package/templates/api/Dockerfile +17 -0
- package/templates/api/README.md +164 -0
- package/templates/api/_dockerignore +8 -0
- package/templates/api/_gitignore +23 -0
- package/templates/api/_package.json +58 -0
- package/templates/api/docker-compose.yml +21 -0
- package/templates/api/eslint.config.js +27 -0
- package/templates/api/jest.config.js +33 -0
- package/templates/api/jest.setup.js +25 -0
- package/templates/api/knexfile.ts +5 -0
- package/templates/api/src/app.ts +48 -0
- package/templates/api/src/common/__tests__/common.test.ts +116 -0
- package/templates/api/src/common/config/env.ts +53 -0
- package/templates/api/src/common/errors/http.error.ts +30 -0
- package/templates/api/src/common/logger/index.ts +25 -0
- package/templates/api/src/common/utils/environment.resolver.ts +7 -0
- package/templates/api/src/common/utils/pagination.ts +25 -0
- package/templates/api/src/common/utils/version.resolver.ts +24 -0
- package/templates/api/src/common/validation/parse-dto.ts +20 -0
- package/templates/api/src/controllers/health/__tests__/health.controller.test.ts +52 -0
- package/templates/api/src/controllers/health/health.controller.ts +26 -0
- package/templates/api/src/db/BaseDAO.ts +92 -0
- package/templates/api/src/db/KnexConnection.ts +59 -0
- package/templates/api/src/db/__tests__/base-dao.test.ts +73 -0
- package/templates/api/src/db/__tests__/index.barrel.test.ts +10 -0
- package/templates/api/src/db/__tests__/knex-connection.test.ts +90 -0
- package/templates/api/src/db/d.types.ts +42 -0
- package/templates/api/src/db/dao/sundays-package-version/sundays-package-version.dao.ts +12 -0
- package/templates/api/src/db/index.ts +17 -0
- package/templates/api/src/db/interfaces/sundays-package-version/sundays-package-version.interfaces.ts +5 -0
- package/templates/api/src/db/knex.config.ts +46 -0
- package/templates/api/src/dto/input/.gitkeep +0 -0
- package/templates/api/src/jobs/.gitkeep +0 -0
- package/templates/api/src/middlewares/error/__tests__/error.middleware.test.ts +117 -0
- package/templates/api/src/middlewares/error/error.middleware.ts +70 -0
- package/templates/api/src/middlewares/not-found/__tests__/not-found.middleware.test.ts +54 -0
- package/templates/api/src/middlewares/not-found/not-found.middleware.ts +51 -0
- package/templates/api/src/middlewares/request-id/__tests__/request-id.middleware.test.ts +31 -0
- package/templates/api/src/middlewares/request-id/request-id.middleware.ts +20 -0
- package/templates/api/src/migrations/20240101000000_create_sundays_package_version.ts +15 -0
- package/templates/api/src/routes/__tests__/index-router.test.ts +61 -0
- package/templates/api/src/routes/health/__tests__/health.routes.test.ts +22 -0
- package/templates/api/src/routes/health/health.router.ts +18 -0
- package/templates/api/src/routes/index.ts +77 -0
- package/templates/api/src/seeds/001_sundays_package_version.ts +14 -0
- package/templates/api/src/server.ts +56 -0
- package/templates/api/src/services/.gitkeep +0 -0
- package/templates/api/tsconfig.json +20 -0
- package/templates/api/tsconfig.spec.json +10 -0
- package/templates/api-auth/overlay.json +95 -0
- package/templates/api-auth/src/controllers/auth/__tests__/auth.controller.test.ts +194 -0
- package/templates/api-auth/src/controllers/auth/auth.controller.ts +109 -0
- package/templates/api-auth/src/db/dao/auth/auth.dao.ts +21 -0
- package/templates/api-auth/src/db/dao/user/user.dao.ts +24 -0
- package/templates/api-auth/src/db/interfaces/auth/auth.interfaces.ts +8 -0
- package/templates/api-auth/src/db/interfaces/user/user.interfaces.ts +11 -0
- package/templates/api-auth/src/dto/input/auth/auth.login.dto.ts +14 -0
- package/templates/api-auth/src/dto/input/auth/auth.register.dto.ts +19 -0
- package/templates/api-auth/src/middlewares/auth/__tests__/auth.middleware.test.ts +52 -0
- package/templates/api-auth/src/middlewares/auth/auth.middleware.ts +56 -0
- package/templates/api-auth/src/migrations/20240101000001_create_user.ts +18 -0
- package/templates/api-auth/src/migrations/20240101000002_create_auth.ts +24 -0
- package/templates/api-auth/src/routes/auth/__tests__/auth.routes.test.ts +83 -0
- package/templates/api-auth/src/routes/auth/auth.router.ts +28 -0
- package/templates/api-auth/src/services/jwt/__tests__/jwt.service.test.ts +32 -0
- package/templates/api-auth/src/services/jwt/jwt.service.ts +36 -0
- package/templates/api-auth/src/services/password/__tests__/password.service.test.ts +13 -0
- package/templates/api-auth/src/services/password/password.service.ts +14 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { NextFunction, Request, Response } from 'express';
|
|
2
|
+
import { requestIdMiddleware } from '../request-id.middleware';
|
|
3
|
+
|
|
4
|
+
const build = (incoming?: string) => {
|
|
5
|
+
const req = { header: jest.fn(() => incoming) } as unknown as Request;
|
|
6
|
+
const res = { setHeader: jest.fn() } as unknown as Response;
|
|
7
|
+
const next = jest.fn() as NextFunction;
|
|
8
|
+
return { req, res, next };
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe('requestIdMiddleware', () => {
|
|
12
|
+
it('generates a uuid when no header is present', () => {
|
|
13
|
+
const { req, res, next } = build();
|
|
14
|
+
requestIdMiddleware(req, res, next);
|
|
15
|
+
expect(req.id).toMatch(/^[0-9a-f-]{36}$/);
|
|
16
|
+
expect(res.setHeader).toHaveBeenCalledWith('x-request-id', req.id);
|
|
17
|
+
expect(next).toHaveBeenCalled();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('reuses a sane incoming header', () => {
|
|
21
|
+
const { req, res, next } = build('trace-42');
|
|
22
|
+
requestIdMiddleware(req, res, next);
|
|
23
|
+
expect(req.id).toBe('trace-42');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('ignores an oversized incoming header', () => {
|
|
27
|
+
const { req, res, next } = build('x'.repeat(200));
|
|
28
|
+
requestIdMiddleware(req, res, next);
|
|
29
|
+
expect(req.id).not.toBe('x'.repeat(200));
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import type { NextFunction, Request, Response } from 'express';
|
|
3
|
+
|
|
4
|
+
export const REQUEST_ID_HEADER = 'x-request-id';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Gives every request an id (honouring an incoming X-Request-Id from a proxy) and echoes
|
|
8
|
+
* it back so clients and logs can be correlated. pino-http picks it up via genReqId.
|
|
9
|
+
*/
|
|
10
|
+
export const requestIdMiddleware = (
|
|
11
|
+
req: Request,
|
|
12
|
+
res: Response,
|
|
13
|
+
next: NextFunction
|
|
14
|
+
): void => {
|
|
15
|
+
const incoming = req.header(REQUEST_ID_HEADER);
|
|
16
|
+
const id = incoming && incoming.length <= 128 ? incoming : randomUUID();
|
|
17
|
+
req.id = id;
|
|
18
|
+
res.setHeader(REQUEST_ID_HEADER, id);
|
|
19
|
+
next();
|
|
20
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Knex } from 'knex';
|
|
2
|
+
|
|
3
|
+
export async function up(knex: Knex): Promise<void> {
|
|
4
|
+
await knex.schema.createTable('sundays_package_version', (table) => {
|
|
5
|
+
table.increments('id').primary();
|
|
6
|
+
table.uuid('uuid').notNullable().unique();
|
|
7
|
+
table.string('versionName').notNullable();
|
|
8
|
+
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now());
|
|
9
|
+
table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now());
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function down(knex: Knex): Promise<void> {
|
|
14
|
+
await knex.schema.dropTableIfExists('sundays_package_version');
|
|
15
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import request from 'supertest';
|
|
5
|
+
import { IndexRouter } from '..';
|
|
6
|
+
|
|
7
|
+
const write = (file: string, content: string): void => {
|
|
8
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
9
|
+
fs.writeFileSync(file, content);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe('IndexRouter auto-discovery', () => {
|
|
13
|
+
let dir: string;
|
|
14
|
+
|
|
15
|
+
// Fixtures live inside the project so ts-jest can resolve 'express'. The folder name
|
|
16
|
+
// starts with "_" so a real IndexRouter scan of src/routes ignores it (and __tests__).
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
dir = fs.mkdtempSync(path.join(__dirname, '_fixtures-'));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('mounts <folder>/<folder>.router at /<folder> and ignores _ and . folders', async () => {
|
|
26
|
+
write(
|
|
27
|
+
path.join(dir, 'ping', 'ping.router.ts'),
|
|
28
|
+
`import { Router, type Request, type Response } from 'express';
|
|
29
|
+
export class PingRouter {
|
|
30
|
+
public router = Router();
|
|
31
|
+
constructor() {
|
|
32
|
+
this.router.get('/', (_req: Request, res: Response) => { res.json({ ok: true }); });
|
|
33
|
+
}
|
|
34
|
+
}`
|
|
35
|
+
);
|
|
36
|
+
write(path.join(dir, '__tests__', '__tests__.router.ts'), 'export {};');
|
|
37
|
+
write(path.join(dir, '.hidden', '.hidden.router.ts'), 'export {};');
|
|
38
|
+
write(path.join(dir, 'empty', 'README.md'), '# no router here');
|
|
39
|
+
|
|
40
|
+
const app = express().use('/api', new IndexRouter(dir).router);
|
|
41
|
+
const ok = await request(app).get('/api/ping');
|
|
42
|
+
expect(ok.status).toBe(200);
|
|
43
|
+
expect(ok.body).toEqual({ ok: true });
|
|
44
|
+
|
|
45
|
+
expect((await request(app).get('/api/__tests__')).status).toBe(404);
|
|
46
|
+
expect((await request(app).get('/api/empty')).status).toBe(404);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('skips modules that export no class', () => {
|
|
50
|
+
write(path.join(dir, 'noop', 'noop.router.ts'), 'export const value = 42;');
|
|
51
|
+
expect(() => new IndexRouter(dir)).not.toThrow();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('rethrows when a router module fails to load', () => {
|
|
55
|
+
write(
|
|
56
|
+
path.join(dir, 'broken', 'broken.router.ts'),
|
|
57
|
+
'throw new Error("bad module");\nexport {};'
|
|
58
|
+
);
|
|
59
|
+
expect(() => new IndexRouter(dir)).toThrow('bad module');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import request from 'supertest';
|
|
2
|
+
import app from '../../../app';
|
|
3
|
+
|
|
4
|
+
// No database needed: health does not query Postgres.
|
|
5
|
+
describe('GET /api/health', () => {
|
|
6
|
+
it('returns the standard envelope with a request id header', async () => {
|
|
7
|
+
const res = await request(app).get('/api/health');
|
|
8
|
+
|
|
9
|
+
expect(res.status).toBe(200);
|
|
10
|
+
expect(res.body.success).toBe(true);
|
|
11
|
+
expect(res.body.health).toBe('Up!');
|
|
12
|
+
expect(typeof res.body.version).toBe('string');
|
|
13
|
+
expect(res.headers['x-request-id']).toBeDefined();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('echoes an incoming X-Request-Id', async () => {
|
|
17
|
+
const res = await request(app)
|
|
18
|
+
.get('/api/health')
|
|
19
|
+
.set('X-Request-Id', 'abc-123');
|
|
20
|
+
expect(res.headers['x-request-id']).toBe('abc-123');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { HealthController } from '../../controllers/health/health.controller';
|
|
3
|
+
|
|
4
|
+
export class HealthRouter {
|
|
5
|
+
public router: Router = Router();
|
|
6
|
+
private readonly _healthController = new HealthController();
|
|
7
|
+
|
|
8
|
+
constructor() {
|
|
9
|
+
this.initRoutes();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
private initRoutes(): void {
|
|
13
|
+
this.router.get(
|
|
14
|
+
'/',
|
|
15
|
+
this._healthController.getHealthStatus.bind(this._healthController)
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { Router } from 'express';
|
|
4
|
+
import { logger } from '../common/logger';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Router auto-discovery. Every direct subfolder of src/routes/ that contains a
|
|
8
|
+
* `<folder>/<folder>.router.ts` is mounted at `/api/<folder>`:
|
|
9
|
+
*
|
|
10
|
+
* src/routes/products/products.router.ts -> /api/products
|
|
11
|
+
*
|
|
12
|
+
* The file must export a class whose instances expose a `router: Router` property
|
|
13
|
+
* (default export or first exported class). Folders starting with "_" or "." are ignored.
|
|
14
|
+
*/
|
|
15
|
+
export class IndexRouter {
|
|
16
|
+
public readonly router: Router = Router();
|
|
17
|
+
|
|
18
|
+
constructor(private readonly routesDir: string = __dirname) {
|
|
19
|
+
this.loadRoutes();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** '.ts' under tsx / ts-jest, '.js' from dist/. */
|
|
23
|
+
private get extension(): string {
|
|
24
|
+
return path.extname(__filename) || '.js';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private loadRoutes(): void {
|
|
28
|
+
const folders = fs
|
|
29
|
+
.readdirSync(this.routesDir, { withFileTypes: true })
|
|
30
|
+
.filter(
|
|
31
|
+
(d) =>
|
|
32
|
+
d.isDirectory() && !d.name.startsWith('_') && !d.name.startsWith('.')
|
|
33
|
+
)
|
|
34
|
+
.map((d) => d.name)
|
|
35
|
+
.sort();
|
|
36
|
+
const mounted: string[] = [];
|
|
37
|
+
|
|
38
|
+
for (const folder of folders) {
|
|
39
|
+
const file = path.join(
|
|
40
|
+
this.routesDir,
|
|
41
|
+
folder,
|
|
42
|
+
`${folder}.router${this.extension}`
|
|
43
|
+
);
|
|
44
|
+
if (!fs.existsSync(file)) {
|
|
45
|
+
logger.warn(
|
|
46
|
+
`[routes] no ${folder}.router${this.extension} found in routes/${folder}, skipped`
|
|
47
|
+
);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
52
|
+
const mod = require(file) as Record<string, unknown>;
|
|
53
|
+
const RouterClass = (mod.default ??
|
|
54
|
+
Object.values(mod).find((e) => typeof e === 'function')) as
|
|
55
|
+
(new () => { router: Router }) | undefined;
|
|
56
|
+
if (!RouterClass) {
|
|
57
|
+
logger.warn(
|
|
58
|
+
`[routes] ${path.basename(file)} exports no router class, skipped`
|
|
59
|
+
);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
this.router.use(`/${folder}`, new RouterClass().router);
|
|
63
|
+
logger.info(
|
|
64
|
+
`[routes] mounted /api/${folder} -> ${path.basename(file)}`
|
|
65
|
+
);
|
|
66
|
+
mounted.push(`/api/${folder}`);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
logger.error({ err }, `[routes] failed to load ${file}`);
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
logger.info(
|
|
73
|
+
{ routes: mounted },
|
|
74
|
+
`[routes] ${mounted.length} router(s) mounted`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import type { Knex } from 'knex';
|
|
3
|
+
|
|
4
|
+
export async function seed(knex: Knex): Promise<void> {
|
|
5
|
+
const existing = await knex('sundays_package_version')
|
|
6
|
+
.where({ versionName: '__SF_CLI_VERSION__' })
|
|
7
|
+
.first();
|
|
8
|
+
if (!existing) {
|
|
9
|
+
await knex('sundays_package_version').insert({
|
|
10
|
+
uuid: randomUUID(),
|
|
11
|
+
versionName: '__SF_CLI_VERSION__',
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { env } from './common/config/env';
|
|
2
|
+
import { logger } from './common/logger';
|
|
3
|
+
import KnexManager from './db/KnexConnection';
|
|
4
|
+
|
|
5
|
+
const SHUTDOWN_TIMEOUT_MS = 10_000;
|
|
6
|
+
|
|
7
|
+
const main = async (): Promise<void> => {
|
|
8
|
+
await KnexManager.connect();
|
|
9
|
+
|
|
10
|
+
if (env.RUN_MIGRATIONS) {
|
|
11
|
+
const [batch, applied] = await KnexManager.getConnection().migrate.latest();
|
|
12
|
+
if (applied.length) {
|
|
13
|
+
logger.info(
|
|
14
|
+
{ batch, applied },
|
|
15
|
+
`[migrations] batch ${batch}: ${applied.length} applied`
|
|
16
|
+
);
|
|
17
|
+
} else {
|
|
18
|
+
logger.info('[migrations] up to date');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// app.ts is imported after the connection exists so nothing at module scope can hit
|
|
23
|
+
// the database before it is ready.
|
|
24
|
+
const { default: app } = await import('./app');
|
|
25
|
+
const server = app.listen(env.PORT, () => {
|
|
26
|
+
logger.info(
|
|
27
|
+
{ port: env.PORT, env: env.NODE_ENV },
|
|
28
|
+
`API listening on http://localhost:${env.PORT}`
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const shutdown = (signal: NodeJS.Signals): void => {
|
|
33
|
+
logger.info({ signal }, 'Shutting down');
|
|
34
|
+
server.close(async () => {
|
|
35
|
+
try {
|
|
36
|
+
await KnexManager.disconnect();
|
|
37
|
+
process.exit(0);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
logger.error({ err }, 'Error during shutdown');
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
setTimeout(() => {
|
|
44
|
+
logger.warn('Forcing exit after shutdown timeout');
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}, SHUTDOWN_TIMEOUT_MS).unref();
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
process.on('SIGTERM', shutdown);
|
|
50
|
+
process.on('SIGINT', shutdown);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
main().catch((err: unknown) => {
|
|
54
|
+
logger.fatal({ err }, 'Failed to start');
|
|
55
|
+
process.exit(1);
|
|
56
|
+
});
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"lib": ["ES2022"],
|
|
6
|
+
"rootDir": "./src",
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"resolveJsonModule": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"strict": true,
|
|
12
|
+
"noUnusedLocals": true,
|
|
13
|
+
"noImplicitOverride": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"sourceMap": true,
|
|
16
|
+
"types": ["node"]
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*"],
|
|
19
|
+
"exclude": ["node_modules", "dist", "**/__tests__/**", "**/*.test.ts"]
|
|
20
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
{
|
|
2
|
+
"packageJson": {
|
|
3
|
+
"dependencies": {
|
|
4
|
+
"bcryptjs": "^3.0.3",
|
|
5
|
+
"jsonwebtoken": "^9.0.3"
|
|
6
|
+
},
|
|
7
|
+
"devDependencies": {
|
|
8
|
+
"@types/jsonwebtoken": "^9.0.10"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"inject": [
|
|
12
|
+
{
|
|
13
|
+
"file": "src/db/index.ts",
|
|
14
|
+
"marker": "// @sundays:interfaces",
|
|
15
|
+
"lines": [
|
|
16
|
+
"export type { IUser, IPublicUser } from './interfaces/user/user.interfaces';",
|
|
17
|
+
"export type { IAuth } from './interfaces/auth/auth.interfaces';"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"file": "src/db/index.ts",
|
|
22
|
+
"marker": "// @sundays:daos",
|
|
23
|
+
"lines": [
|
|
24
|
+
"export { UserDAO } from './dao/user/user.dao';",
|
|
25
|
+
"export { AuthDAO } from './dao/auth/auth.dao';"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"file": "src/common/config/env.ts",
|
|
30
|
+
"marker": "// @sundays:env-schema",
|
|
31
|
+
"lines": [
|
|
32
|
+
" JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),",
|
|
33
|
+
" JWT_EXPIRES_IN: z.string().default('7d'),"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"file": ".env.example",
|
|
38
|
+
"marker": "# @sundays:env",
|
|
39
|
+
"lines": [
|
|
40
|
+
"# Auth (JWT). sundaysf new wrote a random secret to .env; keep it out of git.",
|
|
41
|
+
"JWT_SECRET=__SF_JWT_SECRET__",
|
|
42
|
+
"JWT_EXPIRES_IN=7d",
|
|
43
|
+
""
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"file": ".github/workflows/ci.yaml",
|
|
48
|
+
"marker": "# @sundays:ci-env",
|
|
49
|
+
"lines": [
|
|
50
|
+
" JWT_SECRET: ci-only-secret-not-used-anywhere-else-0123456789"
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"file": "README.md",
|
|
55
|
+
"marker": "<!-- @sundays:readme-env -->",
|
|
56
|
+
"lines": [
|
|
57
|
+
"| `JWT_SECRET` | (generated) | Secret used to sign access tokens. At least 32 characters. |",
|
|
58
|
+
"| `JWT_EXPIRES_IN` | `7d` | Access token lifetime (`60`, `1h`, `7d`...). |"
|
|
59
|
+
]
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"file": "README.md",
|
|
63
|
+
"marker": "<!-- @sundays:readme-endpoints -->",
|
|
64
|
+
"lines": [
|
|
65
|
+
"### Auth",
|
|
66
|
+
"",
|
|
67
|
+
"| Method | Path | Auth | Body |",
|
|
68
|
+
"|---|---|---|---|",
|
|
69
|
+
"| POST | `/api/auth/register` | no | `{ email, password, firstName, lastName }` |",
|
|
70
|
+
"| POST | `/api/auth/login` | no | `{ email, password }` |",
|
|
71
|
+
"| GET | `/api/auth/me` | Bearer | - |",
|
|
72
|
+
"",
|
|
73
|
+
"Register and login answer `{ success: true, data: { token, user } }`. Send the token as",
|
|
74
|
+
"`Authorization: Bearer <token>`; `authMiddleware` puts the payload in `req.auth`.",
|
|
75
|
+
"Protect any route by adding the middleware before the handler:",
|
|
76
|
+
"",
|
|
77
|
+
"```ts",
|
|
78
|
+
"this.router.get('/', authMiddleware, this._controller.getAll.bind(this._controller));",
|
|
79
|
+
"```",
|
|
80
|
+
""
|
|
81
|
+
]
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"file": "CLAUDE.md",
|
|
85
|
+
"marker": "<!-- @sundays:features -->",
|
|
86
|
+
"lines": [
|
|
87
|
+
"- **Auth**: `POST /api/auth/register|login`, `GET /api/auth/me`. JWT signed by `JwtService`",
|
|
88
|
+
" (`src/services/jwt`), passwords hashed by `PasswordService` (bcryptjs). Protect routes with",
|
|
89
|
+
" `authMiddleware` from `src/middlewares/auth/auth.middleware.ts`, which sets `req.auth`",
|
|
90
|
+
" (`{ sub: user uuid, userId, email }`). Tables: `user` (profile) and `auth` (credentials, 1:1).",
|
|
91
|
+
""
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { NextFunction, Request, Response } from 'express';
|
|
2
|
+
import { HttpError } from '../../../common/errors/http.error';
|
|
3
|
+
|
|
4
|
+
const mockUserDao = {
|
|
5
|
+
getByEmail: jest.fn(),
|
|
6
|
+
getByUuid: jest.fn(),
|
|
7
|
+
create: jest.fn(),
|
|
8
|
+
};
|
|
9
|
+
const mockAuthDao = {
|
|
10
|
+
getByUserId: jest.fn(),
|
|
11
|
+
create: jest.fn(),
|
|
12
|
+
touchLastLogin: jest.fn(),
|
|
13
|
+
};
|
|
14
|
+
const mockTrx = jest.fn(async (fn: (trx: unknown) => Promise<unknown>) =>
|
|
15
|
+
fn('trx')
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
jest.mock('../../../db', () => ({
|
|
19
|
+
UserDAO: Object.assign(
|
|
20
|
+
jest.fn(() => mockUserDao),
|
|
21
|
+
{ toPublic: (u: Record<string, unknown>) => ({ ...u, id: undefined }) }
|
|
22
|
+
),
|
|
23
|
+
AuthDAO: jest.fn(() => mockAuthDao),
|
|
24
|
+
KnexManager: { getConnection: () => ({ transaction: mockTrx }) },
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
import { AuthController } from '../auth.controller';
|
|
28
|
+
import { JwtService } from '../../../services/jwt/jwt.service';
|
|
29
|
+
|
|
30
|
+
const mockRes = (): Response => {
|
|
31
|
+
const res: Partial<Response> = {};
|
|
32
|
+
res.status = jest.fn().mockReturnValue(res);
|
|
33
|
+
res.json = jest.fn().mockReturnValue(res);
|
|
34
|
+
return res as Response;
|
|
35
|
+
};
|
|
36
|
+
const user = {
|
|
37
|
+
id: 1,
|
|
38
|
+
uuid: 'uuid-1',
|
|
39
|
+
email: 'a@b.co',
|
|
40
|
+
firstName: 'A',
|
|
41
|
+
lastName: 'B',
|
|
42
|
+
isActive: true,
|
|
43
|
+
};
|
|
44
|
+
const body = {
|
|
45
|
+
email: 'A@b.co',
|
|
46
|
+
password: 'secret123',
|
|
47
|
+
firstName: 'A',
|
|
48
|
+
lastName: 'B',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe('AuthController', () => {
|
|
52
|
+
const controller = new AuthController();
|
|
53
|
+
let next: jest.MockedFunction<NextFunction>;
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
jest.clearAllMocks();
|
|
56
|
+
next = jest.fn();
|
|
57
|
+
});
|
|
58
|
+
const errorOf = () => next.mock.calls[0][0] as unknown as HttpError;
|
|
59
|
+
|
|
60
|
+
describe('register', () => {
|
|
61
|
+
it('creates user + auth in a transaction and returns a token', async () => {
|
|
62
|
+
mockUserDao.getByEmail.mockResolvedValue(null);
|
|
63
|
+
mockUserDao.create.mockResolvedValue(user);
|
|
64
|
+
mockAuthDao.create.mockResolvedValue({ id: 9 });
|
|
65
|
+
const res = mockRes();
|
|
66
|
+
|
|
67
|
+
await controller.register({ body } as Request, res, next);
|
|
68
|
+
|
|
69
|
+
expect(mockUserDao.getByEmail).toHaveBeenCalledWith('a@b.co');
|
|
70
|
+
expect(mockUserDao.create).toHaveBeenCalledWith(
|
|
71
|
+
{ email: 'a@b.co', firstName: 'A', lastName: 'B' },
|
|
72
|
+
'trx'
|
|
73
|
+
);
|
|
74
|
+
expect(mockAuthDao.create).toHaveBeenCalledWith(
|
|
75
|
+
{ userId: 1, password: expect.stringMatching(/^\$2/) },
|
|
76
|
+
'trx'
|
|
77
|
+
);
|
|
78
|
+
expect(res.status).toHaveBeenCalledWith(201);
|
|
79
|
+
const payload = (res.json as jest.Mock).mock.calls[0][0];
|
|
80
|
+
expect(payload.success).toBe(true);
|
|
81
|
+
expect(new JwtService().verify(payload.data.token)).toEqual({
|
|
82
|
+
sub: 'uuid-1',
|
|
83
|
+
userId: 1,
|
|
84
|
+
email: 'a@b.co',
|
|
85
|
+
});
|
|
86
|
+
expect(payload.data.user.id).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('rejects invalid input with 400', async () => {
|
|
90
|
+
await controller.register(
|
|
91
|
+
{ body: { email: 'nope' } } as Request,
|
|
92
|
+
mockRes(),
|
|
93
|
+
next
|
|
94
|
+
);
|
|
95
|
+
expect(errorOf().statusCode).toBe(400);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('rejects a duplicate email with 409', async () => {
|
|
99
|
+
mockUserDao.getByEmail.mockResolvedValue(user);
|
|
100
|
+
await controller.register({ body } as Request, mockRes(), next);
|
|
101
|
+
expect(errorOf().statusCode).toBe(409);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('login', () => {
|
|
106
|
+
const hash = '$2a$04$Z9mcz8kqW0K2q4YwJ8zvIe6z0qYqQm4L2dHfM9Yd3n9J6b0Z0m6Wu'; // placeholder, compare is mocked below
|
|
107
|
+
|
|
108
|
+
it('returns a token for valid credentials', async () => {
|
|
109
|
+
mockUserDao.getByEmail.mockResolvedValue(user);
|
|
110
|
+
mockAuthDao.getByUserId.mockResolvedValue({
|
|
111
|
+
id: 9,
|
|
112
|
+
userId: 1,
|
|
113
|
+
password: hash,
|
|
114
|
+
});
|
|
115
|
+
const spy = jest
|
|
116
|
+
.spyOn(controller['_passwordService'], 'compare')
|
|
117
|
+
.mockResolvedValue(true);
|
|
118
|
+
const res = mockRes();
|
|
119
|
+
|
|
120
|
+
await controller.login(
|
|
121
|
+
{ body: { email: 'a@b.co', password: 'secret123' } } as Request,
|
|
122
|
+
res,
|
|
123
|
+
next
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(spy).toHaveBeenCalledWith('secret123', hash);
|
|
127
|
+
expect(mockAuthDao.touchLastLogin).toHaveBeenCalledWith(9);
|
|
128
|
+
expect(res.status).toHaveBeenCalledWith(200);
|
|
129
|
+
expect((res.json as jest.Mock).mock.calls[0][0].data.token).toEqual(
|
|
130
|
+
expect.any(String)
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it.each([
|
|
135
|
+
['unknown email', null, null, false],
|
|
136
|
+
['wrong password', user, { id: 9, userId: 1, password: hash }, false],
|
|
137
|
+
])('answers 401 for %s', async (_label, foundUser, foundAuth, cmp) => {
|
|
138
|
+
mockUserDao.getByEmail.mockResolvedValue(foundUser);
|
|
139
|
+
mockAuthDao.getByUserId.mockResolvedValue(foundAuth);
|
|
140
|
+
jest
|
|
141
|
+
.spyOn(controller['_passwordService'], 'compare')
|
|
142
|
+
.mockResolvedValue(cmp);
|
|
143
|
+
await controller.login(
|
|
144
|
+
{ body: { email: 'a@b.co', password: 'x' } } as Request,
|
|
145
|
+
mockRes(),
|
|
146
|
+
next
|
|
147
|
+
);
|
|
148
|
+
expect(errorOf().statusCode).toBe(401);
|
|
149
|
+
expect(errorOf().message).toBe('Invalid credentials');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('answers 401 for a disabled account', async () => {
|
|
153
|
+
mockUserDao.getByEmail.mockResolvedValue({ ...user, isActive: false });
|
|
154
|
+
mockAuthDao.getByUserId.mockResolvedValue({
|
|
155
|
+
id: 9,
|
|
156
|
+
userId: 1,
|
|
157
|
+
password: hash,
|
|
158
|
+
});
|
|
159
|
+
jest
|
|
160
|
+
.spyOn(controller['_passwordService'], 'compare')
|
|
161
|
+
.mockResolvedValue(true);
|
|
162
|
+
await controller.login(
|
|
163
|
+
{ body: { email: 'a@b.co', password: 'x' } } as Request,
|
|
164
|
+
mockRes(),
|
|
165
|
+
next
|
|
166
|
+
);
|
|
167
|
+
expect(errorOf().message).toBe('Account is disabled');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('me', () => {
|
|
172
|
+
it('returns the public user', async () => {
|
|
173
|
+
mockUserDao.getByUuid.mockResolvedValue(user);
|
|
174
|
+
const res = mockRes();
|
|
175
|
+
await controller.me(
|
|
176
|
+
{ auth: { sub: 'uuid-1', userId: 1, email: 'a@b.co' } } as Request,
|
|
177
|
+
res,
|
|
178
|
+
next
|
|
179
|
+
);
|
|
180
|
+
expect(mockUserDao.getByUuid).toHaveBeenCalledWith('uuid-1');
|
|
181
|
+
expect(res.status).toHaveBeenCalledWith(200);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('answers 404 when the user vanished', async () => {
|
|
185
|
+
mockUserDao.getByUuid.mockResolvedValue(null);
|
|
186
|
+
await controller.me(
|
|
187
|
+
{ auth: { sub: 'gone', userId: 1, email: 'a@b.co' } } as Request,
|
|
188
|
+
mockRes(),
|
|
189
|
+
next
|
|
190
|
+
);
|
|
191
|
+
expect(errorOf().statusCode).toBe(404);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|