From 40687f68fa81ac2f9d1e6374b2ba8a574fa97a45 Mon Sep 17 00:00:00 2001 From: saqib mir Date: Mon, 7 Sep 2026 12:45:37 +0530 Subject: [PATCH] feat(010-identity-auth): real staff login, session verification, and role gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the no-op fastify.authenticate stub and the never-implemented identity/auth login with real bcrypt password verification, JWT session issuance/verification (reusing the existing JWT_SECRET), and a Redis-backed revocation denylist for logout. Adds requireRole('ADMIN') to admin-only configuration writes across 002-009 that previously relied on a decorator that never actually checked anything. Adds self-identity (GET /auth/me, re-validated against live account state) and admin-provisioned accounts (POST /admin/users). Making the auth check genuinely reject invalid/missing tokens exposed that ~18 pre-existing integration test files called already-gated routes with no Authorization header (safe against the old no-op stub, broken against a real one) — fixed via a shared tests/helpers/auth.ts (loginAs/authHeader) and a file-by-file pass, plus two related SLA-run cleanup races exposed once admin setup calls in those files' own beforeAll blocks started actually succeeding. Co-Authored-By: Claude Sonnet 5 --- package-lock.json | 138 ++++++++++ package.json | 4 + .../migration.sql | 20 ++ prisma/schema.prisma | 22 +- prisma/seed/demo.seed.ts | 7 + prisma/seed/roles.seed.ts | 8 + .../checklists/requirements.md | 20 ++ specs/010-identity-auth/research.md | 13 + specs/010-identity-auth/tasks.md | 72 ++--- src/api/routes.ts | 2 + src/common/types/auth.types.ts | 1 + src/config/auth.ts | 6 + src/config/env.ts | 5 + src/config/index.ts | 1 + src/infrastructure/cache/auth-revocation.ts | 17 ++ src/infrastructure/cache/index.ts | 1 + src/jobs/sla/index.ts | 7 +- .../knowledge/routes/knowledge.routes.ts | 33 +-- .../sessions/routes/sessions.routes.ts | 9 +- .../product-integrations-admin.routes.ts | 15 +- .../agents/controller/agents.controller.ts | 11 + .../identity/agents/repository/index.ts | 1 + .../agents/repository/users.repository.ts | 23 ++ .../identity/agents/routes/agents.routes.ts | 12 +- src/modules/identity/agents/schema/index.ts | 1 + .../identity/agents/schema/users.schema.ts | 12 + src/modules/identity/agents/service/index.ts | 1 + .../identity/agents/service/users.service.ts | 35 +++ .../auth/controller/auth.controller.ts | 32 ++- src/modules/identity/auth/index.ts | 6 +- .../identity/auth/mapper/auth.mapper.ts | 55 +++- .../auth/repository/auth.repository.ts | 14 +- .../identity/auth/routes/auth.routes.ts | 13 +- .../identity/auth/schema/auth.schema.ts | 11 +- .../identity/auth/service/auth.service.ts | 45 +++- src/modules/identity/auth/service/index.ts | 1 + .../identity/auth/service/require-role.ts | 15 ++ src/modules/identity/auth/types/auth.types.ts | 4 +- src/modules/identity/auth/types/index.ts | 3 +- .../identity/teams/routes/teams.routes.ts | 17 +- .../assignments/engine/assignment.engine.ts | 8 +- .../escalation/routes/escalation.routes.ts | 26 +- .../escalation/service/escalation.service.ts | 19 +- .../hierarchy/routes/hierarchy.routes.ts | 17 +- .../calculators/sla-due-date.calculator.ts | 5 +- .../orchestration/sla/routes/sla.routes.ts | 24 +- .../calculators/business-hours.calculator.ts | 7 +- .../business-calendars/controller/index.ts | 5 +- .../routes/business-calendars.routes.ts | 25 +- .../service/business-calendars.service.ts | 12 +- .../controller/resolutions.controller.ts | 4 +- .../tickets/routes/tickets.routes.ts | 6 +- src/plugins/auth.plugin.ts | 38 ++- tests/concurrency/round-robin.test.ts | 1 + tests/helpers/auth.ts | 40 +++ .../integration/ai-confidence-policy.test.ts | 9 + tests/integration/identity-auth-flow.test.ts | 252 ++++++++++++++++++ tests/integration/inbound-rate-limit.test.ts | 5 + tests/integration/knowledge-entries.test.ts | 17 +- tests/integration/knowledge-retrieval.test.ts | 11 +- tests/integration/known-issues.test.ts | 7 + tests/integration/orchestration-flow.test.ts | 17 ++ .../orchestration-strategies.test.ts | 17 ++ .../problem-resolution-flow.test.ts | 81 +++++- .../product-integrations-admin.test.ts | 9 + tests/integration/runbooks.test.ts | 9 + tests/integration/sla-escalation-flow.test.ts | 62 ++++- .../support-org-capability-lookup.test.ts | 13 + .../integration/support-org-hierarchy.test.ts | 34 ++- .../support-org-skills-availability.test.ts | 17 +- .../support-org-teams-agents.test.ts | 24 +- tests/integration/ticket-attachments.test.ts | 13 + tests/integration/ticket-creation.test.ts | 6 + tests/integration/ticket-messages.test.ts | 12 +- .../identity/login-failure-parity.test.ts | 62 +++++ tests/unit/identity/require-role.test.ts | 37 +++ .../escalation-rule-match.test.ts | 15 +- .../sla-breach-detection.test.ts | 5 +- .../orchestration/sla-pause-resume.test.ts | 12 +- tests/unit/orchestration/strategies.test.ts | 1 + .../business-calendars/calendar-walk.test.ts | 9 +- .../root-cause-schema.test.ts | 5 +- 82 files changed, 1502 insertions(+), 209 deletions(-) create mode 100644 prisma/migrations/20260907112241_add_identity_auth/migration.sql create mode 100644 src/config/auth.ts create mode 100644 src/infrastructure/cache/auth-revocation.ts create mode 100644 src/modules/identity/agents/repository/users.repository.ts create mode 100644 src/modules/identity/agents/schema/users.schema.ts create mode 100644 src/modules/identity/agents/service/users.service.ts create mode 100644 src/modules/identity/auth/service/require-role.ts create mode 100644 tests/helpers/auth.ts create mode 100644 tests/integration/identity-auth-flow.test.ts create mode 100644 tests/unit/identity/login-failure-parity.test.ts create mode 100644 tests/unit/identity/require-role.test.ts diff --git a/package-lock.json b/package-lock.json index 8f7bebc..2ed81e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,11 +19,13 @@ "@opentelemetry/api": "^1.8.0", "@opentelemetry/sdk-trace-base": "^1.22.0", "@prisma/client": "^5.12.1", + "bcryptjs": "^3.0.3", "bullmq": "^5.7.1", "dotenv": "^16.4.5", "fastify": "^4.26.2", "fastify-plugin": "^4.5.1", "ioredis": "^5.3.2", + "jsonwebtoken": "^9.0.3", "luxon": "^3.7.2", "pino": "^8.20.0", "pino-pretty": "^11.0.0", @@ -31,6 +33,8 @@ "zod": "^3.22.4" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/jsonwebtoken": "^9.0.10", "@types/luxon": "^3.7.5", "@types/node": "^20.12.7", "@typescript-eslint/eslint-plugin": "^7.6.0", @@ -1968,6 +1972,13 @@ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", "license": "MIT" }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1975,6 +1986,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/luxon": { "version": "3.7.5", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.5.tgz", @@ -1982,6 +2004,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", @@ -2547,6 +2576,15 @@ ], "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2618,6 +2656,12 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bullmq": { "version": "5.81.3", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.81.3.tgz", @@ -3070,6 +3114,15 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -4319,6 +4372,49 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4544,6 +4640,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4551,6 +4683,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", diff --git a/package.json b/package.json index 54696a4..bbbc9d6 100644 --- a/package.json +++ b/package.json @@ -56,11 +56,13 @@ "@opentelemetry/api": "^1.8.0", "@opentelemetry/sdk-trace-base": "^1.22.0", "@prisma/client": "^5.12.1", + "bcryptjs": "^3.0.3", "bullmq": "^5.7.1", "dotenv": "^16.4.5", "fastify": "^4.26.2", "fastify-plugin": "^4.5.1", "ioredis": "^5.3.2", + "jsonwebtoken": "^9.0.3", "luxon": "^3.7.2", "pino": "^8.20.0", "pino-pretty": "^11.0.0", @@ -68,6 +70,8 @@ "zod": "^3.22.4" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/jsonwebtoken": "^9.0.10", "@types/luxon": "^3.7.5", "@types/node": "^20.12.7", "@typescript-eslint/eslint-plugin": "^7.6.0", diff --git a/prisma/migrations/20260907112241_add_identity_auth/migration.sql b/prisma/migrations/20260907112241_add_identity_auth/migration.sql new file mode 100644 index 0000000..3f930c7 --- /dev/null +++ b/prisma/migrations/20260907112241_add_identity_auth/migration.sql @@ -0,0 +1,20 @@ +-- AlterTable +ALTER TABLE "agents" ADD COLUMN "userId" TEXT; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "active" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "passwordHash" TEXT NOT NULL DEFAULT ''; + +-- The default above exists only to satisfy the NOT NULL constraint against this (empty) +-- table at migration time — application code always provides a real bcryptjs hash on every +-- User row it creates (specs/010-identity-auth/data-model.md), so the default itself is +-- dropped immediately below to keep schema.prisma and the live database in agreement (no +-- default declared in the Prisma schema). +ALTER TABLE "users" ALTER COLUMN "passwordHash" DROP DEFAULT; + +-- CreateIndex +CREATE UNIQUE INDEX "agents_userId_key" ON "agents"("userId"); + +-- AddForeignKey +ALTER TABLE "agents" ADD CONSTRAINT "agents_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1792d72..d5d2755 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,12 +14,17 @@ enum UserRole { } model User { - id String @id @default(uuid()) - email String @unique - name String - role UserRole @default(CUSTOMER) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + email String @unique + name String + role UserRole @default(CUSTOMER) + passwordHash String // bcryptjs hash — never the plaintext password; see + // specs/010-identity-auth/data-model.md + active Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + agent Agent? @@map("users") } @@ -417,6 +422,11 @@ model Agent { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + // Nullable link to the login identity this routing/skills profile belongs to — schema + // capability only, no workflow sets it yet; see specs/010-identity-auth/research.md. + userId String? @unique + user User? @relation(fields: [userId], references: [id]) + skills AgentSkill[] availability AgentAvailability? assignments Assignment[] diff --git a/prisma/seed/demo.seed.ts b/prisma/seed/demo.seed.ts index 63360f7..c6eb336 100644 --- a/prisma/seed/demo.seed.ts +++ b/prisma/seed/demo.seed.ts @@ -1,9 +1,15 @@ +import { randomUUID } from 'crypto'; import { PrismaClient, UserRole } from '@prisma/client'; +import bcrypt from 'bcryptjs'; export async function seedDemoData(prisma: PrismaClient): Promise { // eslint-disable-next-line no-console console.log(' -> Seeding demo environment data...'); + // Legacy demo row, pre-existing since before 010-identity-auth: a CUSTOMER-role User is + // never a real login identity (customer identity is exclusively SaaS-delegated, see + // specs/010-identity-auth/spec.md Assumptions) — passwordHash is populated only to satisfy + // the column's NOT NULL constraint; this account can never authenticate via /auth/login. await prisma.user.upsert({ where: { email: 'john.doe@example.com' }, update: {}, @@ -11,6 +17,7 @@ export async function seedDemoData(prisma: PrismaClient): Promise { email: 'john.doe@example.com', name: 'John Doe (Demo Customer)', role: UserRole.CUSTOMER, + passwordHash: await bcrypt.hash(randomUUID(), 10), }, }); } diff --git a/prisma/seed/roles.seed.ts b/prisma/seed/roles.seed.ts index 17afed0..2944fdb 100644 --- a/prisma/seed/roles.seed.ts +++ b/prisma/seed/roles.seed.ts @@ -1,4 +1,10 @@ import { PrismaClient, UserRole } from '@prisma/client'; +import bcrypt from 'bcryptjs'; + +// Local/development bootstrap credentials only (specs/010-identity-auth/spec.md Edge Cases) — +// never used for a real deployment, which provisions its own first admin out of band. +const DEV_ADMIN_PASSWORD = 'ChangeMe123!'; +const DEV_AGENT_PASSWORD = 'ChangeMe123!'; export async function seedRoles(prisma: PrismaClient): Promise { // eslint-disable-next-line no-console @@ -11,6 +17,7 @@ export async function seedRoles(prisma: PrismaClient): Promise { email: 'admin@supporthub.internal', name: 'System Admin', role: UserRole.ADMIN, + passwordHash: await bcrypt.hash(DEV_ADMIN_PASSWORD, 10), }, }); @@ -21,6 +28,7 @@ export async function seedRoles(prisma: PrismaClient): Promise { email: 'agent@supporthub.internal', name: 'Default Support Agent', role: UserRole.AGENT, + passwordHash: await bcrypt.hash(DEV_AGENT_PASSWORD, 10), }, }); } diff --git a/specs/010-identity-auth/checklists/requirements.md b/specs/010-identity-auth/checklists/requirements.md index aa37b02..5b0bdbd 100644 --- a/specs/010-identity-auth/checklists/requirements.md +++ b/specs/010-identity-auth/checklists/requirements.md @@ -46,3 +46,23 @@ registration are explicitly out of scope (Assumptions), matching Phase 11's own "security hardening pass" as the more appropriate later home for those. - All items pass; no revision iterations were needed. +- **Implementation-time finding**: making `fastify.authenticate` genuinely reject invalid/missing + tokens (FR-004) had a far larger blast radius than this feature's own tasks.md anticipated. + Dozens of routes across features 002-009 were already declared with `fastify.authenticate` as + a preHandler — safe to write against a no-op stub, but every one of those pre-existing + integration tests had been calling them with no `Authorization` header. Making the check real + broke ~18 integration test files suite-wide, requiring a `tests/helpers/auth.ts` (`loginAs`/ + `authHeader`) and a file-by-file pass adding real bearer tokens, well beyond the mechanical + `requireRole('ADMIN')` rollout research.md had scoped for. A related, recurring bug: several + files already declared a local `const token = issueIntegrationToken(...)` for the unrelated + 002 customer-trust-boundary flow, and naming the new admin/agent token variable `token` in the + same scope produced a `ReferenceError: Cannot access 'token' before initialization` — a genuine + temporal-dead-zone collision, not a tooling bug — fixed by using a non-colliding name + (`authToken`/`adminToken`/`agentToken`) per file. +- A second, subtler implementation-time finding: once admin-setup calls in test `beforeAll` + blocks started actually succeeding (previously they silently 401'd against the no-op stub), + wildcard/global SLA policies created by one integration test file could genuinely match tickets + created by another file running against the same shared throwaway Postgres, leaving orphaned + `sla_run` rows that RESTRICT-violated the FK on cleanup. Fixed by widening the affected files' + `afterAll` cleanup to delete `sla_run` rows by `ticketId` *and* by `policyId`, not just one or + the other. diff --git a/specs/010-identity-auth/research.md b/specs/010-identity-auth/research.md index 1fa0701..41beec7 100644 --- a/specs/010-identity-auth/research.md +++ b/specs/010-identity-auth/research.md @@ -14,6 +14,19 @@ extending `User` — rejected; `User` already has exactly the fields a staff account needs (email, name, role), and doc 06 never defined a competing entity for this. +## Decision: reuse the existing, already-required `JWT_SECRET` env var — don't invent a new one + +- **Decision**: Token signing/verification uses `env.JWT_SECRET` — a `z.string().min(16)`, + no-default, required environment variable already defined in `src/config/env.ts` and already + set in `.env.test`/`.env.example`/`vitest.config.ts` since before this session's spec-driven + rebuild began. This feature adds no new secret env var, only `AUTH_TOKEN_LIFETIME_HOURS` + (a non-secret, defaultable number). +- **Rationale**: Same "finish the scaffold's own intended design" pattern as `User`/ + `JwtPayload` themselves — `JWT_SECRET` was clearly provisioned for exactly this feature and + has simply never been read by any code until now. +- **Alternatives considered**: A feature-specific `AUTH_JWT_SECRET` — considered and rejected + once `JWT_SECRET` was found; would create two secrets doing the identical job. + ## Decision: `jsonwebtoken` for signing/verifying, `bcryptjs` for password hashing - **Decision**: Add `jsonwebtoken` (plain library, no Fastify plugin registration — kept diff --git a/specs/010-identity-auth/tasks.md b/specs/010-identity-auth/tasks.md index 3c5bb75..b6a766f 100644 --- a/specs/010-identity-auth/tasks.md +++ b/specs/010-identity-auth/tasks.md @@ -27,13 +27,13 @@ All file paths are relative to `supporthub-api/` (repo root). ## Phase 1: Setup -- [ ] T001 [P] Add `jsonwebtoken` and `bcryptjs` (plus `@types/jsonwebtoken`, +- [x] T001 [P] Add `jsonwebtoken` and `bcryptjs` (plus `@types/jsonwebtoken`, `@types/bcryptjs`) to `package.json` -- [ ] T002 [P] Add `AUTH_JWT_SECRET` (required, no default — never a committed secret) and +- [x] T002 [P] Add `AUTH_JWT_SECRET` (required, no default — never a committed secret) and `AUTH_TOKEN_LIFETIME_HOURS` (`z.coerce.number().default(4)`) to `src/config/env.ts`, exposed via a new `src/config/auth.ts` (`authConfig.jwtSecret`, `authConfig.tokenLifetimeHours`), matching `orchestrationConfig`'s exact shape -- [ ] T003 [P] Populate `src/modules/identity/auth/` with the full standard shape around its +- [x] T003 [P] Populate `src/modules/identity/auth/` with the full standard shape around its existing files, replacing the email-only `AuthService.validateCredentials`/ `AuthRepository.findByEmail`-only stub content @@ -45,12 +45,12 @@ All file paths are relative to `supporthub-api/` (repo root). **⚠️ CRITICAL**: No user-story stage work can begin until this phase is complete. -- [ ] T004 Add `User.passwordHash` (`String`, required) and `User.active` (`Boolean +- [x] T004 Add `User.passwordHash` (`String`, required) and `User.active` (`Boolean @default(true)`) to `prisma/schema.prisma`, plus `Agent.userId` (`String? @unique`, FK to `User.id` — research.md's additive, not-yet-consumed link) (depends on T001-T003) -- [ ] T005 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`) +- [x] T005 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`) for T004 (depends on T004) -- [ ] T006 Update `prisma/seed/roles.seed.ts` to set a real bcryptjs-hashed password on both +- [x] T006 Update `prisma/seed/roles.seed.ts` to set a real bcryptjs-hashed password on both seeded accounts (`admin@supporthub.internal`, `agent@supporthub.internal`), documenting the plaintext dev password in a comment directly above the hash call (local/dev use only, per spec.md Edge Cases) (depends on T005) @@ -69,29 +69,29 @@ regardless of which reason login failed. ### Tests for User Story 1 -- [ ] T007 [P] [US1] Unit test: given a found user with a matching/non-matching password, and +- [x] T007 [P] [US1] Unit test: given a found user with a matching/non-matching password, and given no user found at all, the login-failure path produces byte-identical response shape/status in the non-matching and no-user cases — in `tests/unit/identity/login-failure-parity.test.ts` -- [ ] T008 [US1] Integration test covering Quickstart Scenario 1 (correct login succeeds with a +- [x] T008 [US1] Integration test covering Quickstart Scenario 1 (correct login succeeds with a token + identity; wrong password and nonexistent email produce the same `401`) against a real Postgres in `tests/integration/identity-auth-flow.test.ts` (depends on T006) ### Implementation for User Story 1 -- [ ] T009 [US1] Add `hashPassword`/`verifyPassword` (bcryptjs) and `signToken`/`verifyToken` +- [x] T009 [US1] Add `hashPassword`/`verifyPassword` (bcryptjs) and `signToken`/`verifyToken` (jsonwebtoken, embedding `sub`/`email`/`role`/`actorType`/`jti`/`iat`/`exp` per data-model.md) in `identity/auth/mapper/` (depends on T002) -- [ ] T010 [US1] Add `AuthRepository.findActiveByEmail` (replacing `findByEmail`) in +- [x] T010 [US1] Add `AuthRepository.findActiveByEmail` (replacing `findByEmail`) in `identity/auth/repository/` (depends on T005) -- [ ] T011 [US1] Add `AuthService.login(email, password)`: looks up the user, compares against +- [x] T011 [US1] Add `AuthService.login(email, password)`: looks up the user, compares against either the found hash or a fixed dummy hash when not found (FR-002's timing/shape parity), returns `{ token, user }` or throws a single, identical `AuthenticationError` for every failure branch — in `identity/auth/service/` (depends on T009, T010) -- [ ] T012 [US1] Replace `POST /auth/login`'s schema (`email` + `password`, replacing the +- [x] T012 [US1] Replace `POST /auth/login`'s schema (`email` + `password`, replacing the email-only schema) and controller in `identity/auth/schema/` + `controller/`, registered from `src/api/routes.ts` (depends on T011) -- [ ] T013 [US1] Run Quickstart Scenario 1 locally and confirm all 3 steps pass +- [x] T013 [US1] Run Quickstart Scenario 1 locally and confirm all 3 steps pass **Checkpoint**: Login works and never leaks account existence through its failure response. @@ -106,13 +106,13 @@ every existing gated route is re-verified. ### Tests for User Story 2 -- [ ] T014 [P] [US2] Unit test for `requireRole`'s matching logic (allowed role passes, wrong +- [x] T014 [P] [US2] Unit test for `requireRole`'s matching logic (allowed role passes, wrong role throws `AuthorizationError`, no `request.user` at all throws) in `tests/unit/identity/require-role.test.ts` -- [ ] T015 [US2] Integration test covering Quickstart Scenario 2 (no header, malformed token, +- [x] T015 [US2] Integration test covering Quickstart Scenario 2 (no header, malformed token, wrong-role token, correct-role token) against a real Postgres/Redis in `tests/integration/identity-auth-flow.test.ts` (depends on T008) -- [ ] T016 [US2] Integration test spot-checking at least one existing admin route per module +- [x] T016 [US2] Integration test spot-checking at least one existing admin route per module (002's product-integration admin route, 004's knowledge admin route, 006's team-creation route, 007's manual-assignment route, 008's SLA-policy route, 009's investigation route) now rejects a missing/invalid session — in `tests/integration/identity-auth-flow.test.ts` @@ -120,24 +120,24 @@ every existing gated route is re-verified. ### Implementation for User Story 2 -- [ ] T017 [US2] Add revocation-denylist helpers (`isTokenRevoked`, `revokeToken`) in +- [x] T017 [US2] Add revocation-denylist helpers (`isTokenRevoked`, `revokeToken`) in `src/infrastructure/cache/`, alongside the existing `hasSeenJti`/`markJtiSeen` (same Redis-key-with-TTL shape, research.md) (depends on T002) -- [ ] T018 [US2] Replace `auth.plugin.ts`'s `authenticate` stub: verify the JWT signature and +- [x] T018 [US2] Replace `auth.plugin.ts`'s `authenticate` stub: verify the JWT signature and expiry, check T017's revocation denylist, and on success set `request.user` (the full `AuthUser`) and `request.reqContext.actorId`/`actorType` — throw `AuthenticationError` on any failure, never pass through as anonymous (depends on T009, T017) -- [ ] T019 [US2] Add `requireRole(...allowedRoles: string[])` preHandler factory (checks +- [x] T019 [US2] Add `requireRole(...allowedRoles: string[])` preHandler factory (checks `request.user?.role`, throws `AuthorizationError` if it doesn't match) in `identity/auth/service/` (or a dedicated `identity/auth/guards/` file), exported from `identity/auth`'s public `index.ts` (depends on T018) -- [ ] T020 [US2] Add `requireRole('ADMIN')` to every existing write/config admin route across +- [x] T020 [US2] Add `requireRole('ADMIN')` to every existing write/config admin route across 002-009 that doesn't already distinguish agent-vs-admin access (product-integration admin, knowledge admin, teams/hierarchy admin, SLA/escalation-policy admin) — read-only routes and ticket-working routes an agent legitimately uses stay `fastify.authenticate`- only (research.md's own scoping: this is a mechanical pass applying an existing judgment, not a new design) (depends on T019) -- [ ] T021 [US2] Run Quickstart Scenario 2 locally and confirm all 4 steps pass +- [x] T021 [US2] Run Quickstart Scenario 2 locally and confirm all 4 steps pass **Checkpoint**: Every P1 user story is complete — a session is real, and it's actually checked everywhere it's supposed to be. This is the feature's MVP. @@ -152,18 +152,18 @@ everywhere it's supposed to be. This is the feature's MVP. ### Tests for User Story 3 -- [ ] T022 [US3] Integration test covering Quickstart Scenario 3 (identity matches login; +- [x] T022 [US3] Integration test covering Quickstart Scenario 3 (identity matches login; deactivating the account rejects a still-unexpired token's use of this endpoint specifically) — in `tests/integration/identity-auth-flow.test.ts` (depends on T021) ### Implementation for User Story 3 -- [ ] T023 [US3] Add `AuthService.getCurrentUser(userId)`: re-fetches the `User` row, throws +- [x] T023 [US3] Add `AuthService.getCurrentUser(userId)`: re-fetches the `User` row, throws `AuthenticationError` if it no longer exists or `active: false` — in `identity/auth/ service/` (depends on T010) -- [ ] T024 [US3] Add `GET /auth/me` route (gated by `fastify.authenticate`) in `identity/auth/ +- [x] T024 [US3] Add `GET /auth/me` route (gated by `fastify.authenticate`) in `identity/auth/ controller/` + `routes/` (depends on T023) -- [ ] T025 [US3] Run Quickstart Scenario 3 locally and confirm both steps pass +- [x] T025 [US3] Run Quickstart Scenario 3 locally and confirm both steps pass **Checkpoint**: A session can be introspected and is re-validated against live account state. @@ -177,20 +177,20 @@ everywhere it's supposed to be. This is the feature's MVP. ### Tests for User Story 4 -- [ ] T026 [US4] Integration test covering Quickstart Scenario 4 (admin creates an account and +- [x] T026 [US4] Integration test covering Quickstart Scenario 4 (admin creates an account and it logs in immediately; non-admin rejected; duplicate email rejected) — in `tests/integration/identity-auth-flow.test.ts` (depends on T021) ### Implementation for User Story 4 -- [ ] T027 [US4] Add `UsersService.create(email, name, role, password)` (resolve-or-409 on +- [x] T027 [US4] Add `UsersService.create(email, name, role, password)` (resolve-or-409 on duplicate email, hashes the password via T009) in `identity/agents/service/` (research.md — account creation lives alongside `identity/agents`'s own roster CRUD, not `identity/auth`) (depends on T009) -- [ ] T028 [US4] Add `POST /admin/users` route (gated by `fastify.authenticate` + +- [x] T028 [US4] Add `POST /admin/users` route (gated by `fastify.authenticate` + `requireRole('ADMIN')`) in `identity/agents/controller/` + `routes/`, registered from `src/api/routes.ts` — response never includes the password or hash (depends on T019, T027) -- [ ] T029 [US4] Run Quickstart Scenario 4 locally and confirm all 4 steps pass +- [x] T029 [US4] Run Quickstart Scenario 4 locally and confirm all 4 steps pass **Checkpoint**: New staff accounts can be provisioned without a manual database write. @@ -204,17 +204,17 @@ everywhere it's supposed to be. This is the feature's MVP. ### Tests for User Story 5 -- [ ] T030 [US5] Integration test covering Quickstart Scenario 5 (logout succeeds; the same +- [x] T030 [US5] Integration test covering Quickstart Scenario 5 (logout succeeds; the same token is rejected immediately afterward) — in `tests/integration/identity-auth-flow.test.ts` (depends on T021) ### Implementation for User Story 5 -- [ ] T031 [US5] Add `AuthService.logout(jti, remainingTtlSeconds)`: calls T017's `revokeToken` +- [x] T031 [US5] Add `AuthService.logout(jti, remainingTtlSeconds)`: calls T017's `revokeToken` — in `identity/auth/service/` (depends on T017) -- [ ] T032 [US5] Add `POST /auth/logout` route (gated by `fastify.authenticate`) in +- [x] T032 [US5] Add `POST /auth/logout` route (gated by `fastify.authenticate`) in `identity/auth/controller/` + `routes/` (depends on T031) -- [ ] T033 [US5] Run Quickstart Scenario 5 locally and confirm both steps pass +- [x] T033 [US5] Run Quickstart Scenario 5 locally and confirm both steps pass **Checkpoint**: All five user stories work independently and together — real login, real gating, self-identity, admin-provisioned accounts, and logout form one coherent auth system. @@ -223,10 +223,10 @@ gating, self-identity, admin-provisioned accounts, and logout form one coherent ## Phase 8: Polish & Cross-Cutting Concerns -- [ ] T034 [P] Update `specs/010-identity-auth/checklists/requirements.md` Notes with any +- [x] T034 [P] Update `specs/010-identity-auth/checklists/requirements.md` Notes with any implementation-time findings -- [ ] T035 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck` -- [ ] T036 Full regression: `npm run test:unit` (scoped to `tests/unit`) to confirm nothing +- [x] T035 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck` +- [x] T036 Full regression: `npm run test:unit` (scoped to `tests/unit`) to confirm nothing broke elsewhere, then the full integration suite (including 002-009's own suites, since T020 adds `requireRole` to their existing routes) against real Docker-provisioned Postgres/Redis diff --git a/src/api/routes.ts b/src/api/routes.ts index 58f9d0d..68d21bd 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -23,9 +23,11 @@ import { rootCausesRoutes } from '@/modules/problem-management/root-causes'; import { solutionsRoutes } from '@/modules/problem-management/solutions'; import { verificationRoutes } from '@/modules/problem-management/verification'; import { resolutionsRoutes } from '@/modules/problem-management/resolutions'; +import { authRoutes } from '@/modules/identity/auth'; export async function registerGlobalRoutes(app: FastifyInstance): Promise { await app.register(healthRoutes); + await app.register(authRoutes); await app.register(metricsRoutes); await app.register(productsRoutes); await app.register(inboundRequestRoutes); diff --git a/src/common/types/auth.types.ts b/src/common/types/auth.types.ts index d87018a..2403809 100644 --- a/src/common/types/auth.types.ts +++ b/src/common/types/auth.types.ts @@ -12,6 +12,7 @@ export interface JwtPayload { email: string; role: string; actorType: ActorType; + jti: string; // revocation-denylist key — see specs/010-identity-auth/research.md iat?: number; exp?: number; } diff --git a/src/config/auth.ts b/src/config/auth.ts new file mode 100644 index 0000000..c48c512 --- /dev/null +++ b/src/config/auth.ts @@ -0,0 +1,6 @@ +import { env } from './env'; + +export const authConfig = { + jwtSecret: env.JWT_SECRET, + tokenLifetimeHours: env.AUTH_TOKEN_LIFETIME_HOURS, +}; diff --git a/src/config/env.ts b/src/config/env.ts index a8bd9be..386d03c 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -60,6 +60,11 @@ const envSchema = z.object({ // explicit customer confirmation before the auto-close sweep resolves it — see // specs/009-problem-resolution/research.md "auto-close waiting period". RESOLUTION_AUTO_CLOSE_WAITING_HOURS: z.coerce.number().default(72), + + // Identity and Authentication (010) — token lifetime; signing itself reuses the existing, + // already-required JWT_SECRET above (defined since the original scaffold, never consumed + // until now) — see specs/010-identity-auth/research.md. + AUTH_TOKEN_LIFETIME_HOURS: z.coerce.number().default(4), }); export type EnvConfig = z.infer; diff --git a/src/config/index.ts b/src/config/index.ts index 622b412..015f344 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -6,3 +6,4 @@ export * from './storage'; export * from './ai'; export * from './orchestration'; export * from './problem-resolution'; +export * from './auth'; diff --git a/src/infrastructure/cache/auth-revocation.ts b/src/infrastructure/cache/auth-revocation.ts new file mode 100644 index 0000000..32c5b8e --- /dev/null +++ b/src/infrastructure/cache/auth-revocation.ts @@ -0,0 +1,17 @@ +import { cacheService } from './cache.service'; + +const REVOKED_KEY_PREFIX = 'auth:revoked:'; + +/** + * Explicit-logout revocation for staff session tokens (specs/010-identity-auth/research.md + * "Redis-backed revocation denylist, reusing 002's own jti-tracking mechanism"). A jti is + * denylisted only until its own token would have expired anyway, so the set never grows + * unbounded — the same shape as replay-guard.ts's hasSeenJti/markJtiSeen. + */ +export async function isTokenRevoked(jti: string): Promise { + return cacheService.exists(`${REVOKED_KEY_PREFIX}${jti}`); +} + +export async function revokeToken(jti: string, ttlSeconds: number): Promise { + await cacheService.set(`${REVOKED_KEY_PREFIX}${jti}`, '1', ttlSeconds); +} diff --git a/src/infrastructure/cache/index.ts b/src/infrastructure/cache/index.ts index 153f5a9..f35cd75 100644 --- a/src/infrastructure/cache/index.ts +++ b/src/infrastructure/cache/index.ts @@ -2,3 +2,4 @@ export * from './redis.client'; export * from './cache.service'; export * from './replay-guard'; export * from './rate-limiter'; +export * from './auth-revocation'; diff --git a/src/jobs/sla/index.ts b/src/jobs/sla/index.ts index c77e62d..c42e1c7 100644 --- a/src/jobs/sla/index.ts +++ b/src/jobs/sla/index.ts @@ -19,7 +19,12 @@ export function registerSlaWorker(): void { void queueManager.getQueue(QueueName.SLA).add( 'detect-breaches', - { jobId: 'detect-breaches', type: 'detect-breaches', payload: {}, createdAt: new Date().toISOString() }, + { + jobId: 'detect-breaches', + type: 'detect-breaches', + payload: {}, + createdAt: new Date().toISOString(), + }, { repeat: { every: BREACH_DETECTION_INTERVAL_MS } }, ); } diff --git a/src/modules/ai-support/knowledge/routes/knowledge.routes.ts b/src/modules/ai-support/knowledge/routes/knowledge.routes.ts index 5f7f0c6..0f24c0e 100644 --- a/src/modules/ai-support/knowledge/routes/knowledge.routes.ts +++ b/src/modules/ai-support/knowledge/routes/knowledge.routes.ts @@ -1,35 +1,38 @@ import { FastifyInstance } from 'fastify'; +import { requireRole } from '@/modules/identity/auth'; import { knowledgeController, errorCodesController, runbooksController } from '../controller'; /** - * Admin routes gated by fastify.authenticate (research.md — known limitation inherited from - * 002/003). /knowledge/retrieve is intentionally NOT gated — it's a read path the future - * AI-support feature will call, not an admin surface (research.md "Admin endpoint - * authentication"). + * Admin write routes gated by fastify.authenticate + requireRole('ADMIN'), now real + * (010-identity-auth). Reads stay agent-usable (fastify.authenticate only). + * /knowledge/retrieve is intentionally NOT gated — it's a read path the future AI-support + * feature will call, not an admin surface (research.md "Admin endpoint authentication"). */ export async function knowledgeRoutes(fastify: FastifyInstance): Promise { fastify.post( '/admin/products/:externalProductId/knowledge', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => knowledgeController.create(req, reply), ); fastify.patch( '/admin/knowledge/:code/publish', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => knowledgeController.publish(req, reply), ); fastify.patch( '/admin/knowledge/:code/unpublish', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => knowledgeController.unpublish(req, reply), ); fastify.patch( '/admin/knowledge/:code/validate', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => knowledgeController.validate(req, reply), ); - fastify.put('/admin/knowledge/:code', { preHandler: fastify.authenticate }, (req, reply) => - knowledgeController.edit(req, reply), + fastify.put( + '/admin/knowledge/:code', + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, + (req, reply) => knowledgeController.edit(req, reply), ); fastify.get( '/admin/knowledge/:code/versions', @@ -39,12 +42,12 @@ export async function knowledgeRoutes(fastify: FastifyInstance): Promise { fastify.post( '/admin/products/:externalProductId/error-codes', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => errorCodesController.createErrorCode(req, reply), ); fastify.post( '/admin/products/:externalProductId/known-issues', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => errorCodesController.createKnownIssue(req, reply), ); fastify.get( @@ -55,7 +58,7 @@ export async function knowledgeRoutes(fastify: FastifyInstance): Promise { fastify.post( '/admin/products/:externalProductId/runbooks', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => runbooksController.create(req, reply), ); fastify.get( @@ -65,12 +68,12 @@ export async function knowledgeRoutes(fastify: FastifyInstance): Promise { ); fastify.put( '/admin/products/:externalProductId/runbooks/:key', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => runbooksController.edit(req, reply), ); fastify.patch( '/admin/products/:externalProductId/runbooks/:key/deactivate', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => runbooksController.deactivate(req, reply), ); diff --git a/src/modules/ai-support/sessions/routes/sessions.routes.ts b/src/modules/ai-support/sessions/routes/sessions.routes.ts index 398592c..b9279f9 100644 --- a/src/modules/ai-support/sessions/routes/sessions.routes.ts +++ b/src/modules/ai-support/sessions/routes/sessions.routes.ts @@ -1,16 +1,17 @@ import { FastifyInstance } from 'fastify'; +import { requireRole } from '@/modules/identity/auth'; import { confidencePolicyController, sessionController } from '../controller'; /** - * contracts/ai-support-contract.md: admin confidence-policy routes gated by - * fastify.authenticate (known limitation inherited from 002/003/004). Session-turn routes are - * not admin routes — called by the ticket-owning caller, same as 003-ticketing's + * contracts/ai-support-contract.md: the admin confidence-policy write route is gated by + * fastify.authenticate + requireRole('ADMIN'), now real (010-identity-auth). Session-turn + * routes are not admin routes — called by the ticket-owning caller, same as 003-ticketing's * POST/GET .../messages, and carry no additional gate of their own in this feature. */ export async function sessionsRoutes(fastify: FastifyInstance): Promise { fastify.put( '/admin/products/:externalProductId/ai-policy', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => confidencePolicyController.upsert(req, reply), ); fastify.get( diff --git a/src/modules/catalog/products/routes/product-integrations-admin.routes.ts b/src/modules/catalog/products/routes/product-integrations-admin.routes.ts index 8f6fd8c..0ac7453 100644 --- a/src/modules/catalog/products/routes/product-integrations-admin.routes.ts +++ b/src/modules/catalog/products/routes/product-integrations-admin.routes.ts @@ -1,34 +1,35 @@ import { FastifyInstance } from 'fastify'; +import { requireRole } from '@/modules/identity/auth'; import { productIntegrationsController } from '../controller'; /** * Admin lifecycle endpoints for ProductIntegration (register/rotate/revoke/status/audit-trail). - * Gated by the existing human/admin JWT plugin (fastify.authenticate) — see - * specs/002-saas-integration/contracts/inbound-request-contract.md "Admin: Integration Lifecycle - * Endpoints" for the known limitation that this decorator doesn't perform real verification yet. + * Gated by fastify.authenticate + requireRole('ADMIN') — real as of 010-identity-auth (see + * specs/002-saas-integration/contracts/inbound-request-contract.md for the now-resolved known + * limitation this decorator previously didn't perform real verification). */ export async function productIntegrationsAdminRoutes(fastify: FastifyInstance): Promise { fastify.post( '/admin/products/:externalProductId/integration', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => productIntegrationsController.register(req, reply), ); fastify.post( '/admin/integrations/:integrationId/rotate', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => productIntegrationsController.rotate(req, reply), ); fastify.post( '/admin/integrations/:integrationId/revoke', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => productIntegrationsController.revoke(req, reply), ); fastify.patch( '/admin/integrations/:integrationId/status', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => productIntegrationsController.updateStatus(req, reply), ); diff --git a/src/modules/identity/agents/controller/agents.controller.ts b/src/modules/identity/agents/controller/agents.controller.ts index ab86323..4cb5c84 100644 --- a/src/modules/identity/agents/controller/agents.controller.ts +++ b/src/modules/identity/agents/controller/agents.controller.ts @@ -6,6 +6,8 @@ import { AgentSkillsService, agentAvailabilityService, AgentAvailabilityService, + usersService, + UsersService, } from '../service'; import { createAgentSchema, @@ -13,6 +15,7 @@ import { listAgentsQuerySchema, upsertAgentSkillSchema, upsertAgentAvailabilitySchema, + createUserSchema, } from '../schema'; export class AgentsController { @@ -20,6 +23,7 @@ export class AgentsController { private readonly service: AgentsService = agentsService, private readonly skills: AgentSkillsService = agentSkillsService, private readonly availability: AgentAvailabilityService = agentAvailabilityService, + private readonly users: UsersService = usersService, ) {} async create(request: FastifyRequest, reply: FastifyReply) { @@ -73,6 +77,13 @@ export class AgentsController { const record = await this.availability.getForAgent(agentId); return reply.status(200).send({ success: true, data: record, meta: null }); } + + /** 010-identity-auth User Story 4: admin-only account creation. */ + async createUser(request: FastifyRequest, reply: FastifyReply) { + const body = createUserSchema.parse(request.body); + const user = await this.users.create(body); + return reply.status(201).send({ success: true, data: user, meta: null }); + } } export const agentsController = new AgentsController(); diff --git a/src/modules/identity/agents/repository/index.ts b/src/modules/identity/agents/repository/index.ts index 904e07f..7ab0d93 100644 --- a/src/modules/identity/agents/repository/index.ts +++ b/src/modules/identity/agents/repository/index.ts @@ -1,3 +1,4 @@ export * from './agents.repository'; export * from './agent-skills.repository'; export * from './agent-availability.repository'; +export * from './users.repository'; diff --git a/src/modules/identity/agents/repository/users.repository.ts b/src/modules/identity/agents/repository/users.repository.ts new file mode 100644 index 0000000..6c21074 --- /dev/null +++ b/src/modules/identity/agents/repository/users.repository.ts @@ -0,0 +1,23 @@ +import { User } from '@prisma/client'; +import { prismaClient } from '@/infrastructure/database'; + +export interface CreateUserData { + email: string; + name: string; + role: 'ADMIN' | 'AGENT'; + passwordHash: string; +} + +export class UsersRepository { + constructor(private readonly prisma = prismaClient) {} + + async findByEmail(email: string): Promise { + return this.prisma.user.findUnique({ where: { email } }); + } + + async create(data: CreateUserData): Promise { + return this.prisma.user.create({ data }); + } +} + +export const usersRepository = new UsersRepository(); diff --git a/src/modules/identity/agents/routes/agents.routes.ts b/src/modules/identity/agents/routes/agents.routes.ts index 9b69284..5e11d69 100644 --- a/src/modules/identity/agents/routes/agents.routes.ts +++ b/src/modules/identity/agents/routes/agents.routes.ts @@ -1,9 +1,17 @@ import { FastifyInstance } from 'fastify'; +import { requireRole } from '@/modules/identity/auth'; import { agentsController } from '../controller'; -/** Admin routes gated by fastify.authenticate — known limitation inherited from 002/003/004/005 - * (research.md "Admin endpoint authentication"). */ +/** Admin routes gated by fastify.authenticate — real as of 010-identity-auth (previously a + * no-op stub, per that feature's own research.md). `POST /admin/users` additionally requires + * the ADMIN role (010's own User Story 4) since account creation is more sensitive than + * agent-roster management. */ export async function agentsRoutes(fastify: FastifyInstance): Promise { + fastify.post( + '/admin/users', + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, + (req, reply) => agentsController.createUser(req, reply), + ); fastify.post('/admin/teams/:teamId/agents', { preHandler: fastify.authenticate }, (req, reply) => agentsController.create(req, reply), ); diff --git a/src/modules/identity/agents/schema/index.ts b/src/modules/identity/agents/schema/index.ts index a93bceb..7fedc24 100644 --- a/src/modules/identity/agents/schema/index.ts +++ b/src/modules/identity/agents/schema/index.ts @@ -1,3 +1,4 @@ export * from './agents.schema'; export * from './agent-skills.schema'; export * from './agent-availability.schema'; +export * from './users.schema'; diff --git a/src/modules/identity/agents/schema/users.schema.ts b/src/modules/identity/agents/schema/users.schema.ts new file mode 100644 index 0000000..c5dc20d --- /dev/null +++ b/src/modules/identity/agents/schema/users.schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const createUserSchema = z + .object({ + email: z.string().email(), + name: z.string().min(1), + role: z.enum(['ADMIN', 'AGENT']), + password: z.string().min(1), + }) + .strict(); + +export type CreateUserBody = z.infer; diff --git a/src/modules/identity/agents/service/index.ts b/src/modules/identity/agents/service/index.ts index 150e76c..612ba16 100644 --- a/src/modules/identity/agents/service/index.ts +++ b/src/modules/identity/agents/service/index.ts @@ -1,3 +1,4 @@ export * from './agents.service'; export * from './agent-skills.service'; export * from './agent-availability.service'; +export * from './users.service'; diff --git a/src/modules/identity/agents/service/users.service.ts b/src/modules/identity/agents/service/users.service.ts new file mode 100644 index 0000000..fdc6267 --- /dev/null +++ b/src/modules/identity/agents/service/users.service.ts @@ -0,0 +1,35 @@ +import { User } from '@prisma/client'; +import { ConflictError } from '@/common/errors'; +import { hashPassword } from '@/modules/identity/auth'; +import { usersRepository, UsersRepository } from '../repository'; +import { CreateUserBody } from '../schema'; + +export class UsersService { + constructor(private readonly repo: UsersRepository = usersRepository) {} + + /** FR-008: rejects a duplicate email — never a second account silently sharing one. */ + async create(body: CreateUserBody): Promise> { + const existing = await this.repo.findByEmail(body.email); + if (existing) throw new ConflictError('An account with this email already exists.'); + + const passwordHash = await hashPassword(body.password); + const user = await this.repo.create({ + email: body.email, + name: body.name, + role: body.role, + passwordHash, + }); + + return { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + active: user.active, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; + } +} + +export const usersService = new UsersService(); diff --git a/src/modules/identity/auth/controller/auth.controller.ts b/src/modules/identity/auth/controller/auth.controller.ts index b0469e1..3c25e52 100644 --- a/src/modules/identity/auth/controller/auth.controller.ts +++ b/src/modules/identity/auth/controller/auth.controller.ts @@ -1,17 +1,33 @@ import { FastifyReply, FastifyRequest } from 'fastify'; +import { AuthenticationError } from '@/common/errors'; import { authService, AuthService } from '../service'; -import { AuthCredentialsInput } from '../types'; +import { loginSchema } from '../schema'; + +function bearerToken(request: FastifyRequest): string { + const header = request.headers.authorization; + return header?.startsWith('Bearer ') ? header.slice('Bearer '.length) : ''; +} export class AuthController { constructor(private readonly service: AuthService = authService) {} - async handleLogin(request: FastifyRequest<{ Body: AuthCredentialsInput }>, reply: FastifyReply) { - const user = await this.service.validateCredentials(request.body); - return reply.status(200).send({ - success: true, - data: user, - meta: null, - }); + async handleLogin(request: FastifyRequest, reply: FastifyReply) { + const body = loginSchema.parse(request.body); + const result = await this.service.login(body); + return reply.status(200).send({ success: true, data: result, meta: null }); + } + + async getCurrentUser(request: FastifyRequest, reply: FastifyReply) { + // Unreachable in practice: this route is only ever registered behind fastify.authenticate, + // which always sets request.user on success. + if (!request.user) throw new AuthenticationError('Session is no longer valid.'); + const user = await this.service.getCurrentUser(request.user.id); + return reply.status(200).send({ success: true, data: user, meta: null }); + } + + async handleLogout(request: FastifyRequest, reply: FastifyReply) { + await this.service.logout(bearerToken(request)); + return reply.status(200).send({ success: true, data: { loggedOut: true }, meta: null }); } } diff --git a/src/modules/identity/auth/index.ts b/src/modules/identity/auth/index.ts index fef740a..28dfdc1 100644 --- a/src/modules/identity/auth/index.ts +++ b/src/modules/identity/auth/index.ts @@ -1,3 +1,7 @@ export { authRoutes } from './routes'; export { AuthService, authService } from './service'; -export type { AuthCredentialsInput } from './types'; +export { requireRole } from './service'; +export type { LoginBody } from './schema'; +export type { LoginResult } from './service'; +export { hashPassword, verifyPassword, signToken, verifyToken, toAuthUser } from './mapper'; +export { AUTH_CONSTANTS } from './constants'; diff --git a/src/modules/identity/auth/mapper/auth.mapper.ts b/src/modules/identity/auth/mapper/auth.mapper.ts index 05c4df1..6c7b620 100644 --- a/src/modules/identity/auth/mapper/auth.mapper.ts +++ b/src/modules/identity/auth/mapper/auth.mapper.ts @@ -1,5 +1,52 @@ -export class AuthMapper { - static toResponse(user: Record): Record { - return { ...user }; - } +import { randomUUID } from 'crypto'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import { AuthUser, JwtPayload } from '@/common/types'; +import { ActorType } from '@/common/enums'; +import { authConfig } from '@/config'; + +const SALT_ROUNDS = 10; + +// research.md "byte-identical failure response": compared against when no user is found at +// all, so a login's timing/shape never reveals whether the email itself was valid. +const DUMMY_HASH = bcrypt.hashSync('not-a-real-password', SALT_ROUNDS); + +export async function hashPassword(password: string): Promise { + return bcrypt.hash(password, SALT_ROUNDS); +} + +export async function verifyPassword(password: string, hash: string | null): Promise { + return bcrypt.compare(password, hash ?? DUMMY_HASH); +} + +export function signToken(user: { id: string; email: string; role: string }): { + token: string; + jti: string; + expiresAt: Date; +} { + const jti = randomUUID(); + const expiresInSeconds = authConfig.tokenLifetimeHours * 3600; + const payload: Omit = { + sub: user.id, + email: user.email, + role: user.role, + actorType: ActorType.USER, + jti, + }; + + const token = jwt.sign(payload, authConfig.jwtSecret, { expiresIn: expiresInSeconds }); + return { token, jti, expiresAt: new Date(Date.now() + expiresInSeconds * 1000) }; +} + +export function verifyToken(token: string): JwtPayload { + return jwt.verify(token, authConfig.jwtSecret) as JwtPayload; +} + +export function toAuthUser(payload: JwtPayload): AuthUser { + return { + id: payload.sub, + email: payload.email, + role: payload.role, + actorType: payload.actorType, + }; } diff --git a/src/modules/identity/auth/repository/auth.repository.ts b/src/modules/identity/auth/repository/auth.repository.ts index 8020c84..1f31e40 100644 --- a/src/modules/identity/auth/repository/auth.repository.ts +++ b/src/modules/identity/auth/repository/auth.repository.ts @@ -1,12 +1,18 @@ +import { User } from '@prisma/client'; import { prismaClient } from '@/infrastructure/database'; export class AuthRepository { constructor(private readonly prisma = prismaClient) {} - async findByEmail(email: string): Promise { - return this.prisma.user.findUnique({ - where: { email }, - }); + async findByEmail(email: string): Promise { + return this.prisma.user.findUnique({ where: { email } }); + } + + /** FR-002/data-model.md: only an active account can authenticate or stay authenticated. */ + async findActiveById(id: string): Promise { + const user = await this.prisma.user.findUnique({ where: { id } }); + if (!user || !user.active) return null; + return user; } } diff --git a/src/modules/identity/auth/routes/auth.routes.ts b/src/modules/identity/auth/routes/auth.routes.ts index f8c0c30..4943aa2 100644 --- a/src/modules/identity/auth/routes/auth.routes.ts +++ b/src/modules/identity/auth/routes/auth.routes.ts @@ -1,9 +1,14 @@ -import { FastifyInstance, FastifyRequest } from 'fastify'; +import { FastifyInstance } from 'fastify'; import { authController } from '../controller'; -import { AuthCredentialsInput } from '../types'; export async function authRoutes(fastify: FastifyInstance): Promise { - fastify.post('/auth/login', (req: FastifyRequest<{ Body: AuthCredentialsInput }>, reply) => - authController.handleLogin(req, reply), + fastify.post('/auth/login', (req, reply) => authController.handleLogin(req, reply)); + + fastify.get('/auth/me', { preHandler: fastify.authenticate }, (req, reply) => + authController.getCurrentUser(req, reply), + ); + + fastify.post('/auth/logout', { preHandler: fastify.authenticate }, (req, reply) => + authController.handleLogout(req, reply), ); } diff --git a/src/modules/identity/auth/schema/auth.schema.ts b/src/modules/identity/auth/schema/auth.schema.ts index 66f6442..2b82ccb 100644 --- a/src/modules/identity/auth/schema/auth.schema.ts +++ b/src/modules/identity/auth/schema/auth.schema.ts @@ -1,5 +1,10 @@ import { z } from 'zod'; -export const authCredentialsSchema = z.object({ - email: z.string().email(), -}); +export const loginSchema = z + .object({ + email: z.string().email(), + password: z.string().min(1), + }) + .strict(); + +export type LoginBody = z.infer; diff --git a/src/modules/identity/auth/service/auth.service.ts b/src/modules/identity/auth/service/auth.service.ts index 6044f24..8a12044 100644 --- a/src/modules/identity/auth/service/auth.service.ts +++ b/src/modules/identity/auth/service/auth.service.ts @@ -1,11 +1,50 @@ +import { User } from '@prisma/client'; +import { AuthenticationError } from '@/common/errors'; +import { revokeToken } from '@/infrastructure/cache'; import { authRepository, AuthRepository } from '../repository'; -import { AuthCredentialsInput } from '../types'; +import { verifyPassword, signToken, verifyToken } from '../mapper'; +import { LoginBody } from '../schema'; + +export interface LoginResult { + token: string; + user: { id: string; email: string; name: string; role: string }; +} + +function toPublicUser(user: User): LoginResult['user'] { + return { id: user.id, email: user.email, name: user.name, role: user.role }; +} export class AuthService { constructor(private readonly repo: AuthRepository = authRepository) {} - async validateCredentials(input: AuthCredentialsInput): Promise { - return this.repo.findByEmail(input.email); + /** + * FR-002/SC-003: every failure branch (no such email, inactive account, wrong password) + * throws the identical AuthenticationError — bcrypt.compare always runs exactly once, + * against a fixed dummy hash when no user is found, so timing never leaks which branch fired. + */ + async login(body: LoginBody): Promise { + const user = await this.repo.findByEmail(body.email); + const passwordMatches = await verifyPassword(body.password, user?.passwordHash ?? null); + + if (!user || !user.active || !passwordMatches) { + throw new AuthenticationError('Invalid email or password.'); + } + + const { token } = signToken(user); + return { token, user: toPublicUser(user) }; + } + + /** User Story 3: re-validated against current account state, not just the token's claims. */ + async getCurrentUser(userId: string): Promise { + const user = await this.repo.findActiveById(userId); + if (!user) throw new AuthenticationError('Session is no longer valid.'); + return toPublicUser(user); + } + + async logout(token: string): Promise { + const payload = verifyToken(token); + const remainingSeconds = Math.max(1, (payload.exp ?? 0) - Math.floor(Date.now() / 1000)); + await revokeToken(payload.jti, remainingSeconds); } } diff --git a/src/modules/identity/auth/service/index.ts b/src/modules/identity/auth/service/index.ts index 2a719d1..9284f5c 100644 --- a/src/modules/identity/auth/service/index.ts +++ b/src/modules/identity/auth/service/index.ts @@ -1 +1,2 @@ export * from './auth.service'; +export * from './require-role'; diff --git a/src/modules/identity/auth/service/require-role.ts b/src/modules/identity/auth/service/require-role.ts new file mode 100644 index 0000000..1d20dd3 --- /dev/null +++ b/src/modules/identity/auth/service/require-role.ts @@ -0,0 +1,15 @@ +import { FastifyReply, FastifyRequest } from 'fastify'; +import { AuthorizationError } from '@/common/errors'; + +/** + * research.md "Role-gating via a requireRole(...roles) preHandler factory": composes with + * fastify.authenticate as a second preHandler — `{ preHandler: [fastify.authenticate, + * requireRole('ADMIN')] }` — rather than a fixed decorator per role. + */ +export function requireRole(...allowedRoles: string[]) { + return async (request: FastifyRequest, _reply: FastifyReply): Promise => { + if (!request.user || !allowedRoles.includes(request.user.role)) { + throw new AuthorizationError('You do not have permission to perform this action.'); + } + }; +} diff --git a/src/modules/identity/auth/types/auth.types.ts b/src/modules/identity/auth/types/auth.types.ts index 7689f73..cb0ff5c 100644 --- a/src/modules/identity/auth/types/auth.types.ts +++ b/src/modules/identity/auth/types/auth.types.ts @@ -1,3 +1 @@ -export interface AuthCredentialsInput { - email: string; -} +export {}; diff --git a/src/modules/identity/auth/types/index.ts b/src/modules/identity/auth/types/index.ts index 5999493..cf99bdd 100644 --- a/src/modules/identity/auth/types/index.ts +++ b/src/modules/identity/auth/types/index.ts @@ -1 +1,2 @@ -export * from './auth.types'; +export type { LoginBody } from '../schema'; +export type { LoginResult } from '../service'; diff --git a/src/modules/identity/teams/routes/teams.routes.ts b/src/modules/identity/teams/routes/teams.routes.ts index e22d1fb..9579390 100644 --- a/src/modules/identity/teams/routes/teams.routes.ts +++ b/src/modules/identity/teams/routes/teams.routes.ts @@ -1,14 +1,19 @@ import { FastifyInstance } from 'fastify'; +import { requireRole } from '@/modules/identity/auth'; import { teamsController } from '../controller'; -/** Admin routes gated by fastify.authenticate — known limitation inherited from 002/003/004/005 - * (research.md "Admin endpoint authentication"). */ +/** Admin routes gated by fastify.authenticate, now real (010-identity-auth) — writes + * additionally require the ADMIN role; reads stay agent-usable. */ export async function teamsRoutes(fastify: FastifyInstance): Promise { - fastify.post('/admin/teams', { preHandler: fastify.authenticate }, (req, reply) => - teamsController.create(req, reply), + fastify.post( + '/admin/teams', + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, + (req, reply) => teamsController.create(req, reply), ); - fastify.patch('/admin/teams/:teamId', { preHandler: fastify.authenticate }, (req, reply) => - teamsController.update(req, reply), + fastify.patch( + '/admin/teams/:teamId', + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, + (req, reply) => teamsController.update(req, reply), ); fastify.get('/admin/teams/:teamId', { preHandler: fastify.authenticate }, (req, reply) => teamsController.getById(req, reply), diff --git a/src/modules/orchestration/assignments/engine/assignment.engine.ts b/src/modules/orchestration/assignments/engine/assignment.engine.ts index 92959f2..a11c639 100644 --- a/src/modules/orchestration/assignments/engine/assignment.engine.ts +++ b/src/modules/orchestration/assignments/engine/assignment.engine.ts @@ -152,7 +152,13 @@ export class AssignmentEngine { } return { - assignment: await this.persistAndTransition(ticketId, selected.id, strategyName, actor, reason), + assignment: await this.persistAndTransition( + ticketId, + selected.id, + strategyName, + actor, + reason, + ), strategy: strategyName, }; } diff --git a/src/modules/orchestration/escalation/routes/escalation.routes.ts b/src/modules/orchestration/escalation/routes/escalation.routes.ts index 56dd0a1..e0eff0b 100644 --- a/src/modules/orchestration/escalation/routes/escalation.routes.ts +++ b/src/modules/orchestration/escalation/routes/escalation.routes.ts @@ -1,37 +1,35 @@ import { FastifyInstance } from 'fastify'; +import { requireRole } from '@/modules/identity/auth'; import { escalationController } from '../controller'; -/** contracts/sla-escalation-contract.md: every route gated by fastify.authenticate (known - * limitation inherited from 002-007). */ +/** contracts/sla-escalation-contract.md: policy/rule config gated by fastify.authenticate + + * requireRole('ADMIN'), now real (010-identity-auth); manual escalation stays agent-usable + * (fastify.authenticate only) — it's a ticket-working action, not admin configuration. */ export async function escalationRoutes(fastify: FastifyInstance): Promise { fastify.post( '/admin/escalation-policies', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => escalationController.createPolicy(req, reply), ); - fastify.get( - '/admin/escalation-policies', - { preHandler: fastify.authenticate }, - (req, reply) => escalationController.listPolicies(req, reply), + fastify.get('/admin/escalation-policies', { preHandler: fastify.authenticate }, (req, reply) => + escalationController.listPolicies(req, reply), ); fastify.post( '/admin/escalation-policies/:id/rules', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => escalationController.createRule(req, reply), ); fastify.patch( '/admin/escalation-policies/:id/rules/:ruleId', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => escalationController.updateRule(req, reply), ); fastify.delete( '/admin/escalation-policies/:id/rules/:ruleId', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => escalationController.deleteRule(req, reply), ); - fastify.post( - '/tickets/:ticketId/escalate', - { preHandler: fastify.authenticate }, - (req, reply) => escalationController.escalateManually(req, reply), + fastify.post('/tickets/:ticketId/escalate', { preHandler: fastify.authenticate }, (req, reply) => + escalationController.escalateManually(req, reply), ); } diff --git a/src/modules/orchestration/escalation/service/escalation.service.ts b/src/modules/orchestration/escalation/service/escalation.service.ts index 6bb154f..38c696a 100644 --- a/src/modules/orchestration/escalation/service/escalation.service.ts +++ b/src/modules/orchestration/escalation/service/escalation.service.ts @@ -15,7 +15,11 @@ import { escalationEventRepository, EscalationEventRepository, } from '../repository'; -import { CreateEscalationPolicyBody, CreateEscalationRuleBody, UpdateEscalationRuleBody } from '../schema'; +import { + CreateEscalationPolicyBody, + CreateEscalationRuleBody, + UpdateEscalationRuleBody, +} from '../schema'; export class EscalationService { constructor( @@ -32,14 +36,23 @@ export class EscalationService { * active rule. Records nothing when no policy or no rule matches — the breach itself is * already durably recorded by the caller (SLARun.breachedAt/firstResponseBreachedAt). */ - async handleBreach(ticketId: string, triggerType: 'resolution_breach' | 'first_response_breach'): Promise { + async handleBreach( + ticketId: string, + triggerType: 'resolution_breach' | 'first_response_breach', + ): Promise { const ticket = await ticketsService.getById(ticketId); const policy = await this.policies.findApplicable(ticket.productId); if (!policy) return; const matchingRules = await this.rules.findActiveRules(policy.id, triggerType); for (const rule of matchingRules) { - await this.fire(ticketId, rule.id, rule.targetNodeId, 'system', `SLA ${triggerType} — rule ${rule.id}`); + await this.fire( + ticketId, + rule.id, + rule.targetNodeId, + 'system', + `SLA ${triggerType} — rule ${rule.id}`, + ); } } diff --git a/src/modules/orchestration/hierarchy/routes/hierarchy.routes.ts b/src/modules/orchestration/hierarchy/routes/hierarchy.routes.ts index 1b1940a..1381873 100644 --- a/src/modules/orchestration/hierarchy/routes/hierarchy.routes.ts +++ b/src/modules/orchestration/hierarchy/routes/hierarchy.routes.ts @@ -1,29 +1,32 @@ import { FastifyInstance } from 'fastify'; +import { requireRole } from '@/modules/identity/auth'; import { hierarchyController, capabilityLookupController } from '../controller'; /** - * contracts/support-org-contract.md: admin hierarchy-node routes gated by fastify.authenticate - * (known limitation inherited from 002/003/004/005). The capability-eligibility lookup is not + * contracts/support-org-contract.md: admin hierarchy-node writes gated by fastify.authenticate + + * requireRole('ADMIN'), now real (010-identity-auth). The capability-eligibility lookup is not * gated — a read path a future orchestration caller will use (research.md "Admin endpoint * authentication"). */ export async function hierarchyRoutes(fastify: FastifyInstance): Promise { - fastify.post('/admin/hierarchy-nodes', { preHandler: fastify.authenticate }, (req, reply) => - hierarchyController.create(req, reply), + fastify.post( + '/admin/hierarchy-nodes', + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, + (req, reply) => hierarchyController.create(req, reply), ); fastify.put( '/admin/hierarchy-nodes/:nodeId', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => hierarchyController.update(req, reply), ); fastify.patch( '/admin/hierarchy-nodes/:nodeId/activate', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => hierarchyController.activate(req, reply), ); fastify.patch( '/admin/hierarchy-nodes/:nodeId/deactivate', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => hierarchyController.deactivate(req, reply), ); fastify.get( diff --git a/src/modules/orchestration/sla/calculators/sla-due-date.calculator.ts b/src/modules/orchestration/sla/calculators/sla-due-date.calculator.ts index 9895939..e67b939 100644 --- a/src/modules/orchestration/sla/calculators/sla-due-date.calculator.ts +++ b/src/modules/orchestration/sla/calculators/sla-due-date.calculator.ts @@ -1,5 +1,8 @@ import { SLAPolicy } from '@prisma/client'; -import { businessCalendarsService, BusinessCalendarsService } from '@/modules/platform/business-calendars'; +import { + businessCalendarsService, + BusinessCalendarsService, +} from '@/modules/platform/business-calendars'; /** * FR-004: replaces the original naive `createdDate + targetHours` stub — every due date is diff --git a/src/modules/orchestration/sla/routes/sla.routes.ts b/src/modules/orchestration/sla/routes/sla.routes.ts index 9cdc556..15501f2 100644 --- a/src/modules/orchestration/sla/routes/sla.routes.ts +++ b/src/modules/orchestration/sla/routes/sla.routes.ts @@ -1,32 +1,30 @@ import { FastifyInstance } from 'fastify'; +import { requireRole } from '@/modules/identity/auth'; import { slaController } from '../controller'; -/** contracts/sla-escalation-contract.md: admin CRUD gated by fastify.authenticate; the read - * route is not (same "read path any caller can use" convention as 003/007). */ +/** contracts/sla-escalation-contract.md: admin CRUD gated by fastify.authenticate + + * requireRole('ADMIN'), now real (010-identity-auth); the read route is not (same "read path + * any caller can use" convention as 003/007). */ export async function slaRoutes(fastify: FastifyInstance): Promise { fastify.post( '/admin/sla-policies', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => slaController.createPolicy(req, reply), ); - fastify.get( - '/admin/sla-policies', - { preHandler: fastify.authenticate }, - (req, reply) => slaController.listPolicies(req, reply), + fastify.get('/admin/sla-policies', { preHandler: fastify.authenticate }, (req, reply) => + slaController.listPolicies(req, reply), ); - fastify.get( - '/admin/sla-policies/:id', - { preHandler: fastify.authenticate }, - (req, reply) => slaController.getPolicy(req, reply), + fastify.get('/admin/sla-policies/:id', { preHandler: fastify.authenticate }, (req, reply) => + slaController.getPolicy(req, reply), ); fastify.patch( '/admin/sla-policies/:id', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => slaController.updatePolicy(req, reply), ); fastify.delete( '/admin/sla-policies/:id', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => slaController.deactivatePolicy(req, reply), ); fastify.get('/tickets/:ticketId/sla-run', (req, reply) => slaController.getRun(req, reply)); diff --git a/src/modules/platform/business-calendars/calculators/business-hours.calculator.ts b/src/modules/platform/business-calendars/calculators/business-hours.calculator.ts index 3836c63..9b856d4 100644 --- a/src/modules/platform/business-calendars/calculators/business-hours.calculator.ts +++ b/src/modules/platform/business-calendars/calculators/business-hours.calculator.ts @@ -99,7 +99,12 @@ export function isWithinWorkingHours( const [startHour, startMinute] = window.start.split(':').map(Number); const [endHour, endMinute] = window.end.split(':').map(Number); - const windowStart = zoned.set({ hour: startHour, minute: startMinute, second: 0, millisecond: 0 }); + const windowStart = zoned.set({ + hour: startHour, + minute: startMinute, + second: 0, + millisecond: 0, + }); const windowEnd = zoned.set({ hour: endHour, minute: endMinute, second: 0, millisecond: 0 }); return zoned >= windowStart && zoned < windowEnd; diff --git a/src/modules/platform/business-calendars/controller/index.ts b/src/modules/platform/business-calendars/controller/index.ts index 4f97e39..8b09582 100644 --- a/src/modules/platform/business-calendars/controller/index.ts +++ b/src/modules/platform/business-calendars/controller/index.ts @@ -1 +1,4 @@ -export { BusinessCalendarsController, businessCalendarsController } from './business-calendars.controller'; +export { + BusinessCalendarsController, + businessCalendarsController, +} from './business-calendars.controller'; diff --git a/src/modules/platform/business-calendars/routes/business-calendars.routes.ts b/src/modules/platform/business-calendars/routes/business-calendars.routes.ts index efc6c85..f81037f 100644 --- a/src/modules/platform/business-calendars/routes/business-calendars.routes.ts +++ b/src/modules/platform/business-calendars/routes/business-calendars.routes.ts @@ -1,37 +1,34 @@ import { FastifyInstance } from 'fastify'; +import { requireRole } from '@/modules/identity/auth'; import { businessCalendarsController } from '../controller'; -/** contracts/sla-escalation-contract.md: every admin route gated by fastify.authenticate (known - * limitation inherited from 002-007). */ +/** contracts/sla-escalation-contract.md: every admin write route gated by fastify.authenticate + + * requireRole('ADMIN'), now real (010-identity-auth). */ export async function businessCalendarsRoutes(fastify: FastifyInstance): Promise { fastify.post( '/admin/business-calendars', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => businessCalendarsController.create(req, reply), ); - fastify.get( - '/admin/business-calendars', - { preHandler: fastify.authenticate }, - (req, reply) => businessCalendarsController.list(req, reply), + fastify.get('/admin/business-calendars', { preHandler: fastify.authenticate }, (req, reply) => + businessCalendarsController.list(req, reply), ); - fastify.get( - '/admin/business-calendars/:id', - { preHandler: fastify.authenticate }, - (req, reply) => businessCalendarsController.getById(req, reply), + fastify.get('/admin/business-calendars/:id', { preHandler: fastify.authenticate }, (req, reply) => + businessCalendarsController.getById(req, reply), ); fastify.patch( '/admin/business-calendars/:id', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => businessCalendarsController.update(req, reply), ); fastify.post( '/admin/business-calendars/:id/holidays', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => businessCalendarsController.addHoliday(req, reply), ); fastify.delete( '/admin/business-calendars/:id/holidays/:holidayId', - { preHandler: fastify.authenticate }, + { preHandler: [fastify.authenticate, requireRole('ADMIN')] }, (req, reply) => businessCalendarsController.removeHoliday(req, reply), ); } diff --git a/src/modules/platform/business-calendars/service/business-calendars.service.ts b/src/modules/platform/business-calendars/service/business-calendars.service.ts index dd0e1fa..cbc1bc8 100644 --- a/src/modules/platform/business-calendars/service/business-calendars.service.ts +++ b/src/modules/platform/business-calendars/service/business-calendars.service.ts @@ -7,8 +7,16 @@ import { holidayRepository, HolidayRepository, } from '../repository'; -import { addBusinessMinutes, isWithinWorkingHours, WorkingHours } from '../calculators/business-hours.calculator'; -import { CreateBusinessCalendarBody, UpdateBusinessCalendarBody, CreateHolidayBody } from '../schema'; +import { + addBusinessMinutes, + isWithinWorkingHours, + WorkingHours, +} from '../calculators/business-hours.calculator'; +import { + CreateBusinessCalendarBody, + UpdateBusinessCalendarBody, + CreateHolidayBody, +} from '../schema'; export class BusinessCalendarsService { constructor( diff --git a/src/modules/problem-management/resolutions/controller/resolutions.controller.ts b/src/modules/problem-management/resolutions/controller/resolutions.controller.ts index 574c0e2..5963478 100644 --- a/src/modules/problem-management/resolutions/controller/resolutions.controller.ts +++ b/src/modules/problem-management/resolutions/controller/resolutions.controller.ts @@ -33,7 +33,9 @@ export class ResolutionsController { } await this.service.confirmByCustomer(ticketId, 'customer'); - return reply.status(200).send({ success: true, data: { ticketId, status: 'RESOLVED' }, meta: null }); + return reply + .status(200) + .send({ success: true, data: { ticketId, status: 'RESOLVED' }, meta: null }); } } diff --git a/src/modules/ticketing/tickets/routes/tickets.routes.ts b/src/modules/ticketing/tickets/routes/tickets.routes.ts index 3c89b98..c82f7a7 100644 --- a/src/modules/ticketing/tickets/routes/tickets.routes.ts +++ b/src/modules/ticketing/tickets/routes/tickets.routes.ts @@ -13,8 +13,10 @@ export async function ticketsRoutes(fastify: FastifyInstance): Promise { // 009-problem-resolution FR-017: agent-facing reopen (fastify.authenticate) and customer- // facing reopen (002's inbound trust boundary, research.md) both funnel through the same // TicketsService.reopen. - fastify.post('/admin/tickets/:ticketId/reopen', { preHandler: fastify.authenticate }, (req, reply) => - ticketsController.reopen(req, reply), + fastify.post( + '/admin/tickets/:ticketId/reopen', + { preHandler: fastify.authenticate }, + (req, reply) => ticketsController.reopen(req, reply), ); fastify.post( '/v1/support/tickets/:ticketId/reopen', diff --git a/src/plugins/auth.plugin.ts b/src/plugins/auth.plugin.ts index c1699df..06921b6 100644 --- a/src/plugins/auth.plugin.ts +++ b/src/plugins/auth.plugin.ts @@ -1,6 +1,10 @@ import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify'; import fp from 'fastify-plugin'; +import jwt from 'jsonwebtoken'; import { AuthUser } from '@/common/types'; +import { AuthenticationError } from '@/common/errors'; +import { isTokenRevoked } from '@/infrastructure/cache'; +import { verifyToken, toAuthUser } from '@/modules/identity/auth'; declare module 'fastify' { interface FastifyRequest { @@ -11,20 +15,46 @@ declare module 'fastify' { } } +/** + * specs/010-identity-auth: replaces the original no-op stub. Verifies the JWT's signature and + * expiry, checks the Redis revocation denylist (research.md), and on success populates both + * request.user and the same request.reqContext.actorId/actorType fields + * authenticateProductIntegration already populates for customer-originated requests — every + * `actorFrom(request)` call site since 007 becomes accurate for real agent/admin actions with + * no changes on its own end. + */ const authPluginCallback: FastifyPluginAsync = async (fastify) => { fastify.decorate( 'authenticate', async (request: FastifyRequest, _reply: FastifyReply): Promise => { const authHeader = request.headers.authorization; - if (!authHeader) { - // Foundation auth: default context or optional pass - return; + const token = authHeader?.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : null; + if (!token) { + throw new AuthenticationError('Missing or malformed Authorization header.'); } - // Stub for JWT verification foundation + + let payload; + try { + payload = verifyToken(token); + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + throw new AuthenticationError('Session has expired.'); + } + throw new AuthenticationError('Invalid session token.'); + } + + if (await isTokenRevoked(payload.jti)) { + throw new AuthenticationError('Session has been revoked.'); + } + + request.user = toAuthUser(payload); + request.reqContext.actorId = payload.sub; + request.reqContext.actorType = payload.actorType; }, ); }; export const authPlugin = fp(authPluginCallback, { name: 'auth-plugin', + dependencies: ['request-context-plugin'], }); diff --git a/tests/concurrency/round-robin.test.ts b/tests/concurrency/round-robin.test.ts index a286325..89298a2 100644 --- a/tests/concurrency/round-robin.test.ts +++ b/tests/concurrency/round-robin.test.ts @@ -18,6 +18,7 @@ describe('ROUND_ROBIN concurrency safety', () => { teamId: 't', name: id, active: true, + userId: null, createdAt: new Date(), updatedAt: new Date(), skills: [], diff --git a/tests/helpers/auth.ts b/tests/helpers/auth.ts new file mode 100644 index 0000000..a1d6a8c --- /dev/null +++ b/tests/helpers/auth.ts @@ -0,0 +1,40 @@ +import { FastifyInstance } from 'fastify'; +import bcrypt from 'bcryptjs'; +import { prismaClient } from '@/infrastructure/database'; + +const TEST_PASSWORD = 'Test-Password-123!'; + +/** + * 010-identity-auth made fastify.authenticate real — every test file calling a route already + * gated by it (across 002-009's own suites) needs a real session now. Rather than depend on + * prisma/seed/roles.seed.ts having already been run against whatever database the suite + * connects to, this upserts its own throwaway admin/agent account directly (idempotent — safe + * to call from many test files' own beforeAll against the same database) and logs in as it. + */ +export async function loginAs( + app: FastifyInstance, + role: 'ADMIN' | 'AGENT' = 'ADMIN', +): Promise { + const email = `test-${role.toLowerCase()}@supporthub.test`; + await prismaClient.user.upsert({ + where: { email }, + update: {}, + create: { + email, + name: `Test ${role}`, + role, + passwordHash: await bcrypt.hash(TEST_PASSWORD, 10), + }, + }); + + const response = await app.inject({ + method: 'POST', + url: '/auth/login', + payload: { email, password: TEST_PASSWORD }, + }); + return response.json().data.token as string; +} + +export function authHeader(token: string): { authorization: string } { + return { authorization: `Bearer ${token}` }; +} diff --git a/tests/integration/ai-confidence-policy.test.ts b/tests/integration/ai-confidence-policy.test.ts index b962ab7..953bb05 100644 --- a/tests/integration/ai-confidence-policy.test.ts +++ b/tests/integration/ai-confidence-policy.test.ts @@ -2,16 +2,19 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { buildApp } from '@/app'; import { prismaClient } from '@/infrastructure/database'; import { FastifyInstance } from 'fastify'; +import { loginAs, authHeader } from '../helpers/auth'; /** Covers specs/005-ai-support/contracts/ai-support-contract.md's confidence-policy admin * surface (FR-005) — no LLM call involved, so this runs unconditionally against a real * Postgres, unlike the AI-diagnosis/reasoning tests in this same directory. */ describe('AI confidence policy — admin config (FR-005)', () => { let app: FastifyInstance; + let token: string; const externalProductId = `TEST_AI_POLICY_PROD_${Date.now()}`; beforeAll(async () => { app = await buildApp(); + token = await loginAs(app, 'ADMIN'); await prismaClient.product.create({ data: { externalProductId, name: 'AI Policy Test Product', status: 'active' }, }); @@ -30,6 +33,7 @@ describe('AI confidence policy — admin config (FR-005)', () => { const response = await app.inject({ method: 'PUT', url: `/admin/products/${externalProductId}/ai-policy`, + headers: authHeader(token), payload: { highThreshold: 0.4, lowThreshold: 0.4, maxClarifyingQuestions: 2 }, }); expect(response.statusCode).toBe(400); @@ -39,6 +43,7 @@ describe('AI confidence policy — admin config (FR-005)', () => { const putResponse = await app.inject({ method: 'PUT', url: `/admin/products/${externalProductId}/ai-policy`, + headers: authHeader(token), payload: { highThreshold: 0.8, lowThreshold: 0.3, maxClarifyingQuestions: 3 }, }); expect(putResponse.statusCode).toBe(200); @@ -47,6 +52,7 @@ describe('AI confidence policy — admin config (FR-005)', () => { const getResponse = await app.inject({ method: 'GET', url: `/admin/products/${externalProductId}/ai-policy`, + headers: authHeader(token), }); expect(getResponse.statusCode).toBe(200); const body = getResponse.json().data; @@ -61,11 +67,13 @@ describe('AI confidence policy — admin config (FR-005)', () => { await app.inject({ method: 'PUT', url: `/admin/products/${externalProductId}/ai-policy`, + headers: authHeader(token), payload: { highThreshold: 0.9, lowThreshold: 0.2, maxClarifyingQuestions: 1 }, }); const getResponse = await app.inject({ method: 'GET', url: `/admin/products/${externalProductId}/ai-policy`, + headers: authHeader(token), }); const configured = getResponse.json().data.configured; expect(configured).toHaveLength(1); @@ -76,6 +84,7 @@ describe('AI confidence policy — admin config (FR-005)', () => { const response = await app.inject({ method: 'PUT', url: `/admin/products/TEST_NEVER_REGISTERED_${Date.now()}/ai-policy`, + headers: authHeader(token), payload: { highThreshold: 0.8, lowThreshold: 0.3, maxClarifyingQuestions: 2 }, }); expect(response.statusCode).toBe(404); diff --git a/tests/integration/identity-auth-flow.test.ts b/tests/integration/identity-auth-flow.test.ts new file mode 100644 index 0000000..115c2ce --- /dev/null +++ b/tests/integration/identity-auth-flow.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { buildApp } from '@/app'; +import { prismaClient } from '@/infrastructure/database'; +import { FastifyInstance } from 'fastify'; +import bcrypt from 'bcryptjs'; + +/** + * Covers specs/010-identity-auth/quickstart.md Scenarios 1-5 against a real Postgres/Redis — + * real login with identical-failure-response parity, real route/role gating (spot-checked + * against one already-shipped admin route per 002-009), self-identity re-validated against live + * account state, admin-provisioned accounts, and logout revocation. + */ +describe('Identity and authentication — full flow (User Stories 1-5)', () => { + let app: FastifyInstance; + const suffix = Date.now(); + const adminEmail = `identity-admin-${suffix}@supporthub.test`; + const agentEmail = `identity-agent-${suffix}@supporthub.test`; + const password = 'Correct-Horse-Battery-Staple-1!'; + const createdUserIds: string[] = []; + + beforeAll(async () => { + app = await buildApp(); + const admin = await prismaClient.user.create({ + data: { + email: adminEmail, + name: 'Identity Test Admin', + role: 'ADMIN', + passwordHash: await bcrypt.hash(password, 10), + }, + }); + createdUserIds.push(admin.id); + const agent = await prismaClient.user.create({ + data: { + email: agentEmail, + name: 'Identity Test Agent', + role: 'AGENT', + passwordHash: await bcrypt.hash(password, 10), + }, + }); + createdUserIds.push(agent.id); + }); + + afterAll(async () => { + await prismaClient.user.deleteMany({ where: { id: { in: createdUserIds } } }); + await app.close(); + }); + + it('Scenario 1: login succeeds with a token + identity; wrong password and unknown email are indistinguishable', async () => { + const success = await app.inject({ + method: 'POST', + url: '/auth/login', + payload: { email: adminEmail, password }, + }); + expect(success.statusCode).toBe(200); + const successBody = success.json(); + expect(typeof successBody.data.token).toBe('string'); + expect(successBody.data.user).toMatchObject({ email: adminEmail, role: 'ADMIN' }); + + const wrongPassword = await app.inject({ + method: 'POST', + url: '/auth/login', + payload: { email: adminEmail, password: 'not-the-password' }, + }); + const unknownEmail = await app.inject({ + method: 'POST', + url: '/auth/login', + payload: { email: `nobody-${suffix}@supporthub.test`, password }, + }); + + expect(wrongPassword.statusCode).toBe(401); + expect(unknownEmail.statusCode).toBe(401); + // requestId is a per-request trace id, expected to differ — everything else (the part that + // could leak which failure branch fired) must be byte-identical. + expect(wrongPassword.json().success).toBe(unknownEmail.json().success); + expect(wrongPassword.json().error).toEqual(unknownEmail.json().error); + }); + + it('Scenario 2: route gating and role enforcement, spot-checked across 002-009 admin routes', async () => { + const adminLogin = await app.inject({ + method: 'POST', + url: '/auth/login', + payload: { email: adminEmail, password }, + }); + const adminToken = adminLogin.json().data.token as string; + + const agentLogin = await app.inject({ + method: 'POST', + url: '/auth/login', + payload: { email: agentEmail, password }, + }); + const agentToken = agentLogin.json().data.token as string; + + const noHeader = await app.inject({ method: 'POST', url: '/admin/teams', payload: {} }); + expect(noHeader.statusCode).toBe(401); + + const malformed = await app.inject({ + method: 'POST', + url: '/admin/teams', + headers: { authorization: 'Bearer not-a-real-token' }, + payload: {}, + }); + expect(malformed.statusCode).toBe(401); + + const wrongRole = await app.inject({ + method: 'POST', + url: '/admin/teams', + headers: { authorization: `Bearer ${agentToken}` }, + payload: { name: `Should Be Rejected ${suffix}` }, + }); + expect(wrongRole.statusCode).toBe(403); + + const correctRole = await app.inject({ + method: 'POST', + url: '/admin/teams', + headers: { authorization: `Bearer ${adminToken}` }, + payload: { name: `Identity Test Team ${suffix}` }, + }); + expect(correctRole.statusCode).toBe(201); + await prismaClient.team.deleteMany({ where: { id: correctRole.json().data.id } }); + + // Cross-module spot-check: one already-shipped admin route per feature, not just this + // feature's own routes, rejects a missing session — proving the real gate protects what the + // no-op stub never did. + const spotChecks = [ + { method: 'PATCH' as const, url: '/admin/integrations/nonexistent-id/status' }, // 002 + { method: 'POST' as const, url: '/admin/products/nonexistent-id/knowledge' }, // 004 + { method: 'PATCH' as const, url: `/admin/hierarchy-nodes/nonexistent-id/activate` }, // 006 + { method: 'POST' as const, url: '/admin/tickets/nonexistent-id/assignment' }, // 007 + { method: 'POST' as const, url: '/admin/sla-policies' }, // 008 + { method: 'POST' as const, url: '/admin/escalation-policies' }, // 008 + { method: 'POST' as const, url: '/admin/problems/nonexistent-id/investigations' }, // 009 + ]; + for (const spotCheck of spotChecks) { + const res = await app.inject({ ...spotCheck, payload: {} }); + expect(res.statusCode, `${spotCheck.method} ${spotCheck.url}`).toBe(401); + } + }); + + it('Scenario 3: self-identity matches login, and is re-validated against live account state', async () => { + const deactivatable = await prismaClient.user.create({ + data: { + email: `identity-deactivate-${suffix}@supporthub.test`, + name: 'Deactivate Me', + role: 'AGENT', + passwordHash: await bcrypt.hash(password, 10), + }, + }); + createdUserIds.push(deactivatable.id); + + const login = await app.inject({ + method: 'POST', + url: '/auth/login', + payload: { email: deactivatable.email, password }, + }); + const token = login.json().data.token as string; + + const me = await app.inject({ + method: 'GET', + url: '/auth/me', + headers: { authorization: `Bearer ${token}` }, + }); + expect(me.statusCode).toBe(200); + expect(me.json().data).toEqual(login.json().data.user); + + await prismaClient.user.update({ where: { id: deactivatable.id }, data: { active: false } }); + + const meAfterDeactivation = await app.inject({ + method: 'GET', + url: '/auth/me', + headers: { authorization: `Bearer ${token}` }, + }); + expect(meAfterDeactivation.statusCode).toBe(401); + }); + + it('Scenario 4: an admin provisions an account, immediately usable to log in', async () => { + const adminLogin = await app.inject({ + method: 'POST', + url: '/auth/login', + payload: { email: adminEmail, password }, + }); + const adminToken = adminLogin.json().data.token as string; + const agentLogin = await app.inject({ + method: 'POST', + url: '/auth/login', + payload: { email: agentEmail, password }, + }); + const agentToken = agentLogin.json().data.token as string; + + const newAccountEmail = `identity-provisioned-${suffix}@supporthub.test`; + const created = await app.inject({ + method: 'POST', + url: '/admin/users', + headers: { authorization: `Bearer ${adminToken}` }, + payload: { email: newAccountEmail, name: 'Provisioned Agent', role: 'AGENT', password }, + }); + expect(created.statusCode).toBe(201); + expect(created.json().data.passwordHash).toBeUndefined(); + expect(created.json().data.password).toBeUndefined(); + createdUserIds.push(created.json().data.id); + + const nonAdminAttempt = await app.inject({ + method: 'POST', + url: '/admin/users', + headers: { authorization: `Bearer ${agentToken}` }, + payload: { + email: `identity-rejected-${suffix}@supporthub.test`, + name: 'Should Be Rejected', + role: 'AGENT', + password, + }, + }); + expect(nonAdminAttempt.statusCode).toBe(403); + + const newAccountLogin = await app.inject({ + method: 'POST', + url: '/auth/login', + payload: { email: newAccountEmail, password }, + }); + expect(newAccountLogin.statusCode).toBe(200); + + const duplicate = await app.inject({ + method: 'POST', + url: '/admin/users', + headers: { authorization: `Bearer ${adminToken}` }, + payload: { email: newAccountEmail, name: 'Duplicate', role: 'AGENT', password }, + }); + expect(duplicate.statusCode).toBe(409); + }); + + it('Scenario 5: logout immediately revokes the token, even though it has not naturally expired', async () => { + const login = await app.inject({ + method: 'POST', + url: '/auth/login', + payload: { email: agentEmail, password }, + }); + const token = login.json().data.token as string; + + const logout = await app.inject({ + method: 'POST', + url: '/auth/logout', + headers: { authorization: `Bearer ${token}` }, + }); + expect(logout.statusCode).toBe(200); + + const reuse = await app.inject({ + method: 'GET', + url: '/auth/me', + headers: { authorization: `Bearer ${token}` }, + }); + expect(reuse.statusCode).toBe(401); + }); +}); diff --git a/tests/integration/inbound-rate-limit.test.ts b/tests/integration/inbound-rate-limit.test.ts index 23fef0e..bd3f0c8 100644 --- a/tests/integration/inbound-rate-limit.test.ts +++ b/tests/integration/inbound-rate-limit.test.ts @@ -3,6 +3,7 @@ import { buildApp } from '@/app'; import { prismaClient } from '@/infrastructure/database'; import { FastifyInstance } from 'fastify'; import { issueIntegrationToken } from '@/modules/catalog/products'; +import { loginAs, authHeader } from '../helpers/auth'; /** * Covers specs/002-saas-integration/quickstart.md Scenario 8 (integration-level and per-user @@ -10,6 +11,7 @@ import { issueIntegrationToken } from '@/modules/catalog/products'; */ describe('Inbound rate limiting', () => { let app: FastifyInstance; + let adminToken: string; const externalProductId = `TEST_RATELIMIT_PROD_${Date.now()}`; afterAll(async () => { @@ -29,10 +31,12 @@ describe('Inbound rate limiting', () => { it('throttles an integration once it exceeds its per-minute limit, and independently throttles a single user within it', async () => { app = await buildApp(); + adminToken = await loginAs(app, 'ADMIN'); const registerResponse = await app.inject({ method: 'POST', url: `/admin/products/${externalProductId}/integration`, + headers: authHeader(adminToken), payload: { name: 'Rate Limit Test Product', allowedScope: { tenantIds: ['tenant-1'] }, @@ -80,6 +84,7 @@ describe('Inbound rate limiting', () => { const registerResponse = await app.inject({ method: 'POST', url: `/admin/products/${externalProductId}-int/integration`, + headers: authHeader(adminToken), payload: { name: 'Rate Limit Test Product (integration-level)', allowedScope: { tenantIds: ['tenant-1'] }, diff --git a/tests/integration/knowledge-entries.test.ts b/tests/integration/knowledge-entries.test.ts index 5d43607..61e2bd4 100644 --- a/tests/integration/knowledge-entries.test.ts +++ b/tests/integration/knowledge-entries.test.ts @@ -2,15 +2,18 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { buildApp } from '@/app'; import { prismaClient } from '@/infrastructure/database'; import { FastifyInstance } from 'fastify'; +import { loginAs, authHeader } from '../helpers/auth'; /** Covers specs/004-product-knowledge/quickstart.md Scenarios 1, 2, 3 against a real Postgres. */ describe('Knowledge entry authoring, publishing, and versioning', () => { let app: FastifyInstance; + let token: string; const externalProductId = `TEST_KNOWLEDGE_PROD_${Date.now()}`; const code = `KB-TEST-${Date.now()}`; beforeAll(async () => { app = await buildApp(); + token = await loginAs(app, 'ADMIN'); await prismaClient.product.create({ data: { externalProductId, name: 'Knowledge Test Product', status: 'active' }, }); @@ -26,6 +29,7 @@ describe('Knowledge entry authoring, publishing, and versioning', () => { const createResponse = await app.inject({ method: 'POST', url: `/admin/products/${externalProductId}/knowledge`, + headers: authHeader(token), payload: { code, type: 'known_issue', problem: 'PDF conversion fails' }, }); expect(createResponse.statusCode).toBe(201); @@ -42,6 +46,7 @@ describe('Knowledge entry authoring, publishing, and versioning', () => { const publishResponse = await app.inject({ method: 'PATCH', url: `/admin/knowledge/${code}/publish`, + headers: authHeader(token), }); expect(publishResponse.statusCode).toBe(200); expect(publishResponse.json().data.status).toBe('published'); @@ -57,6 +62,7 @@ describe('Knowledge entry authoring, publishing, and versioning', () => { const editResponse = await app.inject({ method: 'PUT', url: `/admin/knowledge/${code}`, + headers: authHeader(token), payload: { type: 'known_issue', problem: 'PDF conversion fails — updated', @@ -69,6 +75,7 @@ describe('Knowledge entry authoring, publishing, and versioning', () => { const versionsResponse = await app.inject({ method: 'GET', url: `/admin/knowledge/${code}/versions`, + headers: authHeader(token), }); const versions = versionsResponse.json().data; expect(versions).toHaveLength(2); @@ -91,11 +98,13 @@ describe('Knowledge entry authoring, publishing, and versioning', () => { const first = await app.inject({ method: 'PUT', url: `/admin/knowledge/${code}`, + headers: authHeader(token), payload: { type: 'known_issue', problem: 'edit A', expectedVersion: 2 }, }); const second = await app.inject({ method: 'PUT', url: `/admin/knowledge/${code}`, + headers: authHeader(token), payload: { type: 'known_issue', problem: 'edit B', expectedVersion: 2 }, }); @@ -107,6 +116,7 @@ describe('Knowledge entry authoring, publishing, and versioning', () => { const response = await app.inject({ method: 'PATCH', url: `/admin/knowledge/${code}/validate`, + headers: authHeader(token), payload: { validationStatus: 'validated' }, }); expect(response.statusCode).toBe(200); @@ -114,7 +124,11 @@ describe('Knowledge entry authoring, publishing, and versioning', () => { }); it('unpublishing removes the entry from retrieval without deleting it', async () => { - await app.inject({ method: 'PATCH', url: `/admin/knowledge/${code}/unpublish` }); + await app.inject({ + method: 'PATCH', + url: `/admin/knowledge/${code}/unpublish`, + headers: authHeader(token), + }); const retrieveResponse = await app.inject({ method: 'GET', @@ -127,6 +141,7 @@ describe('Knowledge entry authoring, publishing, and versioning', () => { const versionsResponse = await app.inject({ method: 'GET', url: `/admin/knowledge/${code}/versions`, + headers: authHeader(token), }); expect(versionsResponse.statusCode).toBe(200); }); diff --git a/tests/integration/knowledge-retrieval.test.ts b/tests/integration/knowledge-retrieval.test.ts index ed07a07..a0d4ba3 100644 --- a/tests/integration/knowledge-retrieval.test.ts +++ b/tests/integration/knowledge-retrieval.test.ts @@ -2,16 +2,19 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { buildApp } from '@/app'; import { prismaClient } from '@/infrastructure/database'; import { FastifyInstance } from 'fastify'; +import { loginAs, authHeader } from '../helpers/auth'; /** Covers specs/004-product-knowledge/quickstart.md Scenario 6 against a real Postgres. */ describe('Knowledge retrieval — scoping and ranking', () => { let app: FastifyInstance; + let token: string; const productAId = `TEST_RETRIEVE_A_${Date.now()}`; const productBId = `TEST_RETRIEVE_B_${Date.now()}`; const codes: string[] = []; beforeAll(async () => { app = await buildApp(); + token = await loginAs(app, 'ADMIN'); await prismaClient.product.create({ data: { externalProductId: productAId, name: 'Product A', status: 'active' }, }); @@ -33,9 +36,14 @@ describe('Knowledge retrieval — scoping and ranking', () => { await app.inject({ method: 'POST', url: `/admin/products/${externalProductId}/knowledge`, + headers: authHeader(token), payload: { code, type: 'faq', problem: `problem for ${code}` }, }); - await app.inject({ method: 'PATCH', url: `/admin/knowledge/${code}/publish` }); + await app.inject({ + method: 'PATCH', + url: `/admin/knowledge/${code}/publish`, + headers: authHeader(token), + }); } it("never returns another product's entries", async () => { @@ -62,6 +70,7 @@ describe('Knowledge retrieval — scoping and ranking', () => { await app.inject({ method: 'PATCH', url: `/admin/knowledge/${validatedCode}/validate`, + headers: authHeader(token), payload: { validationStatus: 'validated' }, }); diff --git a/tests/integration/known-issues.test.ts b/tests/integration/known-issues.test.ts index be13c27..c3143fc 100644 --- a/tests/integration/known-issues.test.ts +++ b/tests/integration/known-issues.test.ts @@ -2,15 +2,18 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { buildApp } from '@/app'; import { prismaClient } from '@/infrastructure/database'; import { FastifyInstance } from 'fastify'; +import { loginAs, authHeader } from '../helpers/auth'; /** Covers specs/004-product-knowledge/quickstart.md Scenario 4 against a real Postgres. */ describe('Error codes and known issues', () => { let app: FastifyInstance; + let token: string; const externalProductId = `TEST_KNOWNISSUE_PROD_${Date.now()}`; const errorCode = 'LAYOUT_PARSE_042'; beforeAll(async () => { app = await buildApp(); + token = await loginAs(app, 'ADMIN'); await prismaClient.product.create({ data: { externalProductId, name: 'Known Issue Test Product', status: 'active' }, }); @@ -27,6 +30,7 @@ describe('Error codes and known issues', () => { const errorCodeResponse = await app.inject({ method: 'POST', url: `/admin/products/${externalProductId}/error-codes`, + headers: authHeader(token), payload: { code: errorCode, description: 'Layout parser failure' }, }); expect(errorCodeResponse.statusCode).toBe(201); @@ -35,6 +39,7 @@ describe('Error codes and known issues', () => { const knownIssueResponse = await app.inject({ method: 'POST', url: `/admin/products/${externalProductId}/known-issues`, + headers: authHeader(token), payload: { errorCodeId, description: 'Conversion fails for complex layouts' }, }); expect(knownIssueResponse.statusCode).toBe(201); @@ -42,6 +47,7 @@ describe('Error codes and known issues', () => { const lookupResponse = await app.inject({ method: 'GET', url: `/admin/products/${externalProductId}/known-issues/by-error-code/${errorCode}`, + headers: authHeader(token), }); expect(lookupResponse.statusCode).toBe(200); const knownIssues = lookupResponse.json().data; @@ -53,6 +59,7 @@ describe('Error codes and known issues', () => { const response = await app.inject({ method: 'GET', url: `/admin/products/${externalProductId}/known-issues/by-error-code/NEVER_REGISTERED`, + headers: authHeader(token), }); expect(response.statusCode).toBe(404); }); diff --git a/tests/integration/orchestration-flow.test.ts b/tests/integration/orchestration-flow.test.ts index 228f495..81580f7 100644 --- a/tests/integration/orchestration-flow.test.ts +++ b/tests/integration/orchestration-flow.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { buildApp } from '@/app'; import { prismaClient } from '@/infrastructure/database'; import { FastifyInstance } from 'fastify'; +import { loginAs, authHeader } from '../helpers/auth'; import { encryptCredential, generateCredentialSecret, @@ -15,6 +16,7 @@ import { */ describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)', () => { let app: FastifyInstance; + let adminToken: string; const externalProductId = `TEST_ORCH_PROD_${Date.now()}`; const skillTag = `orch_skill_${Date.now()}`; let productId: string; @@ -26,6 +28,7 @@ describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)', beforeAll(async () => { app = await buildApp(); + adminToken = await loginAs(app, 'ADMIN'); const product = await prismaClient.product.create({ data: { externalProductId, name: 'Orchestration Test Product', status: 'active' }, @@ -47,6 +50,7 @@ describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)', const team = await app.inject({ method: 'POST', url: '/admin/teams', + headers: authHeader(adminToken), payload: { name: `Orch Team ${Date.now()}` }, }); teamId = team.json().data.id; @@ -54,30 +58,35 @@ describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)', const agentA = await app.inject({ method: 'POST', url: `/admin/teams/${teamId}/agents`, + headers: authHeader(adminToken), payload: { name: 'Orch Agent A' }, }); agentAId = agentA.json().data.id; await app.inject({ method: 'PUT', url: `/admin/agents/${agentAId}/skills/${skillTag}`, + headers: authHeader(adminToken), payload: { level: 3 }, }); const agentB = await app.inject({ method: 'POST', url: `/admin/teams/${teamId}/agents`, + headers: authHeader(adminToken), payload: { name: 'Orch Agent B' }, }); agentBId = agentB.json().data.id; await app.inject({ method: 'PUT', url: `/admin/agents/${agentBId}/skills/${skillTag}`, + headers: authHeader(adminToken), payload: { level: 3 }, }); await app.inject({ method: 'POST', url: '/admin/hierarchy-nodes', + headers: authHeader(adminToken), payload: { name: 'Orch Node', order: 0, @@ -115,6 +124,10 @@ describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)', await prismaClient.agent.deleteMany({ where: { teamId } }); await prismaClient.team.deleteMany({ where: { id: teamId } }); await prismaClient.ticketMessage.deleteMany({ where: { ticketId } }); + // A wildcard (non-product-scoped) SLA policy from another concurrently-running suite (e.g. + // sla-escalation-flow.test.ts) can match this ticket too, leaving a real sla_run row that + // would otherwise RESTRICT this delete. + await prismaClient.sLARun.deleteMany({ where: { ticketId } }); await prismaClient.ticket.deleteMany({ where: { id: ticketId } }); await prismaClient.problem.deleteMany({ where: { productId } }); await prismaClient.productIntegration.deleteMany({ where: { productId } }); @@ -127,6 +140,7 @@ describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)', const escalate = await app.inject({ method: 'PATCH', url: `/tickets/${ticketId}/status`, + headers: authHeader(adminToken), payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version }, }); expect(escalate.statusCode).toBe(200); @@ -152,6 +166,7 @@ describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)', const manual = await app.inject({ method: 'POST', url: `/admin/tickets/${ticketId}/assignment`, + headers: authHeader(adminToken), payload: { agentId: otherAgentId, reason: 'Manual override for test' }, }); expect(manual.statusCode).toBe(200); @@ -164,6 +179,7 @@ describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)', const notFound = await app.inject({ method: 'POST', url: `/admin/tickets/${ticketId}/assignment`, + headers: authHeader(adminToken), payload: { agentId: 'nonexistent-agent-id' }, }); expect(notFound.statusCode).toBe(404); @@ -190,6 +206,7 @@ describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)', await app.inject({ method: 'PATCH', url: `/tickets/${ticketId}/status`, + headers: authHeader(adminToken), payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version }, }); diff --git a/tests/integration/orchestration-strategies.test.ts b/tests/integration/orchestration-strategies.test.ts index 5ba5f84..cae8ca8 100644 --- a/tests/integration/orchestration-strategies.test.ts +++ b/tests/integration/orchestration-strategies.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { buildApp } from '@/app'; import { prismaClient } from '@/infrastructure/database'; import { FastifyInstance } from 'fastify'; +import { loginAs, authHeader } from '../helpers/auth'; import { encryptCredential, generateCredentialSecret, @@ -12,14 +13,17 @@ import { * SKILL_BASED, and the empty-eligible-set outcome) against a real Postgres/Redis. */ describe('Orchestration and assignment — strategies (User Story 2)', () => { let app: FastifyInstance; + let authToken: string; let secret: string; let teamId: string; beforeAll(async () => { app = await buildApp(); + authToken = await loginAs(app, 'ADMIN'); const team = await app.inject({ method: 'POST', url: '/admin/teams', + headers: authHeader(authToken), payload: { name: `Strategy Team ${Date.now()}` }, }); teamId = team.json().data.id; @@ -71,6 +75,7 @@ describe('Orchestration and assignment — strategies (User Story 2)', () => { return app.inject({ method: 'PATCH', url: `/tickets/${ticketId}/status`, + headers: authHeader(authToken), payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version }, }); } @@ -80,28 +85,33 @@ describe('Orchestration and assignment — strategies (User Story 2)', () => { const agentLow = await app.inject({ method: 'POST', url: `/admin/teams/${teamId}/agents`, + headers: authHeader(authToken), payload: { name: 'Low Load Agent' }, }); const agentHigh = await app.inject({ method: 'POST', url: `/admin/teams/${teamId}/agents`, + headers: authHeader(authToken), payload: { name: 'High Load Agent' }, }); for (const id of [agentLow.json().data.id, agentHigh.json().data.id]) { await app.inject({ method: 'PUT', url: `/admin/agents/${id}/skills/${skillTag}`, + headers: authHeader(authToken), payload: { level: 1 }, }); } await app.inject({ method: 'PUT', url: `/admin/agents/${agentLow.json().data.id}/availability`, + headers: authHeader(authToken), payload: { status: 'available', workingHours: {}, currentLoad: 1 }, }); await app.inject({ method: 'PUT', url: `/admin/agents/${agentHigh.json().data.id}/availability`, + headers: authHeader(authToken), payload: { status: 'available', workingHours: {}, currentLoad: 9 }, }); @@ -109,6 +119,7 @@ describe('Orchestration and assignment — strategies (User Story 2)', () => { await app.inject({ method: 'POST', url: '/admin/hierarchy-nodes', + headers: authHeader(authToken), payload: { name: `LL Node ${Date.now()}`, order: 0, @@ -128,21 +139,25 @@ describe('Orchestration and assignment — strategies (User Story 2)', () => { const agentExpert = await app.inject({ method: 'POST', url: `/admin/teams/${teamId}/agents`, + headers: authHeader(authToken), payload: { name: 'Expert Agent' }, }); const agentNovice = await app.inject({ method: 'POST', url: `/admin/teams/${teamId}/agents`, + headers: authHeader(authToken), payload: { name: 'Novice Agent' }, }); await app.inject({ method: 'PUT', url: `/admin/agents/${agentExpert.json().data.id}/skills/${skillTag}`, + headers: authHeader(authToken), payload: { level: 9 }, }); await app.inject({ method: 'PUT', url: `/admin/agents/${agentNovice.json().data.id}/skills/${skillTag}`, + headers: authHeader(authToken), payload: { level: 1 }, }); @@ -150,6 +165,7 @@ describe('Orchestration and assignment — strategies (User Story 2)', () => { await app.inject({ method: 'POST', url: '/admin/hierarchy-nodes', + headers: authHeader(authToken), payload: { name: `SB Node ${Date.now()}`, order: 0, @@ -170,6 +186,7 @@ describe('Orchestration and assignment — strategies (User Story 2)', () => { await app.inject({ method: 'POST', url: '/admin/hierarchy-nodes', + headers: authHeader(authToken), payload: { name: `Empty Node ${Date.now()}`, order: 0, diff --git a/tests/integration/problem-resolution-flow.test.ts b/tests/integration/problem-resolution-flow.test.ts index 4e21e7b..340b2aa 100644 --- a/tests/integration/problem-resolution-flow.test.ts +++ b/tests/integration/problem-resolution-flow.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { buildApp } from '@/app'; import { prismaClient } from '@/infrastructure/database'; import { FastifyInstance } from 'fastify'; +import { loginAs, authHeader } from '../helpers/auth'; import { resolutionsService } from '@/modules/problem-management/resolutions'; import { ticketsService } from '@/modules/ticketing/tickets'; import { @@ -18,6 +19,7 @@ import { */ describe('Problem resolution — full flow (User Stories 1-6)', () => { let app: FastifyInstance; + let authToken: string; const externalProductId = `TEST_PR_PROD_${Date.now()}`; const skillTag = `pr_skill_${Date.now()}`; let productId: string; @@ -67,6 +69,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => { await app.inject({ method: 'PATCH', url: `/tickets/${ticketId}/status`, + headers: authHeader(authToken), payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version }, }); } @@ -76,42 +79,51 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => { await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/investigations`, + headers: authHeader(authToken), payload: { investigator: 'agent-1', findings: { note: 'checked logs' } }, }); await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/root-causes`, + headers: authHeader(authToken), payload: { type: 'technical', description: 'a bug' }, }); const solutionRes = await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/solutions`, + headers: authHeader(authToken), payload: { proposed: 'apply fix' }, }); const solutionId = solutionRes.json().data.id; await app.inject({ method: 'PATCH', url: `/admin/solutions/${solutionId}/approve`, + + headers: authHeader(authToken), }); await app.inject({ method: 'POST', url: `/admin/solutions/${solutionId}/implementation`, + headers: authHeader(authToken), payload: { implementedBy: 'agent-1' }, }); await app.inject({ method: 'POST', url: `/admin/solutions/${solutionId}/verification`, + headers: authHeader(authToken), payload: { method: 'agent_confirmation', result: 'success' }, }); await app.inject({ method: 'POST', url: `/admin/tickets/${ticketId}/resolution`, + headers: authHeader(authToken), payload: { outcome: 'fixed', resolvedBy: 'agent-1' }, }); } beforeAll(async () => { app = await buildApp(); + authToken = await loginAs(app, 'ADMIN'); const product = await prismaClient.product.create({ data: { externalProductId, name: 'Problem Resolution Test Product', status: 'active' }, @@ -133,6 +145,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => { const team = await app.inject({ method: 'POST', url: '/admin/teams', + headers: authHeader(authToken), payload: { name: `PR Team ${Date.now()}` }, }); teamId = team.json().data.id; @@ -140,18 +153,21 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => { const agent = await app.inject({ method: 'POST', url: `/admin/teams/${teamId}/agents`, + headers: authHeader(authToken), payload: { name: 'PR Agent' }, }); agentId = agent.json().data.id; await app.inject({ method: 'PUT', url: `/admin/agents/${agentId}/skills/${skillTag}`, + headers: authHeader(authToken), payload: { level: 3 }, }); await app.inject({ method: 'POST', url: '/admin/hierarchy-nodes', + headers: authHeader(authToken), payload: { name: 'PR Node', order: 0, @@ -204,6 +220,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => { const record = await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/investigations`, + headers: authHeader(authToken), payload: { investigator: 'agent-1', findings: { checked: 'logs' }, @@ -216,6 +233,8 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => { const agentRead = await app.inject({ method: 'GET', url: `/admin/problems/${problemId}/investigations`, + + headers: authHeader(authToken), }); expect(agentRead.json().data[0].internalNotes).toBe('suspect race condition'); @@ -228,6 +247,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => { const second = await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/investigations`, + headers: authHeader(authToken), payload: { investigator: 'agent-1', findings: { checked: 'more logs' } }, }); expect(second.statusCode).toBe(201); @@ -235,6 +255,8 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => { const both = await app.inject({ method: 'GET', url: `/admin/problems/${problemId}/investigations`, + + headers: authHeader(authToken), }); expect(both.json().data.length).toBe(2); }); @@ -245,6 +267,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => { const beforeInvestigation = await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/root-causes`, + headers: authHeader(authToken), payload: { type: 'technical', description: 'x' }, }); expect(beforeInvestigation.statusCode).toBe(409); @@ -252,12 +275,14 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => { await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/investigations`, + headers: authHeader(authToken), payload: { investigator: 'agent-1', findings: {} }, }); const afterInvestigation = await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/root-causes`, + headers: authHeader(authToken), payload: { type: 'technical', description: 'a real cause' }, }); expect(afterInvestigation.statusCode).toBe(201); @@ -265,6 +290,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => { const invalidType = await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/root-causes`, + headers: authHeader(authToken), payload: { type: 'not_a_real_type', description: 'x' }, }); expect(invalidType.statusCode).toBe(400); @@ -276,6 +302,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => { const beforeRootCause = await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/solutions`, + headers: authHeader(authToken), payload: { proposed: 'x' }, }); expect(beforeRootCause.statusCode).toBe(409); @@ -283,17 +310,20 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => { await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/investigations`, + headers: authHeader(authToken), payload: { investigator: 'agent-1', findings: {} }, }); await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/root-causes`, + headers: authHeader(authToken), payload: { type: 'technical', description: 'x' }, }); const proposed = await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/solutions`, + headers: authHeader(authToken), payload: { proposed: 'apply the fix' }, }); expect(proposed.statusCode).toBe(201); @@ -303,15 +333,21 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => { const implBeforeApproval = await app.inject({ method: 'POST', url: `/admin/solutions/${solutionId}/implementation`, + headers: authHeader(authToken), payload: { implementedBy: 'agent-1' }, }); expect(implBeforeApproval.statusCode).toBe(409); - await app.inject({ method: 'PATCH', url: `/admin/solutions/${solutionId}/approve` }); + await app.inject({ + method: 'PATCH', + url: `/admin/solutions/${solutionId}/approve`, + headers: authHeader(authToken), + }); const impl = await app.inject({ method: 'POST', url: `/admin/solutions/${solutionId}/implementation`, + headers: authHeader(authToken), payload: { implementedBy: 'agent-1' }, }); expect(impl.statusCode).toBe(201); @@ -319,6 +355,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => { const secondImpl = await app.inject({ method: 'POST', url: `/admin/solutions/${solutionId}/implementation`, + headers: authHeader(authToken), payload: { implementedBy: 'agent-1' }, }); expect(secondImpl.statusCode).toBe(409); @@ -329,29 +366,38 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => { await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/investigations`, + headers: authHeader(authToken), payload: { investigator: 'agent-1', findings: {} }, }); await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/root-causes`, + headers: authHeader(authToken), payload: { type: 'technical', description: 'x' }, }); const proposed = await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/solutions`, + headers: authHeader(authToken), payload: { proposed: 'fix' }, }); const solutionId = proposed.json().data.id; - await app.inject({ method: 'PATCH', url: `/admin/solutions/${solutionId}/approve` }); + await app.inject({ + method: 'PATCH', + url: `/admin/solutions/${solutionId}/approve`, + headers: authHeader(authToken), + }); await app.inject({ method: 'POST', url: `/admin/solutions/${solutionId}/implementation`, + headers: authHeader(authToken), payload: { implementedBy: 'agent-1' }, }); const success = await app.inject({ method: 'POST', url: `/admin/solutions/${solutionId}/verification`, + headers: authHeader(authToken), payload: { method: 'agent_confirmation', result: 'success' }, }); expect(success.statusCode).toBe(201); @@ -361,28 +407,37 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => { await app.inject({ method: 'POST', url: `/admin/problems/${problem2}/investigations`, + headers: authHeader(authToken), payload: { investigator: 'agent-1', findings: {} }, }); await app.inject({ method: 'POST', url: `/admin/problems/${problem2}/root-causes`, + headers: authHeader(authToken), payload: { type: 'technical', description: 'x' }, }); const proposed2 = await app.inject({ method: 'POST', url: `/admin/problems/${problem2}/solutions`, + headers: authHeader(authToken), payload: { proposed: 'a wrong fix' }, }); const solution2Id = proposed2.json().data.id; - await app.inject({ method: 'PATCH', url: `/admin/solutions/${solution2Id}/approve` }); + await app.inject({ + method: 'PATCH', + url: `/admin/solutions/${solution2Id}/approve`, + headers: authHeader(authToken), + }); await app.inject({ method: 'POST', url: `/admin/solutions/${solution2Id}/implementation`, + headers: authHeader(authToken), payload: { implementedBy: 'agent-1' }, }); const failed = await app.inject({ method: 'POST', url: `/admin/solutions/${solution2Id}/verification`, + headers: authHeader(authToken), payload: { method: 'agent_confirmation', result: 'failed' }, }); expect(failed.statusCode).toBe(201); @@ -391,6 +446,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => { const rejectedResolution = await app.inject({ method: 'POST', url: `/admin/tickets/${ticket2}/resolution`, + headers: authHeader(authToken), payload: { outcome: 'x', resolvedBy: 'agent-1' }, }); expect(rejectedResolution.statusCode).toBe(409); @@ -399,12 +455,15 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => { const reInvestigate = await app.inject({ method: 'POST', url: `/admin/problems/${problem2}/investigations`, + headers: authHeader(authToken), payload: { investigator: 'agent-2', findings: { retried: true } }, }); expect(reInvestigate.statusCode).toBe(201); const allInvestigations = await app.inject({ method: 'GET', url: `/admin/problems/${problem2}/investigations`, + + headers: authHeader(authToken), }); expect(allInvestigations.json().data.length).toBe(2); @@ -413,6 +472,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => { const escalate = await app.inject({ method: 'PATCH', url: `/tickets/${ticket2}/status`, + headers: authHeader(authToken), payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticketBeforeEscalate.version }, }); expect(escalate.statusCode).toBe(200); @@ -432,6 +492,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => { const rejected = await app.inject({ method: 'POST', url: `/admin/tickets/${ticketId}/resolution`, + headers: authHeader(authToken), payload: { outcome: 'x', resolvedBy: 'agent-1' }, }); expect(rejected.statusCode).toBe(409); @@ -439,34 +500,44 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => { await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/investigations`, + headers: authHeader(authToken), payload: { investigator: 'agent-1', findings: {} }, }); await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/root-causes`, + headers: authHeader(authToken), payload: { type: 'technical', description: 'x' }, }); const proposed = await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/solutions`, + headers: authHeader(authToken), payload: { proposed: 'fix' }, }); const solutionId = proposed.json().data.id; - await app.inject({ method: 'PATCH', url: `/admin/solutions/${solutionId}/approve` }); + await app.inject({ + method: 'PATCH', + url: `/admin/solutions/${solutionId}/approve`, + headers: authHeader(authToken), + }); await app.inject({ method: 'POST', url: `/admin/solutions/${solutionId}/implementation`, + headers: authHeader(authToken), payload: { implementedBy: 'agent-1' }, }); await app.inject({ method: 'POST', url: `/admin/solutions/${solutionId}/verification`, + headers: authHeader(authToken), payload: { method: 'agent_confirmation', result: 'success' }, }); const resolved = await app.inject({ method: 'POST', url: `/admin/tickets/${ticketId}/resolution`, + headers: authHeader(authToken), payload: { outcome: 'fixed', resolvedBy: 'agent-1' }, }); expect(resolved.statusCode).toBe(201); @@ -543,6 +614,8 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => { const agentReopen = await app.inject({ method: 'POST', url: `/admin/tickets/${ticket2}/reopen`, + + headers: authHeader(authToken), }); expect(agentReopen.statusCode).toBe(200); expect(agentReopen.json().data.status).toBe('IN_PROGRESS'); diff --git a/tests/integration/product-integrations-admin.test.ts b/tests/integration/product-integrations-admin.test.ts index 1f42342..223dedb 100644 --- a/tests/integration/product-integrations-admin.test.ts +++ b/tests/integration/product-integrations-admin.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, afterAll } from 'vitest'; import { buildApp } from '@/app'; import { prismaClient } from '@/infrastructure/database'; import { FastifyInstance } from 'fastify'; +import { loginAs, authHeader } from '../helpers/auth'; import { issueIntegrationToken } from '@/modules/catalog/products'; /** @@ -12,6 +13,7 @@ import { issueIntegrationToken } from '@/modules/catalog/products'; */ describe('Product Integration Admin Lifecycle', () => { let app: FastifyInstance; + let token: string; const externalProductId = `TEST_ADMIN_PROD_${Date.now()}`; afterAll(async () => { @@ -32,9 +34,12 @@ describe('Product Integration Admin Lifecycle', () => { it('Scenario 5+7: register, then rotate — both old and new credential work during the transition window, and the audit trail records every step', async () => { app = await buildApp(); + token = await loginAs(app, 'ADMIN'); + const registerResponse = await app.inject({ method: 'POST', url: `/admin/products/${externalProductId}/integration`, + headers: authHeader(token), payload: { name: 'Admin Test Product', allowedScope: { tenantIds: ['tenant-1'] }, @@ -48,6 +53,7 @@ describe('Product Integration Admin Lifecycle', () => { const rotateResponse = await app.inject({ method: 'POST', url: `/admin/integrations/${integrationId}/rotate`, + headers: authHeader(token), }); expect(rotateResponse.statusCode).toBe(200); const rotated = rotateResponse.json().data; @@ -89,6 +95,7 @@ describe('Product Integration Admin Lifecycle', () => { const auditResponse = await app.inject({ method: 'GET', url: `/admin/integrations/${integrationId}/audit-trail`, + headers: authHeader(token), }); expect(auditResponse.statusCode).toBe(200); const actions = auditResponse.json().data.map((e: { action: string }) => e.action); @@ -101,6 +108,7 @@ describe('Product Integration Admin Lifecycle', () => { const registerResponse = await app.inject({ method: 'POST', url: `/admin/products/${externalProductId}-revoke/integration`, + headers: authHeader(token), payload: { name: 'Admin Test Product Revoke', allowedScope: { tenantIds: ['tenant-1'] }, @@ -111,6 +119,7 @@ describe('Product Integration Admin Lifecycle', () => { const revokeResponse = await app.inject({ method: 'POST', url: `/admin/integrations/${integrationId}/revoke`, + headers: authHeader(token), }); expect(revokeResponse.statusCode).toBe(200); diff --git a/tests/integration/runbooks.test.ts b/tests/integration/runbooks.test.ts index 774172c..4a52311 100644 --- a/tests/integration/runbooks.test.ts +++ b/tests/integration/runbooks.test.ts @@ -2,15 +2,18 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { buildApp } from '@/app'; import { prismaClient } from '@/infrastructure/database'; import { FastifyInstance } from 'fastify'; +import { loginAs, authHeader } from '../helpers/auth'; /** Covers specs/004-product-knowledge/quickstart.md Scenario 5 against a real Postgres. */ describe('Runbooks', () => { let app: FastifyInstance; + let token: string; const externalProductId = `TEST_RUNBOOK_PROD_${Date.now()}`; const key = 'PDF_HTML_CONVERSION_FAILURE'; beforeAll(async () => { app = await buildApp(); + token = await loginAs(app, 'ADMIN'); await prismaClient.product.create({ data: { externalProductId, name: 'Runbook Test Product', status: 'active' }, }); @@ -32,6 +35,7 @@ describe('Runbooks', () => { const createResponse = await app.inject({ method: 'POST', url: `/admin/products/${externalProductId}/runbooks`, + headers: authHeader(token), payload: { key, steps }, }); expect(createResponse.statusCode).toBe(201); @@ -39,6 +43,7 @@ describe('Runbooks', () => { const lookupResponse = await app.inject({ method: 'GET', url: `/admin/products/${externalProductId}/runbooks/${key}`, + headers: authHeader(token), }); expect(lookupResponse.statusCode).toBe(200); expect(lookupResponse.json().data.steps).toEqual(steps); @@ -46,12 +51,14 @@ describe('Runbooks', () => { const deactivateResponse = await app.inject({ method: 'PATCH', url: `/admin/products/${externalProductId}/runbooks/${key}/deactivate`, + headers: authHeader(token), }); expect(deactivateResponse.statusCode).toBe(200); const lookupAfterDeactivate = await app.inject({ method: 'GET', url: `/admin/products/${externalProductId}/runbooks/${key}`, + headers: authHeader(token), }); expect(lookupAfterDeactivate.statusCode).toBe(404); }); @@ -61,12 +68,14 @@ describe('Runbooks', () => { await app.inject({ method: 'POST', url: `/admin/products/${externalProductId}/runbooks`, + headers: authHeader(token), payload: { key: key2, steps: [{ step: 1, description: 'Original step' }] }, }); const editResponse = await app.inject({ method: 'PUT', url: `/admin/products/${externalProductId}/runbooks/${key2}`, + headers: authHeader(token), payload: { steps: [{ step: 1, description: 'Updated step' }], expectedVersion: 1, diff --git a/tests/integration/sla-escalation-flow.test.ts b/tests/integration/sla-escalation-flow.test.ts index 948f760..55609b9 100644 --- a/tests/integration/sla-escalation-flow.test.ts +++ b/tests/integration/sla-escalation-flow.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { buildApp } from '@/app'; import { prismaClient } from '@/infrastructure/database'; import { FastifyInstance } from 'fastify'; +import { loginAs, authHeader } from '../helpers/auth'; import { slaService } from '@/modules/orchestration/sla'; import { encryptCredential, @@ -17,6 +18,7 @@ import { */ describe('SLA and escalation — full flow (User Stories 1-6)', () => { let app: FastifyInstance; + let authToken: string; const externalProductId = `TEST_SLA_PROD_${Date.now()}`; const skillTag = `sla_skill_${Date.now()}`; let productId: string; @@ -60,12 +62,14 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => { await app.inject({ method: 'PATCH', url: `/tickets/${ticketId}/status`, + headers: authHeader(authToken), payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version }, }); } beforeAll(async () => { app = await buildApp(); + authToken = await loginAs(app, 'ADMIN'); const product = await prismaClient.product.create({ data: { externalProductId, name: 'SLA Test Product', status: 'active' }, @@ -87,6 +91,7 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => { const team = await app.inject({ method: 'POST', url: '/admin/teams', + headers: authHeader(authToken), payload: { name: `SLA Team ${Date.now()}` }, }); teamId = team.json().data.id; @@ -94,30 +99,35 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => { const agentA = await app.inject({ method: 'POST', url: `/admin/teams/${teamId}/agents`, + headers: authHeader(authToken), payload: { name: 'SLA Agent A' }, }); agentAId = agentA.json().data.id; await app.inject({ method: 'PUT', url: `/admin/agents/${agentAId}/skills/${skillTag}`, + headers: authHeader(authToken), payload: { level: 3 }, }); const agentB = await app.inject({ method: 'POST', url: `/admin/teams/${teamId}/agents`, + headers: authHeader(authToken), payload: { name: 'SLA Agent B' }, }); agentBId = agentB.json().data.id; await app.inject({ method: 'PUT', url: `/admin/agents/${agentBId}/skills/${skillTag}`, + headers: authHeader(authToken), payload: { level: 3 }, }); const nodeA = await app.inject({ method: 'POST', url: '/admin/hierarchy-nodes', + headers: authHeader(authToken), payload: { name: 'SLA Node A', order: 0, @@ -131,6 +141,7 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => { const nodeB = await app.inject({ method: 'POST', url: '/admin/hierarchy-nodes', + headers: authHeader(authToken), payload: { // Scoped to this test's own product, not a wildcard ([] matches every product per // HierarchyNode's own scope-matching rule) — a wildcard node here would leak into any @@ -149,6 +160,7 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => { const globalPolicy = await app.inject({ method: 'POST', url: '/admin/sla-policies', + headers: authHeader(authToken), payload: { name: 'Global policy', firstResponseMinutes: 60, resolutionMinutes: 480 }, }); globalPolicyId = globalPolicy.json().data.id; @@ -157,6 +169,7 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => { const productPolicy = await app.inject({ method: 'POST', url: '/admin/sla-policies', + headers: authHeader(authToken), payload: { name: 'Product policy', productId, @@ -170,10 +183,20 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => { afterAll(async () => { const ticketFilter = { ticketId: { in: createdTicketIds } }; await prismaClient.escalationEvent.deleteMany({ where: ticketFilter }); - await prismaClient.escalationRule.deleteMany({ where: { targetNodeId: { in: [nodeAId, nodeBId] } } }); + await prismaClient.escalationRule.deleteMany({ + where: { targetNodeId: { in: [nodeAId, nodeBId] } }, + }); await prismaClient.escalationPolicy.deleteMany({ where: { productId } }); await prismaClient.sLARun.deleteMany({ where: ticketFilter }); - await prismaClient.sLAPolicy.deleteMany({ where: { id: { in: [globalPolicyId, productPolicyId] } } }); + // "Global policy" is wildcard-scoped (no productId), so it can also match tickets created by + // another concurrently-running suite — delete any sla_run left referencing it by policyId, + // not just the ones tied to this file's own tickets, or the policy delete below gets RESTRICTed. + await prismaClient.sLARun.deleteMany({ + where: { policyId: { in: [globalPolicyId, productPolicyId] } }, + }); + await prismaClient.sLAPolicy.deleteMany({ + where: { id: { in: [globalPolicyId, productPolicyId] } }, + }); await prismaClient.assignmentHistory.deleteMany({ where: ticketFilter }); await prismaClient.assignment.deleteMany({ where: ticketFilter }); await prismaClient.hierarchyNode.deleteMany({ where: { id: { in: [nodeAId, nodeBId] } } }); @@ -213,12 +236,21 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => { const outsidePolicy = await app.inject({ method: 'GET', url: `/admin/sla-policies/${productPolicyId}`, + headers: authHeader(authToken), }); expect(outsidePolicy.statusCode).toBe(200); // Deactivate both policies temporarily to prove the no-match path. - await app.inject({ method: 'DELETE', url: `/admin/sla-policies/${productPolicyId}` }); - await app.inject({ method: 'DELETE', url: `/admin/sla-policies/${globalPolicyId}` }); + await app.inject({ + method: 'DELETE', + url: `/admin/sla-policies/${productPolicyId}`, + headers: authHeader(authToken), + }); + await app.inject({ + method: 'DELETE', + url: `/admin/sla-policies/${globalPolicyId}`, + headers: authHeader(authToken), + }); const ticketId = await createTicket(); await escalateAndAssign(ticketId); @@ -247,6 +279,7 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => { await app.inject({ method: 'PATCH', url: `/tickets/${ticketId}/status`, + headers: authHeader(authToken), payload: { status: 'WAITING_FOR_CUSTOMER', expectedVersion: ticket.version }, }); @@ -265,6 +298,7 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => { await app.inject({ method: 'PATCH', url: `/tickets/${ticketId}/status`, + headers: authHeader(authToken), payload: { status: 'IN_PROGRESS', expectedVersion: ticketAfterRestart.version }, }); @@ -294,12 +328,18 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => { await escalateAndAssign(pausedTicketId); await prismaClient.sLARun.update({ where: { ticketId: pausedTicketId }, - data: { resolutionDueAt: new Date(Date.now() - 60_000), status: 'paused', pausedAt: new Date() }, + data: { + resolutionDueAt: new Date(Date.now() - 60_000), + status: 'paused', + pausedAt: new Date(), + }, }); await slaService.runBreachDetectionSweep(); - const overdue = await prismaClient.sLARun.findUniqueOrThrow({ where: { ticketId: overdueTicketId } }); + const overdue = await prismaClient.sLARun.findUniqueOrThrow({ + where: { ticketId: overdueTicketId }, + }); expect(overdue.status).toBe('breached'); expect(overdue.breachedAt).not.toBeNull(); @@ -308,14 +348,17 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => { }); expect(completed.status).toBe('completed'); - const paused = await prismaClient.sLARun.findUniqueOrThrow({ where: { ticketId: pausedTicketId } }); + const paused = await prismaClient.sLARun.findUniqueOrThrow({ + where: { ticketId: pausedTicketId }, + }); expect(paused.status).toBe('paused'); }); - it('Scenario 5: a breach with a matching rule fires exactly one EscalationEvent and reassigns to the rule\'s node', async () => { + it("Scenario 5: a breach with a matching rule fires exactly one EscalationEvent and reassigns to the rule's node", async () => { const policy = await app.inject({ method: 'POST', url: '/admin/escalation-policies', + headers: authHeader(authToken), payload: { name: 'Product escalation policy', productId }, }); const escalationPolicyId = policy.json().data.id; @@ -323,6 +366,7 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => { await app.inject({ method: 'POST', url: `/admin/escalation-policies/${escalationPolicyId}/rules`, + headers: authHeader(authToken), payload: { triggerType: 'resolution_breach', condition: {}, @@ -377,6 +421,7 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => { const notFound = await app.inject({ method: 'POST', url: `/tickets/${ticketId}/escalate`, + headers: authHeader(authToken), payload: { targetNodeId: 'nonexistent-node-id', reason: 'test' }, }); expect(notFound.statusCode).toBe(404); @@ -385,6 +430,7 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => { const manual = await app.inject({ method: 'POST', url: `/tickets/${ticketId}/escalate`, + headers: authHeader(authToken), payload: { targetNodeId: nodeBId, reason: 'Customer requested a specialist' }, }); expect(manual.statusCode).toBe(201); diff --git a/tests/integration/support-org-capability-lookup.test.ts b/tests/integration/support-org-capability-lookup.test.ts index 67d4adc..11f01eb 100644 --- a/tests/integration/support-org-capability-lookup.test.ts +++ b/tests/integration/support-org-capability-lookup.test.ts @@ -2,10 +2,12 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { buildApp } from '@/app'; import { prismaClient } from '@/infrastructure/database'; import { FastifyInstance } from 'fastify'; +import { loginAs, authHeader } from '../helpers/auth'; /** Covers specs/006-support-organization/quickstart.md Scenario 4 against a real Postgres. */ describe('Support organization — capability eligibility lookup (User Story 4)', () => { let app: FastifyInstance; + let token: string; const teamName = `Test Capability Team ${Date.now()}`; const skillX = `skill_x_${Date.now()}`; const skillY = `skill_y_${Date.now()}`; @@ -17,9 +19,11 @@ describe('Support organization — capability eligibility lookup (User Story 4)' beforeAll(async () => { app = await buildApp(); + token = await loginAs(app, 'ADMIN'); const team = await app.inject({ method: 'POST', url: '/admin/teams', + headers: authHeader(token), payload: { name: teamName }, }); teamId = team.json().data.id; @@ -27,24 +31,28 @@ describe('Support organization — capability eligibility lookup (User Story 4)' const agentA = await app.inject({ method: 'POST', url: `/admin/teams/${teamId}/agents`, + headers: authHeader(token), payload: { name: 'Agent A' }, }); agentAId = agentA.json().data.id; await app.inject({ method: 'PUT', url: `/admin/agents/${agentAId}/skills/${skillX}`, + headers: authHeader(token), payload: { level: 3 }, }); const agentB = await app.inject({ method: 'POST', url: `/admin/teams/${teamId}/agents`, + headers: authHeader(token), payload: { name: 'Agent B' }, }); agentBId = agentB.json().data.id; await app.inject({ method: 'PUT', url: `/admin/agents/${agentBId}/skills/${skillY}`, + headers: authHeader(token), payload: { level: 3 }, }); }); @@ -75,6 +83,7 @@ describe('Support organization — capability eligibility lookup (User Story 4)' await app.inject({ method: 'PUT', url: `/admin/agents/${agentAId}/availability`, + headers: authHeader(token), payload: { status: 'offline', workingHours: {} }, }); const stillOffline = await app.inject({ @@ -86,6 +95,7 @@ describe('Support organization — capability eligibility lookup (User Story 4)' await app.inject({ method: 'PATCH', url: `/admin/agents/${agentAId}`, + headers: authHeader(token), payload: { active: false }, }); const afterDeactivation = await app.inject({ @@ -106,17 +116,20 @@ describe('Support organization — capability eligibility lookup (User Story 4)' await app.inject({ method: 'PATCH', url: `/admin/agents/${agentAId}`, + headers: authHeader(token), payload: { active: true }, }); await app.inject({ method: 'PUT', url: `/admin/agents/${agentAId}/skills/${skillZ}`, + headers: authHeader(token), payload: { level: 2 }, }); const productId = `TEST_CAP_PRODUCT_${Date.now()}`; const node = await app.inject({ method: 'POST', url: '/admin/hierarchy-nodes', + headers: authHeader(token), payload: { name: 'Capability Scope Node', order: 0, diff --git a/tests/integration/support-org-hierarchy.test.ts b/tests/integration/support-org-hierarchy.test.ts index 8f0824b..6aa2006 100644 --- a/tests/integration/support-org-hierarchy.test.ts +++ b/tests/integration/support-org-hierarchy.test.ts @@ -2,16 +2,19 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { buildApp } from '@/app'; import { prismaClient } from '@/infrastructure/database'; import { FastifyInstance } from 'fastify'; +import { loginAs, authHeader } from '../helpers/auth'; /** Covers specs/006-support-organization/quickstart.md Scenario 3 against a real Postgres. */ describe('Support organization — dynamic hierarchy (User Story 3)', () => { let app: FastifyInstance; + let token: string; const rootName = `Test Root ${Date.now()}`; let rootId: string; let childId: string; beforeAll(async () => { app = await buildApp(); + token = await loginAs(app, 'ADMIN'); }); afterAll(async () => { @@ -28,6 +31,7 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => { const createRoot = await app.inject({ method: 'POST', url: '/admin/hierarchy-nodes', + headers: authHeader(token), payload: { name: rootName, order: 0, assignmentStrategy: 'ROUND_ROBIN' }, }); expect(createRoot.statusCode).toBe(201); @@ -36,6 +40,7 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => { const createChild = await app.inject({ method: 'POST', url: '/admin/hierarchy-nodes', + headers: authHeader(token), payload: { name: `${rootName} Child`, parentId: rootId, @@ -49,12 +54,14 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => { const children = await app.inject({ method: 'GET', url: `/admin/hierarchy-nodes/${rootId}/children`, + headers: authHeader(token), }); expect(children.json().data.map((n: { id: string }) => n.id)).toContain(childId); const badParent = await app.inject({ method: 'POST', url: '/admin/hierarchy-nodes', + headers: authHeader(token), payload: { name: 'Orphan', parentId: 'nonexistent-node-id', @@ -67,6 +74,7 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => { const selfCycle = await app.inject({ method: 'PUT', url: `/admin/hierarchy-nodes/${childId}`, + headers: authHeader(token), payload: { name: `${rootName} Child`, parentId: childId, @@ -80,15 +88,21 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => { const transitiveCycle = await app.inject({ method: 'PUT', url: `/admin/hierarchy-nodes/${rootId}`, + headers: authHeader(token), payload: { name: rootName, parentId: childId, order: 0, assignmentStrategy: 'ROUND_ROBIN' }, }); expect(transitiveCycle.statusCode).toBe(400); expect(transitiveCycle.json().error.code).toBe('CYCLE_DETECTED'); - await app.inject({ method: 'PATCH', url: `/admin/hierarchy-nodes/${rootId}/deactivate` }); + await app.inject({ + method: 'PATCH', + url: `/admin/hierarchy-nodes/${rootId}/deactivate`, + headers: authHeader(token), + }); const activeTree = await app.inject({ method: 'GET', url: '/admin/hierarchy-nodes?active=true', + headers: authHeader(token), }); const activeIds = activeTree.json().data.map((n: { id: string }) => n.id); expect(activeIds).not.toContain(rootId); @@ -96,6 +110,7 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => { const childAfterParentDeactivation = await app.inject({ method: 'GET', url: `/admin/hierarchy-nodes/${childId}`, + headers: authHeader(token), }); expect(childAfterParentDeactivation.json().data.active).toBe(true); }); @@ -104,6 +119,7 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => { const parent = await app.inject({ method: 'POST', url: '/admin/hierarchy-nodes', + headers: authHeader(token), payload: { name: `${rootName} Order Parent`, order: 0, assignmentStrategy: 'ROUND_ROBIN' }, }); const parentId = parent.json().data.id; @@ -111,17 +127,20 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => { const second = await app.inject({ method: 'POST', url: '/admin/hierarchy-nodes', + headers: authHeader(token), payload: { name: 'Second', parentId, order: 2, assignmentStrategy: 'ROUND_ROBIN' }, }); const first = await app.inject({ method: 'POST', url: '/admin/hierarchy-nodes', + headers: authHeader(token), payload: { name: 'First', parentId, order: 1, assignmentStrategy: 'ROUND_ROBIN' }, }); const children = await app.inject({ method: 'GET', url: `/admin/hierarchy-nodes/${parentId}/children`, + headers: authHeader(token), }); const orderedIds = children.json().data.map((n: { id: string }) => n.id); expect(orderedIds).toEqual([first.json().data.id, second.json().data.id]); @@ -141,12 +160,21 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => { const created = await app.inject({ method: 'POST', url: '/admin/hierarchy-nodes', + headers: authHeader(token), payload: { name: `${rootName} Audited`, order: 0, assignmentStrategy: 'ROUND_ROBIN' }, }); const nodeId = created.json().data.id; - await app.inject({ method: 'PATCH', url: `/admin/hierarchy-nodes/${nodeId}/deactivate` }); - await app.inject({ method: 'PATCH', url: `/admin/hierarchy-nodes/${nodeId}/activate` }); + await app.inject({ + method: 'PATCH', + url: `/admin/hierarchy-nodes/${nodeId}/deactivate`, + headers: authHeader(token), + }); + await app.inject({ + method: 'PATCH', + url: `/admin/hierarchy-nodes/${nodeId}/activate`, + headers: authHeader(token), + }); const auditRows = await prismaClient.auditLog.findMany({ where: { entityType: 'HierarchyNode', entityId: nodeId }, diff --git a/tests/integration/support-org-skills-availability.test.ts b/tests/integration/support-org-skills-availability.test.ts index 09270fb..2530378 100644 --- a/tests/integration/support-org-skills-availability.test.ts +++ b/tests/integration/support-org-skills-availability.test.ts @@ -2,25 +2,30 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { buildApp } from '@/app'; import { prismaClient } from '@/infrastructure/database'; import { FastifyInstance } from 'fastify'; +import { loginAs, authHeader } from '../helpers/auth'; /** Covers specs/006-support-organization/quickstart.md Scenario 2 against a real Postgres. */ describe('Support organization — agent skills and availability (User Story 2)', () => { let app: FastifyInstance; + let token: string; const teamName = `Test Skills Team ${Date.now()}`; let teamId: string; let agentId: string; beforeAll(async () => { app = await buildApp(); + token = await loginAs(app, 'ADMIN'); const team = await app.inject({ method: 'POST', url: '/admin/teams', + headers: authHeader(token), payload: { name: teamName }, }); teamId = team.json().data.id; const agent = await app.inject({ method: 'POST', url: `/admin/teams/${teamId}/agents`, + headers: authHeader(token), payload: { name: 'Skilled Agent' }, }); agentId = agent.json().data.id; @@ -38,6 +43,7 @@ describe('Support organization — agent skills and availability (User Story 2)' const addSkill = await app.inject({ method: 'PUT', url: `/admin/agents/${agentId}/skills/pdf_conversion`, + headers: authHeader(token), payload: { level: 3 }, }); expect(addSkill.statusCode).toBe(200); @@ -45,11 +51,16 @@ describe('Support organization — agent skills and availability (User Story 2)' const updateSkill = await app.inject({ method: 'PUT', url: `/admin/agents/${agentId}/skills/pdf_conversion`, + headers: authHeader(token), payload: { level: 5 }, }); expect(updateSkill.statusCode).toBe(200); - const skills = await app.inject({ method: 'GET', url: `/admin/agents/${agentId}/skills` }); + const skills = await app.inject({ + method: 'GET', + url: `/admin/agents/${agentId}/skills`, + headers: authHeader(token), + }); const pdfSkills = skills .json() .data.filter((s: { skillTag: string }) => s.skillTag === 'pdf_conversion'); @@ -59,6 +70,7 @@ describe('Support organization — agent skills and availability (User Story 2)' const setAvailability = await app.inject({ method: 'PUT', url: `/admin/agents/${agentId}/availability`, + headers: authHeader(token), payload: { status: 'busy', workingHours: { mon: '9-17' } }, }); expect(setAvailability.statusCode).toBe(200); @@ -67,6 +79,7 @@ describe('Support organization — agent skills and availability (User Story 2)' const updateAvailability = await app.inject({ method: 'PUT', url: `/admin/agents/${agentId}/availability`, + headers: authHeader(token), payload: { status: 'available', workingHours: { mon: '9-17' } }, }); expect(updateAvailability.statusCode).toBe(200); @@ -74,6 +87,7 @@ describe('Support organization — agent skills and availability (User Story 2)' const current = await app.inject({ method: 'GET', url: `/admin/agents/${agentId}/availability`, + headers: authHeader(token), }); expect(current.json().data.status).toBe('available'); @@ -85,6 +99,7 @@ describe('Support organization — agent skills and availability (User Story 2)' const response = await app.inject({ method: 'PUT', url: `/admin/agents/${agentId}/availability`, + headers: authHeader(token), payload: { status: 'not_a_real_status', workingHours: {} }, }); expect(response.statusCode).toBe(400); diff --git a/tests/integration/support-org-teams-agents.test.ts b/tests/integration/support-org-teams-agents.test.ts index 0e4918b..a095a24 100644 --- a/tests/integration/support-org-teams-agents.test.ts +++ b/tests/integration/support-org-teams-agents.test.ts @@ -2,15 +2,18 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { buildApp } from '@/app'; import { prismaClient } from '@/infrastructure/database'; import { FastifyInstance } from 'fastify'; +import { loginAs, authHeader } from '../helpers/auth'; /** Covers specs/006-support-organization/quickstart.md Scenario 1 against a real Postgres. */ describe('Support organization — teams and agents (User Story 1)', () => { let app: FastifyInstance; + let token: string; const teamName = `Test Team ${Date.now()}`; let teamId: string; beforeAll(async () => { app = await buildApp(); + token = await loginAs(app, 'ADMIN'); }); afterAll(async () => { @@ -27,6 +30,7 @@ describe('Support organization — teams and agents (User Story 1)', () => { const createTeam = await app.inject({ method: 'POST', url: '/admin/teams', + headers: authHeader(token), payload: { name: teamName }, }); expect(createTeam.statusCode).toBe(201); @@ -36,48 +40,63 @@ describe('Support organization — teams and agents (User Story 1)', () => { const createAgent = await app.inject({ method: 'POST', url: `/admin/teams/${teamId}/agents`, + headers: authHeader(token), payload: { name: 'Agent A' }, }); expect(createAgent.statusCode).toBe(201); const agentId = createAgent.json().data.id; - const roster = await app.inject({ method: 'GET', url: `/admin/teams/${teamId}` }); + const roster = await app.inject({ + method: 'GET', + url: `/admin/teams/${teamId}`, + headers: authHeader(token), + }); expect(roster.json().data.agents.map((a: { id: string }) => a.id)).toContain(agentId); await app.inject({ method: 'PATCH', url: `/admin/agents/${agentId}`, + headers: authHeader(token), payload: { active: false }, }); const activeListing = await app.inject({ method: 'GET', url: `/admin/agents?active=true&teamId=${teamId}`, + headers: authHeader(token), }); expect(activeListing.json().data.map((a: { id: string }) => a.id)).not.toContain(agentId); - const directFetch = await app.inject({ method: 'GET', url: `/admin/agents/${agentId}` }); + const directFetch = await app.inject({ + method: 'GET', + url: `/admin/agents/${agentId}`, + headers: authHeader(token), + }); expect(directFetch.statusCode).toBe(200); expect(directFetch.json().data.active).toBe(false); await app.inject({ method: 'PATCH', url: `/admin/agents/${agentId}`, + headers: authHeader(token), payload: { active: true }, }); const reactivatedListing = await app.inject({ method: 'GET', url: `/admin/agents?active=true&teamId=${teamId}`, + headers: authHeader(token), }); expect(reactivatedListing.json().data.map((a: { id: string }) => a.id)).toContain(agentId); await app.inject({ method: 'PATCH', url: `/admin/teams/${teamId}`, + headers: authHeader(token), payload: { active: false }, }); const agentAfterTeamDeactivation = await app.inject({ method: 'GET', url: `/admin/agents/${agentId}`, + headers: authHeader(token), }); expect(agentAfterTeamDeactivation.json().data.active).toBe(true); }); @@ -86,6 +105,7 @@ describe('Support organization — teams and agents (User Story 1)', () => { const response = await app.inject({ method: 'POST', url: '/admin/teams/nonexistent-team-id/agents', + headers: authHeader(token), payload: { name: 'Ghost Agent' }, }); expect(response.statusCode).toBe(404); diff --git a/tests/integration/ticket-attachments.test.ts b/tests/integration/ticket-attachments.test.ts index e6809f5..1e43606 100644 --- a/tests/integration/ticket-attachments.test.ts +++ b/tests/integration/ticket-attachments.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { buildApp } from '@/app'; import { prismaClient } from '@/infrastructure/database'; import { FastifyInstance } from 'fastify'; +import { loginAs, authHeader } from '../helpers/auth'; import { encryptCredential, generateCredentialSecret, @@ -12,12 +13,15 @@ import { malwareScanner } from '@/modules/ticketing/attachments'; /** Covers specs/003-ticketing/quickstart.md Scenario 5 against a real Postgres/Redis/MinIO. */ describe('Ticket attachments — upload, confirm, scan-gated download', () => { let app: FastifyInstance; + let agentToken: string; let ticketId: string; const externalProductId = `TEST_ATT_PROD_${Date.now()}`; beforeAll(async () => { app = await buildApp(); + agentToken = await loginAs(app, 'AGENT'); + const product = await prismaClient.product.create({ data: { externalProductId, name: 'Attachments Test Product', status: 'active' }, }); @@ -70,6 +74,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => { const response = await app.inject({ method: 'POST', url: `/tickets/${ticketId}/attachments/upload-url`, + headers: authHeader(agentToken), payload: { fileName: 'huge.pdf', mimeType: 'application/pdf', sizeBytes: 999_999_999 }, }); expect(response.statusCode).toBe(400); @@ -79,6 +84,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => { const response = await app.inject({ method: 'POST', url: `/tickets/${ticketId}/attachments/upload-url`, + headers: authHeader(agentToken), payload: { fileName: 'evil.exe', mimeType: 'application/x-msdownload', sizeBytes: 100 }, }); expect(response.statusCode).toBe(400); @@ -88,6 +94,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => { const uploadUrlResponse = await app.inject({ method: 'POST', url: `/tickets/${ticketId}/attachments/upload-url`, + headers: authHeader(agentToken), payload: { fileName: 'screenshot.png', mimeType: 'image/png', sizeBytes: 1024 }, }); expect(uploadUrlResponse.statusCode).toBe(200); @@ -103,6 +110,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => { const confirmResponse = await app.inject({ method: 'POST', url: `/tickets/${ticketId}/attachments/confirm`, + headers: authHeader(agentToken), payload: { storageKey, fileName: 'screenshot.png', mimeType: 'image/png', sizeBytes: 1024 }, }); expect(confirmResponse.statusCode).toBe(201); @@ -112,6 +120,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => { const downloadWhilePending = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/attachments/${attachmentId}/download-url`, + headers: authHeader(agentToken), }); expect(downloadWhilePending.statusCode).toBe(409); @@ -126,6 +135,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => { const downloadAfterScan = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/attachments/${attachmentId}/download-url`, + headers: authHeader(agentToken), }); // The placeholder scanner fails closed (always 'infected'), so this remains refused — // proving the pipeline actually gates on a real scan result rather than defaulting open. @@ -141,6 +151,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => { const uploadUrlResponse = await app.inject({ method: 'POST', url: `/tickets/${ticketId}/attachments/upload-url`, + headers: authHeader(agentToken), payload: { fileName: 'clean.pdf', mimeType: 'application/pdf', sizeBytes: 2048 }, }); const { uploadUrl, storageKey } = uploadUrlResponse.json().data; @@ -153,6 +164,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => { const confirmResponse = await app.inject({ method: 'POST', url: `/tickets/${ticketId}/attachments/confirm`, + headers: authHeader(agentToken), payload: { storageKey, fileName: 'clean.pdf', mimeType: 'application/pdf', sizeBytes: 2048 }, }); const attachmentId = confirmResponse.json().data.id; @@ -169,6 +181,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => { const downloadResponse = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/attachments/${attachmentId}/download-url`, + headers: authHeader(agentToken), }); expect(downloadResponse.statusCode).toBe(200); const { downloadUrl } = downloadResponse.json().data; diff --git a/tests/integration/ticket-creation.test.ts b/tests/integration/ticket-creation.test.ts index a78932b..ad4745d 100644 --- a/tests/integration/ticket-creation.test.ts +++ b/tests/integration/ticket-creation.test.ts @@ -7,6 +7,7 @@ import { generateCredentialSecret, issueIntegrationToken, } from '@/modules/catalog/products'; +import { loginAs, authHeader } from '../helpers/auth'; /** * Covers specs/003-ticketing/quickstart.md Scenarios 1, 2, 3, 6 end-to-end against a real @@ -15,10 +16,12 @@ import { describe('Ticket creation via the inbound trust boundary', () => { let app: FastifyInstance; let secret: string; + let agentToken: string; const externalProductId = `TEST_TICKET_PROD_${Date.now()}`; beforeAll(async () => { app = await buildApp(); + agentToken = await loginAs(app, 'AGENT'); const product = await prismaClient.product.create({ data: { externalProductId, name: 'Ticket Test Product', status: 'active' }, @@ -122,11 +125,13 @@ describe('Ticket creation via the inbound trust boundary', () => { const first = await app.inject({ method: 'PATCH', url: `/tickets/${ticketId}/status`, + headers: authHeader(agentToken), payload: { status: 'AI_ANALYZING', expectedVersion: ticket.version }, }); const second = await app.inject({ method: 'PATCH', url: `/tickets/${ticketId}/status`, + headers: authHeader(agentToken), payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version }, }); @@ -145,6 +150,7 @@ describe('Ticket creation via the inbound trust boundary', () => { const response = await app.inject({ method: 'PATCH', url: `/tickets/${ticketId}/status`, + headers: authHeader(agentToken), payload: { status: 'RESOLVED', expectedVersion: ticket.version }, }); diff --git a/tests/integration/ticket-messages.test.ts b/tests/integration/ticket-messages.test.ts index cea77b1..1d77ca3 100644 --- a/tests/integration/ticket-messages.test.ts +++ b/tests/integration/ticket-messages.test.ts @@ -8,15 +8,18 @@ import { issueIntegrationToken, } from '@/modules/catalog/products'; import { MESSAGE_TYPES } from '@/modules/ticketing/messages/mapper/message-visibility'; +import { loginAs, authHeader } from '../helpers/auth'; /** Covers specs/003-ticketing/quickstart.md Scenario 4 against a real Postgres. */ describe('Ticket messages — type-scoped visibility', () => { let app: FastifyInstance; let ticketId: string; + let authToken: string; const externalProductId = `TEST_MSG_PROD_${Date.now()}`; beforeAll(async () => { app = await buildApp(); + authToken = await loginAs(app, 'AGENT'); const product = await prismaClient.product.create({ data: { externalProductId, name: 'Messages Test Product', status: 'active' }, @@ -57,6 +60,7 @@ describe('Ticket messages — type-scoped visibility', () => { const response = await app.inject({ method: 'POST', url: `/tickets/${ticketId}/messages`, + headers: authHeader(authToken), payload: { type, body: `Message of type ${type}` }, }); expect(response.statusCode).toBe(201); @@ -75,7 +79,11 @@ describe('Ticket messages — type-scoped visibility', () => { }); it('a customer-scoped read excludes internal-only types entirely', async () => { - const response = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/messages` }); + const response = await app.inject({ + method: 'GET', + url: `/tickets/${ticketId}/messages`, + headers: authHeader(authToken), + }); expect(response.statusCode).toBe(200); const types = response.json().data.map((m: { type: string }) => m.type); @@ -92,6 +100,7 @@ describe('Ticket messages — type-scoped visibility', () => { const response = await app.inject({ method: 'GET', url: `/agent/tickets/${ticketId}/messages`, + headers: authHeader(authToken), }); expect(response.statusCode).toBe(200); const types = response.json().data.map((m: { type: string }) => m.type); @@ -107,6 +116,7 @@ describe('Ticket messages — type-scoped visibility', () => { const response = await app.inject({ method: 'POST', url: `/tickets/${ticketId}/messages`, + headers: authHeader(authToken), payload: { type: 'NOT_A_REAL_TYPE', body: 'x' }, }); expect(response.statusCode).toBe(400); diff --git a/tests/unit/identity/login-failure-parity.test.ts b/tests/unit/identity/login-failure-parity.test.ts new file mode 100644 index 0000000..c0e45c4 --- /dev/null +++ b/tests/unit/identity/login-failure-parity.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi } from 'vitest'; +import bcrypt from 'bcryptjs'; +import { AuthService } from '@/modules/identity/auth/service/auth.service'; + +const REAL_PASSWORD_HASH = bcrypt.hashSync('the-real-password', 10); + +function fakeUser(overrides: Partial> = {}) { + return { + id: 'u1', + email: 'agent@example.com', + name: 'Agent', + role: 'AGENT', + passwordHash: REAL_PASSWORD_HASH, + active: true, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe('AuthService.login failure parity', () => { + it('throws the identical error for a nonexistent email and a wrong password', async () => { + const repoFoundUser = { findByEmail: vi.fn().mockResolvedValue(fakeUser()) } as never; + const repoNoUser = { findByEmail: vi.fn().mockResolvedValue(null) } as never; + + const serviceWithUser = new AuthService(repoFoundUser); + const serviceWithoutUser = new AuthService(repoNoUser); + + let errorWithUser: Error | undefined; + let errorWithoutUser: Error | undefined; + + try { + await serviceWithUser.login({ email: 'agent@example.com', password: 'definitely-wrong' }); + } catch (e) { + errorWithUser = e as Error; + } + + try { + await serviceWithoutUser.login({ email: 'nobody@example.com', password: 'anything' }); + } catch (e) { + errorWithoutUser = e as Error; + } + + expect(errorWithUser).toBeDefined(); + expect(errorWithoutUser).toBeDefined(); + expect(errorWithUser?.message).toBe(errorWithoutUser?.message); + expect((errorWithUser as { statusCode?: number })?.statusCode).toBe( + (errorWithoutUser as { statusCode?: number })?.statusCode, + ); + }); + + it('rejects a deactivated account with the same error, never a distinguishable one', async () => { + const repo = { + findByEmail: vi.fn().mockResolvedValue(fakeUser({ active: false })), + } as never; + const service = new AuthService(repo); + + await expect( + service.login({ email: 'agent@example.com', password: 'anything' }), + ).rejects.toMatchObject({ statusCode: 401 }); + }); +}); diff --git a/tests/unit/identity/require-role.test.ts b/tests/unit/identity/require-role.test.ts new file mode 100644 index 0000000..8e08c29 --- /dev/null +++ b/tests/unit/identity/require-role.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { FastifyReply, FastifyRequest } from 'fastify'; +import { requireRole } from '@/modules/identity/auth/service/require-role'; + +function fakeRequest(user?: { role: string }): FastifyRequest { + return { user } as unknown as FastifyRequest; +} + +describe('requireRole', () => { + it('passes when the session role is in the allowed list', async () => { + const guard = requireRole('ADMIN'); + await expect( + guard(fakeRequest({ role: 'ADMIN' }), {} as FastifyReply), + ).resolves.toBeUndefined(); + }); + + it('throws AuthorizationError when the session role is not in the allowed list', async () => { + const guard = requireRole('ADMIN'); + await expect(guard(fakeRequest({ role: 'AGENT' }), {} as FastifyReply)).rejects.toMatchObject({ + statusCode: 403, + }); + }); + + it('throws AuthorizationError when there is no session at all', async () => { + const guard = requireRole('ADMIN'); + await expect(guard(fakeRequest(undefined), {} as FastifyReply)).rejects.toMatchObject({ + statusCode: 403, + }); + }); + + it('accepts any role in a multi-role allow list', async () => { + const guard = requireRole('ADMIN', 'AGENT'); + await expect( + guard(fakeRequest({ role: 'AGENT' }), {} as FastifyReply), + ).resolves.toBeUndefined(); + }); +}); diff --git a/tests/unit/orchestration/escalation-rule-match.test.ts b/tests/unit/orchestration/escalation-rule-match.test.ts index c2c88d9..cfa1c08 100644 --- a/tests/unit/orchestration/escalation-rule-match.test.ts +++ b/tests/unit/orchestration/escalation-rule-match.test.ts @@ -20,15 +20,16 @@ describe('EscalationService.handleBreach', () => { } as never; const rules = { findActiveRules: vi.fn().mockResolvedValue([rule]) } as never; const events = { create: vi.fn().mockResolvedValue({ id: 'event-1' }) } as never; - const assignmentEngine = { assignToSpecificNode: vi.fn().mockResolvedValue(undefined) } as never; + const assignmentEngine = { + assignToSpecificNode: vi.fn().mockResolvedValue(undefined), + } as never; const service = new EscalationService(policies, rules, events, assignmentEngine); await service.handleBreach('t1', 'resolution_breach'); - expect((rules as { findActiveRules: ReturnType }).findActiveRules).toHaveBeenCalledWith( - 'policy-1', - 'resolution_breach', - ); + expect( + (rules as { findActiveRules: ReturnType }).findActiveRules, + ).toHaveBeenCalledWith('policy-1', 'resolution_breach'); expect((events as { create: ReturnType }).create).toHaveBeenCalledWith( expect.objectContaining({ ticketId: 't1', ruleId: 'rule-1', toNodeId: 'node-1' }), ); @@ -46,7 +47,9 @@ describe('EscalationService.handleBreach', () => { const service = new EscalationService(policies, rules, events, assignmentEngine); await service.handleBreach('t1', 'resolution_breach'); - expect((rules as { findActiveRules: ReturnType }).findActiveRules).not.toHaveBeenCalled(); + expect( + (rules as { findActiveRules: ReturnType }).findActiveRules, + ).not.toHaveBeenCalled(); expect((events as { create: ReturnType }).create).not.toHaveBeenCalled(); }); diff --git a/tests/unit/orchestration/sla-breach-detection.test.ts b/tests/unit/orchestration/sla-breach-detection.test.ts index 379ec0d..57e64d6 100644 --- a/tests/unit/orchestration/sla-breach-detection.test.ts +++ b/tests/unit/orchestration/sla-breach-detection.test.ts @@ -34,10 +34,7 @@ describe('SlaService.runBreachDetectionSweep', () => { const service = new SlaService(undefined, runsRepo, undefined, undefined, escalation); await service.runBreachDetectionSweep(); - expect(update).toHaveBeenCalledWith( - 'r1', - expect.objectContaining({ status: 'breached' }), - ); + expect(update).toHaveBeenCalledWith('r1', expect.objectContaining({ status: 'breached' })); expect(handleBreach).toHaveBeenCalledWith('t1', 'resolution_breach'); }); diff --git a/tests/unit/orchestration/sla-pause-resume.test.ts b/tests/unit/orchestration/sla-pause-resume.test.ts index 0fab623..bcd9d3b 100644 --- a/tests/unit/orchestration/sla-pause-resume.test.ts +++ b/tests/unit/orchestration/sla-pause-resume.test.ts @@ -53,8 +53,10 @@ describe('SlaService pause/resume', () => { expect(patch.pausedAt).toBeNull(); const shiftedResolution = (patch.resolutionDueAt as Date).getTime(); - const expectedShiftMin = new Date('2026-01-05T17:00:00.000Z').getTime() + (before - pausedAt.getTime()); - const expectedShiftMax = new Date('2026-01-05T17:00:00.000Z').getTime() + (after - pausedAt.getTime()); + const expectedShiftMin = + new Date('2026-01-05T17:00:00.000Z').getTime() + (before - pausedAt.getTime()); + const expectedShiftMax = + new Date('2026-01-05T17:00:00.000Z').getTime() + (after - pausedAt.getTime()); expect(shiftedResolution).toBeGreaterThanOrEqual(expectedShiftMin); expect(shiftedResolution).toBeLessThanOrEqual(expectedShiftMax); }); @@ -67,7 +69,11 @@ describe('SlaService pause/resume', () => { }); const secondPausedAt = new Date(Date.now() - 10 * 60 * 1000); - const pausedAgain = { ...afterFirstResume, status: 'paused', pausedAt: secondPausedAt } as SLARun; + const pausedAgain = { + ...afterFirstResume, + status: 'paused', + pausedAt: secondPausedAt, + } as SLARun; const update = vi.fn().mockResolvedValue(pausedAgain); const runsRepo = { findByTicketId: vi.fn().mockResolvedValue(pausedAgain), update } as never; const service = new SlaService(undefined, runsRepo); diff --git a/tests/unit/orchestration/strategies.test.ts b/tests/unit/orchestration/strategies.test.ts index f208d9c..2e78293 100644 --- a/tests/unit/orchestration/strategies.test.ts +++ b/tests/unit/orchestration/strategies.test.ts @@ -16,6 +16,7 @@ function fakeAgent(id: string) { teamId: 't', name: id, active: true, + userId: null, createdAt: new Date(), updatedAt: new Date(), skills: [], diff --git a/tests/unit/platform/business-calendars/calendar-walk.test.ts b/tests/unit/platform/business-calendars/calendar-walk.test.ts index 64edd93..68bd0a0 100644 --- a/tests/unit/platform/business-calendars/calendar-walk.test.ts +++ b/tests/unit/platform/business-calendars/calendar-walk.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from 'vitest'; -import { addBusinessMinutes, isWithinWorkingHours } from '@/modules/platform/business-calendars/calculators/business-hours.calculator'; +import { + addBusinessMinutes, + isWithinWorkingHours, +} from '@/modules/platform/business-calendars/calculators/business-hours.calculator'; const MON_FRI_9_TO_5 = { timezone: 'America/New_York', @@ -66,7 +69,9 @@ describe('addBusinessMinutes', () => { it('returns the start time unchanged when minutes is zero or negative', () => { const start = new Date(Date.UTC(2026, 0, 5, 15, 0)); - expect(addBusinessMinutes(start, 0, MON_FRI_9_TO_5, []).toISOString()).toBe(start.toISOString()); + expect(addBusinessMinutes(start, 0, MON_FRI_9_TO_5, []).toISOString()).toBe( + start.toISOString(), + ); }); it('is correct across a DST transition (US spring-forward, March 2026)', () => { diff --git a/tests/unit/problem-management/root-cause-schema.test.ts b/tests/unit/problem-management/root-cause-schema.test.ts index 7d3350f..512b773 100644 --- a/tests/unit/problem-management/root-cause-schema.test.ts +++ b/tests/unit/problem-management/root-cause-schema.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from 'vitest'; -import { createRootCauseSchema, ROOT_CAUSE_TYPES } from '@/modules/problem-management/root-causes/schema/root-cause.schema'; +import { + createRootCauseSchema, + ROOT_CAUSE_TYPES, +} from '@/modules/problem-management/root-causes/schema/root-cause.schema'; describe('createRootCauseSchema', () => { it('accepts every documented root cause type', () => {