feat(010-identity-auth): real staff login, session verification, and role gating

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 <noreply@anthropic.com>
This commit is contained in:
saqib mir
2026-09-07 12:45:37 +05:30
co-authored by Claude Sonnet 5
parent 8327fafac2
commit 40687f68fa
82 changed files with 1502 additions and 209 deletions
+138
View File
@@ -19,11 +19,13 @@
"@opentelemetry/api": "^1.8.0", "@opentelemetry/api": "^1.8.0",
"@opentelemetry/sdk-trace-base": "^1.22.0", "@opentelemetry/sdk-trace-base": "^1.22.0",
"@prisma/client": "^5.12.1", "@prisma/client": "^5.12.1",
"bcryptjs": "^3.0.3",
"bullmq": "^5.7.1", "bullmq": "^5.7.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"fastify": "^4.26.2", "fastify": "^4.26.2",
"fastify-plugin": "^4.5.1", "fastify-plugin": "^4.5.1",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.3",
"luxon": "^3.7.2", "luxon": "^3.7.2",
"pino": "^8.20.0", "pino": "^8.20.0",
"pino-pretty": "^11.0.0", "pino-pretty": "^11.0.0",
@@ -31,6 +33,8 @@
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/luxon": "^3.7.5", "@types/luxon": "^3.7.5",
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"@typescript-eslint/eslint-plugin": "^7.6.0", "@typescript-eslint/eslint-plugin": "^7.6.0",
@@ -1968,6 +1972,13 @@
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT" "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": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1975,6 +1986,17 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/luxon": {
"version": "3.7.5", "version": "3.7.5",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.5.tgz", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.5.tgz",
@@ -1982,6 +2004,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "20.19.43", "version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
@@ -2547,6 +2576,15 @@
], ],
"license": "MIT" "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": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -2618,6 +2656,12 @@
"ieee754": "^1.2.1" "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": { "node_modules/bullmq": {
"version": "5.81.3", "version": "5.81.3",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.81.3.tgz", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.81.3.tgz",
@@ -3070,6 +3114,15 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT" "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": { "node_modules/emoji-regex": {
"version": "9.2.2", "version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -4319,6 +4372,49 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -4544,6 +4640,42 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -4551,6 +4683,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/log-update": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
+4
View File
@@ -56,11 +56,13 @@
"@opentelemetry/api": "^1.8.0", "@opentelemetry/api": "^1.8.0",
"@opentelemetry/sdk-trace-base": "^1.22.0", "@opentelemetry/sdk-trace-base": "^1.22.0",
"@prisma/client": "^5.12.1", "@prisma/client": "^5.12.1",
"bcryptjs": "^3.0.3",
"bullmq": "^5.7.1", "bullmq": "^5.7.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"fastify": "^4.26.2", "fastify": "^4.26.2",
"fastify-plugin": "^4.5.1", "fastify-plugin": "^4.5.1",
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.3",
"luxon": "^3.7.2", "luxon": "^3.7.2",
"pino": "^8.20.0", "pino": "^8.20.0",
"pino-pretty": "^11.0.0", "pino-pretty": "^11.0.0",
@@ -68,6 +70,8 @@
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/luxon": "^3.7.5", "@types/luxon": "^3.7.5",
"@types/node": "^20.12.7", "@types/node": "^20.12.7",
"@typescript-eslint/eslint-plugin": "^7.6.0", "@typescript-eslint/eslint-plugin": "^7.6.0",
@@ -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;
+10
View File
@@ -18,9 +18,14 @@ model User {
email String @unique email String @unique
name String name String
role UserRole @default(CUSTOMER) 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
agent Agent?
@@map("users") @@map("users")
} }
@@ -417,6 +422,11 @@ model Agent {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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[] skills AgentSkill[]
availability AgentAvailability? availability AgentAvailability?
assignments Assignment[] assignments Assignment[]
+7
View File
@@ -1,9 +1,15 @@
import { randomUUID } from 'crypto';
import { PrismaClient, UserRole } from '@prisma/client'; import { PrismaClient, UserRole } from '@prisma/client';
import bcrypt from 'bcryptjs';
export async function seedDemoData(prisma: PrismaClient): Promise<void> { export async function seedDemoData(prisma: PrismaClient): Promise<void> {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(' -> Seeding demo environment data...'); 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({ await prisma.user.upsert({
where: { email: 'john.doe@example.com' }, where: { email: 'john.doe@example.com' },
update: {}, update: {},
@@ -11,6 +17,7 @@ export async function seedDemoData(prisma: PrismaClient): Promise<void> {
email: 'john.doe@example.com', email: 'john.doe@example.com',
name: 'John Doe (Demo Customer)', name: 'John Doe (Demo Customer)',
role: UserRole.CUSTOMER, role: UserRole.CUSTOMER,
passwordHash: await bcrypt.hash(randomUUID(), 10),
}, },
}); });
} }
+8
View File
@@ -1,4 +1,10 @@
import { PrismaClient, UserRole } from '@prisma/client'; 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<void> { export async function seedRoles(prisma: PrismaClient): Promise<void> {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@@ -11,6 +17,7 @@ export async function seedRoles(prisma: PrismaClient): Promise<void> {
email: 'admin@supporthub.internal', email: 'admin@supporthub.internal',
name: 'System Admin', name: 'System Admin',
role: UserRole.ADMIN, role: UserRole.ADMIN,
passwordHash: await bcrypt.hash(DEV_ADMIN_PASSWORD, 10),
}, },
}); });
@@ -21,6 +28,7 @@ export async function seedRoles(prisma: PrismaClient): Promise<void> {
email: 'agent@supporthub.internal', email: 'agent@supporthub.internal',
name: 'Default Support Agent', name: 'Default Support Agent',
role: UserRole.AGENT, role: UserRole.AGENT,
passwordHash: await bcrypt.hash(DEV_AGENT_PASSWORD, 10),
}, },
}); });
} }
@@ -46,3 +46,23 @@
registration are explicitly out of scope (Assumptions), matching Phase 11's own "security registration are explicitly out of scope (Assumptions), matching Phase 11's own "security
hardening pass" as the more appropriate later home for those. hardening pass" as the more appropriate later home for those.
- All items pass; no revision iterations were needed. - 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.
+13
View File
@@ -14,6 +14,19 @@
extending `User` — rejected; `User` already has exactly the fields a staff account needs 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. (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: `jsonwebtoken` for signing/verifying, `bcryptjs` for password hashing
- **Decision**: Add `jsonwebtoken` (plain library, no Fastify plugin registration — kept - **Decision**: Add `jsonwebtoken` (plain library, no Fastify plugin registration — kept
+36 -36
View File
@@ -27,13 +27,13 @@ All file paths are relative to `supporthub-api/` (repo root).
## Phase 1: Setup ## 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` `@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`, `AUTH_TOKEN_LIFETIME_HOURS` (`z.coerce.number().default(4)`) to `src/config/env.ts`,
exposed via a new `src/config/auth.ts` (`authConfig.jwtSecret`, exposed via a new `src/config/auth.ts` (`authConfig.jwtSecret`,
`authConfig.tokenLifetimeHours`), matching `orchestrationConfig`'s exact shape `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`/ existing files, replacing the email-only `AuthService.validateCredentials`/
`AuthRepository.findByEmail`-only stub content `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. **⚠️ 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 @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) `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) 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 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, the plaintext dev password in a comment directly above the hash call (local/dev use only,
per spec.md Edge Cases) (depends on T005) per spec.md Edge Cases) (depends on T005)
@@ -69,29 +69,29 @@ regardless of which reason login failed.
### Tests for User Story 1 ### 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 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 shape/status in the non-matching and no-user cases — in
`tests/unit/identity/login-failure-parity.test.ts` `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 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) real Postgres in `tests/integration/identity-auth-flow.test.ts` (depends on T006)
### Implementation for User Story 1 ### 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 (jsonwebtoken, embedding `sub`/`email`/`role`/`actorType`/`jti`/`iat`/`exp` per
data-model.md) in `identity/auth/mapper/` (depends on T002) 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) `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 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 parity), returns `{ token, user }` or throws a single, identical `AuthenticationError` for
every failure branch — in `identity/auth/service/` (depends on T009, T010) 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 email-only schema) and controller in `identity/auth/schema/` + `controller/`, registered
from `src/api/routes.ts` (depends on T011) 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. **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 ### 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 role throws `AuthorizationError`, no `request.user` at all throws) in
`tests/unit/identity/require-role.test.ts` `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 wrong-role token, correct-role token) against a real Postgres/Redis in
`tests/integration/identity-auth-flow.test.ts` (depends on T008) `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 (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) 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` 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 ### 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 `src/infrastructure/cache/`, alongside the existing `hasSeenJti`/`markJtiSeen` (same
Redis-key-with-TTL shape, research.md) (depends on T002) 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 expiry, check T017's revocation denylist, and on success set `request.user` (the full
`AuthUser`) and `request.reqContext.actorId`/`actorType` — throw `AuthenticationError` on `AuthUser`) and `request.reqContext.actorId`/`actorType` — throw `AuthenticationError` on
any failure, never pass through as anonymous (depends on T009, T017) 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 `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/service/` (or a dedicated `identity/auth/guards/` file), exported from
`identity/auth`'s public `index.ts` (depends on T018) `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 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 admin, knowledge admin, teams/hierarchy admin, SLA/escalation-policy admin) — read-only
routes and ticket-working routes an agent legitimately uses stay `fastify.authenticate`- 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, only (research.md's own scoping: this is a mechanical pass applying an existing judgment,
not a new design) (depends on T019) 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 **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. 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 ### 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 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) specifically) — in `tests/integration/identity-auth-flow.test.ts` (depends on T021)
### Implementation for User Story 3 ### 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/ `AuthenticationError` if it no longer exists or `active: false` — in `identity/auth/
service/` (depends on T010) 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) 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. **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 ### 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 it logs in immediately; non-admin rejected; duplicate email rejected) — in
`tests/integration/identity-auth-flow.test.ts` (depends on T021) `tests/integration/identity-auth-flow.test.ts` (depends on T021)
### Implementation for User Story 4 ### 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 duplicate email, hashes the password via T009) in `identity/agents/service/` (research.md
— account creation lives alongside `identity/agents`'s own roster CRUD, not — account creation lives alongside `identity/agents`'s own roster CRUD, not
`identity/auth`) (depends on T009) `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 `requireRole('ADMIN')`) in `identity/agents/controller/` + `routes/`, registered from
`src/api/routes.ts` — response never includes the password or hash (depends on T019, T027) `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. **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 ### 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` token is rejected immediately afterward) — in `tests/integration/identity-auth-flow.test.ts`
(depends on T021) (depends on T021)
### Implementation for User Story 5 ### 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) — 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) `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 **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. 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 ## 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 implementation-time findings
- [ ] T035 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck` - [x] 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] 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 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 T020 adds `requireRole` to their existing routes) against real Docker-provisioned
Postgres/Redis Postgres/Redis
+2
View File
@@ -23,9 +23,11 @@ import { rootCausesRoutes } from '@/modules/problem-management/root-causes';
import { solutionsRoutes } from '@/modules/problem-management/solutions'; import { solutionsRoutes } from '@/modules/problem-management/solutions';
import { verificationRoutes } from '@/modules/problem-management/verification'; import { verificationRoutes } from '@/modules/problem-management/verification';
import { resolutionsRoutes } from '@/modules/problem-management/resolutions'; import { resolutionsRoutes } from '@/modules/problem-management/resolutions';
import { authRoutes } from '@/modules/identity/auth';
export async function registerGlobalRoutes(app: FastifyInstance): Promise<void> { export async function registerGlobalRoutes(app: FastifyInstance): Promise<void> {
await app.register(healthRoutes); await app.register(healthRoutes);
await app.register(authRoutes);
await app.register(metricsRoutes); await app.register(metricsRoutes);
await app.register(productsRoutes); await app.register(productsRoutes);
await app.register(inboundRequestRoutes); await app.register(inboundRequestRoutes);
+1
View File
@@ -12,6 +12,7 @@ export interface JwtPayload {
email: string; email: string;
role: string; role: string;
actorType: ActorType; actorType: ActorType;
jti: string; // revocation-denylist key — see specs/010-identity-auth/research.md
iat?: number; iat?: number;
exp?: number; exp?: number;
} }
+6
View File
@@ -0,0 +1,6 @@
import { env } from './env';
export const authConfig = {
jwtSecret: env.JWT_SECRET,
tokenLifetimeHours: env.AUTH_TOKEN_LIFETIME_HOURS,
};
+5
View File
@@ -60,6 +60,11 @@ const envSchema = z.object({
// explicit customer confirmation before the auto-close sweep resolves it — see // explicit customer confirmation before the auto-close sweep resolves it — see
// specs/009-problem-resolution/research.md "auto-close waiting period". // specs/009-problem-resolution/research.md "auto-close waiting period".
RESOLUTION_AUTO_CLOSE_WAITING_HOURS: z.coerce.number().default(72), 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<typeof envSchema>; export type EnvConfig = z.infer<typeof envSchema>;
+1
View File
@@ -6,3 +6,4 @@ export * from './storage';
export * from './ai'; export * from './ai';
export * from './orchestration'; export * from './orchestration';
export * from './problem-resolution'; export * from './problem-resolution';
export * from './auth';
+17
View File
@@ -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<boolean> {
return cacheService.exists(`${REVOKED_KEY_PREFIX}${jti}`);
}
export async function revokeToken(jti: string, ttlSeconds: number): Promise<void> {
await cacheService.set(`${REVOKED_KEY_PREFIX}${jti}`, '1', ttlSeconds);
}
+1
View File
@@ -2,3 +2,4 @@ export * from './redis.client';
export * from './cache.service'; export * from './cache.service';
export * from './replay-guard'; export * from './replay-guard';
export * from './rate-limiter'; export * from './rate-limiter';
export * from './auth-revocation';
+6 -1
View File
@@ -19,7 +19,12 @@ export function registerSlaWorker(): void {
void queueManager.getQueue(QueueName.SLA).add( void queueManager.getQueue(QueueName.SLA).add(
'detect-breaches', '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 } }, { repeat: { every: BREACH_DETECTION_INTERVAL_MS } },
); );
} }
@@ -1,35 +1,38 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { requireRole } from '@/modules/identity/auth';
import { knowledgeController, errorCodesController, runbooksController } from '../controller'; import { knowledgeController, errorCodesController, runbooksController } from '../controller';
/** /**
* Admin routes gated by fastify.authenticate (research.md — known limitation inherited from * Admin write routes gated by fastify.authenticate + requireRole('ADMIN'), now real
* 002/003). /knowledge/retrieve is intentionally NOT gated — it's a read path the future * (010-identity-auth). Reads stay agent-usable (fastify.authenticate only).
* AI-support feature will call, not an admin surface (research.md "Admin endpoint * /knowledge/retrieve is intentionally NOT gated — it's a read path the future AI-support
* authentication"). * feature will call, not an admin surface (research.md "Admin endpoint authentication").
*/ */
export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> { export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post( fastify.post(
'/admin/products/:externalProductId/knowledge', '/admin/products/:externalProductId/knowledge',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => knowledgeController.create(req, reply), (req, reply) => knowledgeController.create(req, reply),
); );
fastify.patch( fastify.patch(
'/admin/knowledge/:code/publish', '/admin/knowledge/:code/publish',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => knowledgeController.publish(req, reply), (req, reply) => knowledgeController.publish(req, reply),
); );
fastify.patch( fastify.patch(
'/admin/knowledge/:code/unpublish', '/admin/knowledge/:code/unpublish',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => knowledgeController.unpublish(req, reply), (req, reply) => knowledgeController.unpublish(req, reply),
); );
fastify.patch( fastify.patch(
'/admin/knowledge/:code/validate', '/admin/knowledge/:code/validate',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => knowledgeController.validate(req, reply), (req, reply) => knowledgeController.validate(req, reply),
); );
fastify.put('/admin/knowledge/:code', { preHandler: fastify.authenticate }, (req, reply) => fastify.put(
knowledgeController.edit(req, reply), '/admin/knowledge/:code',
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => knowledgeController.edit(req, reply),
); );
fastify.get( fastify.get(
'/admin/knowledge/:code/versions', '/admin/knowledge/:code/versions',
@@ -39,12 +42,12 @@ export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post( fastify.post(
'/admin/products/:externalProductId/error-codes', '/admin/products/:externalProductId/error-codes',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => errorCodesController.createErrorCode(req, reply), (req, reply) => errorCodesController.createErrorCode(req, reply),
); );
fastify.post( fastify.post(
'/admin/products/:externalProductId/known-issues', '/admin/products/:externalProductId/known-issues',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => errorCodesController.createKnownIssue(req, reply), (req, reply) => errorCodesController.createKnownIssue(req, reply),
); );
fastify.get( fastify.get(
@@ -55,7 +58,7 @@ export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post( fastify.post(
'/admin/products/:externalProductId/runbooks', '/admin/products/:externalProductId/runbooks',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => runbooksController.create(req, reply), (req, reply) => runbooksController.create(req, reply),
); );
fastify.get( fastify.get(
@@ -65,12 +68,12 @@ export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> {
); );
fastify.put( fastify.put(
'/admin/products/:externalProductId/runbooks/:key', '/admin/products/:externalProductId/runbooks/:key',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => runbooksController.edit(req, reply), (req, reply) => runbooksController.edit(req, reply),
); );
fastify.patch( fastify.patch(
'/admin/products/:externalProductId/runbooks/:key/deactivate', '/admin/products/:externalProductId/runbooks/:key/deactivate',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => runbooksController.deactivate(req, reply), (req, reply) => runbooksController.deactivate(req, reply),
); );
@@ -1,16 +1,17 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { requireRole } from '@/modules/identity/auth';
import { confidencePolicyController, sessionController } from '../controller'; import { confidencePolicyController, sessionController } from '../controller';
/** /**
* contracts/ai-support-contract.md: admin confidence-policy routes gated by * contracts/ai-support-contract.md: the admin confidence-policy write route is gated by
* fastify.authenticate (known limitation inherited from 002/003/004). Session-turn routes are * fastify.authenticate + requireRole('ADMIN'), now real (010-identity-auth). Session-turn
* not admin routes — called by the ticket-owning caller, same as 003-ticketing's * 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. * POST/GET .../messages, and carry no additional gate of their own in this feature.
*/ */
export async function sessionsRoutes(fastify: FastifyInstance): Promise<void> { export async function sessionsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.put( fastify.put(
'/admin/products/:externalProductId/ai-policy', '/admin/products/:externalProductId/ai-policy',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => confidencePolicyController.upsert(req, reply), (req, reply) => confidencePolicyController.upsert(req, reply),
); );
fastify.get( fastify.get(
@@ -1,34 +1,35 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { requireRole } from '@/modules/identity/auth';
import { productIntegrationsController } from '../controller'; import { productIntegrationsController } from '../controller';
/** /**
* Admin lifecycle endpoints for ProductIntegration (register/rotate/revoke/status/audit-trail). * Admin lifecycle endpoints for ProductIntegration (register/rotate/revoke/status/audit-trail).
* Gated by the existing human/admin JWT plugin (fastify.authenticate) — see * Gated by fastify.authenticate + requireRole('ADMIN') — real as of 010-identity-auth (see
* specs/002-saas-integration/contracts/inbound-request-contract.md "Admin: Integration Lifecycle * specs/002-saas-integration/contracts/inbound-request-contract.md for the now-resolved known
* Endpoints" for the known limitation that this decorator doesn't perform real verification yet. * limitation this decorator previously didn't perform real verification).
*/ */
export async function productIntegrationsAdminRoutes(fastify: FastifyInstance): Promise<void> { export async function productIntegrationsAdminRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post( fastify.post(
'/admin/products/:externalProductId/integration', '/admin/products/:externalProductId/integration',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => productIntegrationsController.register(req, reply), (req, reply) => productIntegrationsController.register(req, reply),
); );
fastify.post( fastify.post(
'/admin/integrations/:integrationId/rotate', '/admin/integrations/:integrationId/rotate',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => productIntegrationsController.rotate(req, reply), (req, reply) => productIntegrationsController.rotate(req, reply),
); );
fastify.post( fastify.post(
'/admin/integrations/:integrationId/revoke', '/admin/integrations/:integrationId/revoke',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => productIntegrationsController.revoke(req, reply), (req, reply) => productIntegrationsController.revoke(req, reply),
); );
fastify.patch( fastify.patch(
'/admin/integrations/:integrationId/status', '/admin/integrations/:integrationId/status',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => productIntegrationsController.updateStatus(req, reply), (req, reply) => productIntegrationsController.updateStatus(req, reply),
); );
@@ -6,6 +6,8 @@ import {
AgentSkillsService, AgentSkillsService,
agentAvailabilityService, agentAvailabilityService,
AgentAvailabilityService, AgentAvailabilityService,
usersService,
UsersService,
} from '../service'; } from '../service';
import { import {
createAgentSchema, createAgentSchema,
@@ -13,6 +15,7 @@ import {
listAgentsQuerySchema, listAgentsQuerySchema,
upsertAgentSkillSchema, upsertAgentSkillSchema,
upsertAgentAvailabilitySchema, upsertAgentAvailabilitySchema,
createUserSchema,
} from '../schema'; } from '../schema';
export class AgentsController { export class AgentsController {
@@ -20,6 +23,7 @@ export class AgentsController {
private readonly service: AgentsService = agentsService, private readonly service: AgentsService = agentsService,
private readonly skills: AgentSkillsService = agentSkillsService, private readonly skills: AgentSkillsService = agentSkillsService,
private readonly availability: AgentAvailabilityService = agentAvailabilityService, private readonly availability: AgentAvailabilityService = agentAvailabilityService,
private readonly users: UsersService = usersService,
) {} ) {}
async create(request: FastifyRequest, reply: FastifyReply) { async create(request: FastifyRequest, reply: FastifyReply) {
@@ -73,6 +77,13 @@ export class AgentsController {
const record = await this.availability.getForAgent(agentId); const record = await this.availability.getForAgent(agentId);
return reply.status(200).send({ success: true, data: record, meta: null }); 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(); export const agentsController = new AgentsController();
@@ -1,3 +1,4 @@
export * from './agents.repository'; export * from './agents.repository';
export * from './agent-skills.repository'; export * from './agent-skills.repository';
export * from './agent-availability.repository'; export * from './agent-availability.repository';
export * from './users.repository';
@@ -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<User | null> {
return this.prisma.user.findUnique({ where: { email } });
}
async create(data: CreateUserData): Promise<User> {
return this.prisma.user.create({ data });
}
}
export const usersRepository = new UsersRepository();
@@ -1,9 +1,17 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { requireRole } from '@/modules/identity/auth';
import { agentsController } from '../controller'; import { agentsController } from '../controller';
/** Admin routes gated by fastify.authenticate — known limitation inherited from 002/003/004/005 /** Admin routes gated by fastify.authenticate — real as of 010-identity-auth (previously a
* (research.md "Admin endpoint authentication"). */ * 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<void> { export async function agentsRoutes(fastify: FastifyInstance): Promise<void> {
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) => fastify.post('/admin/teams/:teamId/agents', { preHandler: fastify.authenticate }, (req, reply) =>
agentsController.create(req, reply), agentsController.create(req, reply),
); );
@@ -1,3 +1,4 @@
export * from './agents.schema'; export * from './agents.schema';
export * from './agent-skills.schema'; export * from './agent-skills.schema';
export * from './agent-availability.schema'; export * from './agent-availability.schema';
export * from './users.schema';
@@ -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<typeof createUserSchema>;
@@ -1,3 +1,4 @@
export * from './agents.service'; export * from './agents.service';
export * from './agent-skills.service'; export * from './agent-skills.service';
export * from './agent-availability.service'; export * from './agent-availability.service';
export * from './users.service';
@@ -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<Omit<User, 'passwordHash'>> {
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();
@@ -1,17 +1,33 @@
import { FastifyReply, FastifyRequest } from 'fastify'; import { FastifyReply, FastifyRequest } from 'fastify';
import { AuthenticationError } from '@/common/errors';
import { authService, AuthService } from '../service'; 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 { export class AuthController {
constructor(private readonly service: AuthService = authService) {} constructor(private readonly service: AuthService = authService) {}
async handleLogin(request: FastifyRequest<{ Body: AuthCredentialsInput }>, reply: FastifyReply) { async handleLogin(request: FastifyRequest, reply: FastifyReply) {
const user = await this.service.validateCredentials(request.body); const body = loginSchema.parse(request.body);
return reply.status(200).send({ const result = await this.service.login(body);
success: true, return reply.status(200).send({ success: true, data: result, meta: null });
data: user, }
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 });
} }
} }
+5 -1
View File
@@ -1,3 +1,7 @@
export { authRoutes } from './routes'; export { authRoutes } from './routes';
export { AuthService, authService } from './service'; 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';
@@ -1,5 +1,52 @@
export class AuthMapper { import { randomUUID } from 'crypto';
static toResponse(user: Record<string, unknown>): Record<string, unknown> { import bcrypt from 'bcryptjs';
return { ...user }; 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<string> {
return bcrypt.hash(password, SALT_ROUNDS);
} }
export async function verifyPassword(password: string, hash: string | null): Promise<boolean> {
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<JwtPayload, 'iat' | 'exp'> = {
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,
};
} }
@@ -1,12 +1,18 @@
import { User } from '@prisma/client';
import { prismaClient } from '@/infrastructure/database'; import { prismaClient } from '@/infrastructure/database';
export class AuthRepository { export class AuthRepository {
constructor(private readonly prisma = prismaClient) {} constructor(private readonly prisma = prismaClient) {}
async findByEmail(email: string): Promise<unknown> { async findByEmail(email: string): Promise<User | null> {
return this.prisma.user.findUnique({ return this.prisma.user.findUnique({ where: { email } });
where: { email }, }
});
/** FR-002/data-model.md: only an active account can authenticate or stay authenticated. */
async findActiveById(id: string): Promise<User | null> {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user || !user.active) return null;
return user;
} }
} }
@@ -1,9 +1,14 @@
import { FastifyInstance, FastifyRequest } from 'fastify'; import { FastifyInstance } from 'fastify';
import { authController } from '../controller'; import { authController } from '../controller';
import { AuthCredentialsInput } from '../types';
export async function authRoutes(fastify: FastifyInstance): Promise<void> { export async function authRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post('/auth/login', (req: FastifyRequest<{ Body: AuthCredentialsInput }>, reply) => fastify.post('/auth/login', (req, reply) => authController.handleLogin(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),
); );
} }
@@ -1,5 +1,10 @@
import { z } from 'zod'; import { z } from 'zod';
export const authCredentialsSchema = z.object({ export const loginSchema = z
.object({
email: z.string().email(), email: z.string().email(),
}); password: z.string().min(1),
})
.strict();
export type LoginBody = z.infer<typeof loginSchema>;
@@ -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 { 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 { export class AuthService {
constructor(private readonly repo: AuthRepository = authRepository) {} constructor(private readonly repo: AuthRepository = authRepository) {}
async validateCredentials(input: AuthCredentialsInput): Promise<unknown> { /**
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<LoginResult> {
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<LoginResult['user']> {
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<void> {
const payload = verifyToken(token);
const remainingSeconds = Math.max(1, (payload.exp ?? 0) - Math.floor(Date.now() / 1000));
await revokeToken(payload.jti, remainingSeconds);
} }
} }
@@ -1 +1,2 @@
export * from './auth.service'; export * from './auth.service';
export * from './require-role';
@@ -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<void> => {
if (!request.user || !allowedRoles.includes(request.user.role)) {
throw new AuthorizationError('You do not have permission to perform this action.');
}
};
}
@@ -1,3 +1 @@
export interface AuthCredentialsInput { export {};
email: string;
}
+2 -1
View File
@@ -1 +1,2 @@
export * from './auth.types'; export type { LoginBody } from '../schema';
export type { LoginResult } from '../service';
@@ -1,14 +1,19 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { requireRole } from '@/modules/identity/auth';
import { teamsController } from '../controller'; import { teamsController } from '../controller';
/** Admin routes gated by fastify.authenticate — known limitation inherited from 002/003/004/005 /** Admin routes gated by fastify.authenticate, now real (010-identity-auth) — writes
* (research.md "Admin endpoint authentication"). */ * additionally require the ADMIN role; reads stay agent-usable. */
export async function teamsRoutes(fastify: FastifyInstance): Promise<void> { export async function teamsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post('/admin/teams', { preHandler: fastify.authenticate }, (req, reply) => fastify.post(
teamsController.create(req, reply), '/admin/teams',
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => teamsController.create(req, reply),
); );
fastify.patch('/admin/teams/:teamId', { preHandler: fastify.authenticate }, (req, reply) => fastify.patch(
teamsController.update(req, reply), '/admin/teams/:teamId',
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => teamsController.update(req, reply),
); );
fastify.get('/admin/teams/:teamId', { preHandler: fastify.authenticate }, (req, reply) => fastify.get('/admin/teams/:teamId', { preHandler: fastify.authenticate }, (req, reply) =>
teamsController.getById(req, reply), teamsController.getById(req, reply),
@@ -152,7 +152,13 @@ export class AssignmentEngine {
} }
return { return {
assignment: await this.persistAndTransition(ticketId, selected.id, strategyName, actor, reason), assignment: await this.persistAndTransition(
ticketId,
selected.id,
strategyName,
actor,
reason,
),
strategy: strategyName, strategy: strategyName,
}; };
} }
@@ -1,37 +1,35 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { requireRole } from '@/modules/identity/auth';
import { escalationController } from '../controller'; import { escalationController } from '../controller';
/** contracts/sla-escalation-contract.md: every route gated by fastify.authenticate (known /** contracts/sla-escalation-contract.md: policy/rule config gated by fastify.authenticate +
* limitation inherited from 002-007). */ * 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<void> { export async function escalationRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post( fastify.post(
'/admin/escalation-policies', '/admin/escalation-policies',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => escalationController.createPolicy(req, reply), (req, reply) => escalationController.createPolicy(req, reply),
); );
fastify.get( fastify.get('/admin/escalation-policies', { preHandler: fastify.authenticate }, (req, reply) =>
'/admin/escalation-policies', escalationController.listPolicies(req, reply),
{ preHandler: fastify.authenticate },
(req, reply) => escalationController.listPolicies(req, reply),
); );
fastify.post( fastify.post(
'/admin/escalation-policies/:id/rules', '/admin/escalation-policies/:id/rules',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => escalationController.createRule(req, reply), (req, reply) => escalationController.createRule(req, reply),
); );
fastify.patch( fastify.patch(
'/admin/escalation-policies/:id/rules/:ruleId', '/admin/escalation-policies/:id/rules/:ruleId',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => escalationController.updateRule(req, reply), (req, reply) => escalationController.updateRule(req, reply),
); );
fastify.delete( fastify.delete(
'/admin/escalation-policies/:id/rules/:ruleId', '/admin/escalation-policies/:id/rules/:ruleId',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => escalationController.deleteRule(req, reply), (req, reply) => escalationController.deleteRule(req, reply),
); );
fastify.post( fastify.post('/tickets/:ticketId/escalate', { preHandler: fastify.authenticate }, (req, reply) =>
'/tickets/:ticketId/escalate', escalationController.escalateManually(req, reply),
{ preHandler: fastify.authenticate },
(req, reply) => escalationController.escalateManually(req, reply),
); );
} }
@@ -15,7 +15,11 @@ import {
escalationEventRepository, escalationEventRepository,
EscalationEventRepository, EscalationEventRepository,
} from '../repository'; } from '../repository';
import { CreateEscalationPolicyBody, CreateEscalationRuleBody, UpdateEscalationRuleBody } from '../schema'; import {
CreateEscalationPolicyBody,
CreateEscalationRuleBody,
UpdateEscalationRuleBody,
} from '../schema';
export class EscalationService { export class EscalationService {
constructor( constructor(
@@ -32,14 +36,23 @@ export class EscalationService {
* active rule. Records nothing when no policy or no rule matches — the breach itself is * active rule. Records nothing when no policy or no rule matches — the breach itself is
* already durably recorded by the caller (SLARun.breachedAt/firstResponseBreachedAt). * already durably recorded by the caller (SLARun.breachedAt/firstResponseBreachedAt).
*/ */
async handleBreach(ticketId: string, triggerType: 'resolution_breach' | 'first_response_breach'): Promise<void> { async handleBreach(
ticketId: string,
triggerType: 'resolution_breach' | 'first_response_breach',
): Promise<void> {
const ticket = await ticketsService.getById(ticketId); const ticket = await ticketsService.getById(ticketId);
const policy = await this.policies.findApplicable(ticket.productId); const policy = await this.policies.findApplicable(ticket.productId);
if (!policy) return; if (!policy) return;
const matchingRules = await this.rules.findActiveRules(policy.id, triggerType); const matchingRules = await this.rules.findActiveRules(policy.id, triggerType);
for (const rule of matchingRules) { 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}`,
);
} }
} }
@@ -1,29 +1,32 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { requireRole } from '@/modules/identity/auth';
import { hierarchyController, capabilityLookupController } from '../controller'; import { hierarchyController, capabilityLookupController } from '../controller';
/** /**
* contracts/support-org-contract.md: admin hierarchy-node routes gated by fastify.authenticate * contracts/support-org-contract.md: admin hierarchy-node writes gated by fastify.authenticate +
* (known limitation inherited from 002/003/004/005). The capability-eligibility lookup is not * 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 * gated — a read path a future orchestration caller will use (research.md "Admin endpoint
* authentication"). * authentication").
*/ */
export async function hierarchyRoutes(fastify: FastifyInstance): Promise<void> { export async function hierarchyRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post('/admin/hierarchy-nodes', { preHandler: fastify.authenticate }, (req, reply) => fastify.post(
hierarchyController.create(req, reply), '/admin/hierarchy-nodes',
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => hierarchyController.create(req, reply),
); );
fastify.put( fastify.put(
'/admin/hierarchy-nodes/:nodeId', '/admin/hierarchy-nodes/:nodeId',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => hierarchyController.update(req, reply), (req, reply) => hierarchyController.update(req, reply),
); );
fastify.patch( fastify.patch(
'/admin/hierarchy-nodes/:nodeId/activate', '/admin/hierarchy-nodes/:nodeId/activate',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => hierarchyController.activate(req, reply), (req, reply) => hierarchyController.activate(req, reply),
); );
fastify.patch( fastify.patch(
'/admin/hierarchy-nodes/:nodeId/deactivate', '/admin/hierarchy-nodes/:nodeId/deactivate',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => hierarchyController.deactivate(req, reply), (req, reply) => hierarchyController.deactivate(req, reply),
); );
fastify.get( fastify.get(
@@ -1,5 +1,8 @@
import { SLAPolicy } from '@prisma/client'; 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 * FR-004: replaces the original naive `createdDate + targetHours` stub — every due date is
@@ -1,32 +1,30 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { requireRole } from '@/modules/identity/auth';
import { slaController } from '../controller'; import { slaController } from '../controller';
/** contracts/sla-escalation-contract.md: admin CRUD gated by fastify.authenticate; the read /** contracts/sla-escalation-contract.md: admin CRUD gated by fastify.authenticate +
* route is not (same "read path any caller can use" convention as 003/007). */ * 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<void> { export async function slaRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post( fastify.post(
'/admin/sla-policies', '/admin/sla-policies',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => slaController.createPolicy(req, reply), (req, reply) => slaController.createPolicy(req, reply),
); );
fastify.get( fastify.get('/admin/sla-policies', { preHandler: fastify.authenticate }, (req, reply) =>
'/admin/sla-policies', slaController.listPolicies(req, reply),
{ preHandler: fastify.authenticate },
(req, reply) => slaController.listPolicies(req, reply),
); );
fastify.get( fastify.get('/admin/sla-policies/:id', { preHandler: fastify.authenticate }, (req, reply) =>
'/admin/sla-policies/:id', slaController.getPolicy(req, reply),
{ preHandler: fastify.authenticate },
(req, reply) => slaController.getPolicy(req, reply),
); );
fastify.patch( fastify.patch(
'/admin/sla-policies/:id', '/admin/sla-policies/:id',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => slaController.updatePolicy(req, reply), (req, reply) => slaController.updatePolicy(req, reply),
); );
fastify.delete( fastify.delete(
'/admin/sla-policies/:id', '/admin/sla-policies/:id',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => slaController.deactivatePolicy(req, reply), (req, reply) => slaController.deactivatePolicy(req, reply),
); );
fastify.get('/tickets/:ticketId/sla-run', (req, reply) => slaController.getRun(req, reply)); fastify.get('/tickets/:ticketId/sla-run', (req, reply) => slaController.getRun(req, reply));
@@ -99,7 +99,12 @@ export function isWithinWorkingHours(
const [startHour, startMinute] = window.start.split(':').map(Number); const [startHour, startMinute] = window.start.split(':').map(Number);
const [endHour, endMinute] = window.end.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 }); const windowEnd = zoned.set({ hour: endHour, minute: endMinute, second: 0, millisecond: 0 });
return zoned >= windowStart && zoned < windowEnd; return zoned >= windowStart && zoned < windowEnd;
@@ -1 +1,4 @@
export { BusinessCalendarsController, businessCalendarsController } from './business-calendars.controller'; export {
BusinessCalendarsController,
businessCalendarsController,
} from './business-calendars.controller';
@@ -1,37 +1,34 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { requireRole } from '@/modules/identity/auth';
import { businessCalendarsController } from '../controller'; import { businessCalendarsController } from '../controller';
/** contracts/sla-escalation-contract.md: every admin route gated by fastify.authenticate (known /** contracts/sla-escalation-contract.md: every admin write route gated by fastify.authenticate +
* limitation inherited from 002-007). */ * requireRole('ADMIN'), now real (010-identity-auth). */
export async function businessCalendarsRoutes(fastify: FastifyInstance): Promise<void> { export async function businessCalendarsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post( fastify.post(
'/admin/business-calendars', '/admin/business-calendars',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => businessCalendarsController.create(req, reply), (req, reply) => businessCalendarsController.create(req, reply),
); );
fastify.get( fastify.get('/admin/business-calendars', { preHandler: fastify.authenticate }, (req, reply) =>
'/admin/business-calendars', businessCalendarsController.list(req, reply),
{ preHandler: fastify.authenticate },
(req, reply) => businessCalendarsController.list(req, reply),
); );
fastify.get( fastify.get('/admin/business-calendars/:id', { preHandler: fastify.authenticate }, (req, reply) =>
'/admin/business-calendars/:id', businessCalendarsController.getById(req, reply),
{ preHandler: fastify.authenticate },
(req, reply) => businessCalendarsController.getById(req, reply),
); );
fastify.patch( fastify.patch(
'/admin/business-calendars/:id', '/admin/business-calendars/:id',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => businessCalendarsController.update(req, reply), (req, reply) => businessCalendarsController.update(req, reply),
); );
fastify.post( fastify.post(
'/admin/business-calendars/:id/holidays', '/admin/business-calendars/:id/holidays',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => businessCalendarsController.addHoliday(req, reply), (req, reply) => businessCalendarsController.addHoliday(req, reply),
); );
fastify.delete( fastify.delete(
'/admin/business-calendars/:id/holidays/:holidayId', '/admin/business-calendars/:id/holidays/:holidayId',
{ preHandler: fastify.authenticate }, { preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => businessCalendarsController.removeHoliday(req, reply), (req, reply) => businessCalendarsController.removeHoliday(req, reply),
); );
} }
@@ -7,8 +7,16 @@ import {
holidayRepository, holidayRepository,
HolidayRepository, HolidayRepository,
} from '../repository'; } from '../repository';
import { addBusinessMinutes, isWithinWorkingHours, WorkingHours } from '../calculators/business-hours.calculator'; import {
import { CreateBusinessCalendarBody, UpdateBusinessCalendarBody, CreateHolidayBody } from '../schema'; addBusinessMinutes,
isWithinWorkingHours,
WorkingHours,
} from '../calculators/business-hours.calculator';
import {
CreateBusinessCalendarBody,
UpdateBusinessCalendarBody,
CreateHolidayBody,
} from '../schema';
export class BusinessCalendarsService { export class BusinessCalendarsService {
constructor( constructor(
@@ -33,7 +33,9 @@ export class ResolutionsController {
} }
await this.service.confirmByCustomer(ticketId, 'customer'); 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 });
} }
} }
@@ -13,8 +13,10 @@ export async function ticketsRoutes(fastify: FastifyInstance): Promise<void> {
// 009-problem-resolution FR-017: agent-facing reopen (fastify.authenticate) and customer- // 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 // facing reopen (002's inbound trust boundary, research.md) both funnel through the same
// TicketsService.reopen. // TicketsService.reopen.
fastify.post('/admin/tickets/:ticketId/reopen', { preHandler: fastify.authenticate }, (req, reply) => fastify.post(
ticketsController.reopen(req, reply), '/admin/tickets/:ticketId/reopen',
{ preHandler: fastify.authenticate },
(req, reply) => ticketsController.reopen(req, reply),
); );
fastify.post( fastify.post(
'/v1/support/tickets/:ticketId/reopen', '/v1/support/tickets/:ticketId/reopen',
+34 -4
View File
@@ -1,6 +1,10 @@
import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify'; import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
import fp from 'fastify-plugin'; import fp from 'fastify-plugin';
import jwt from 'jsonwebtoken';
import { AuthUser } from '@/common/types'; 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' { declare module 'fastify' {
interface FastifyRequest { 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) => { const authPluginCallback: FastifyPluginAsync = async (fastify) => {
fastify.decorate( fastify.decorate(
'authenticate', 'authenticate',
async (request: FastifyRequest, _reply: FastifyReply): Promise<void> => { async (request: FastifyRequest, _reply: FastifyReply): Promise<void> => {
const authHeader = request.headers.authorization; const authHeader = request.headers.authorization;
if (!authHeader) { const token = authHeader?.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : null;
// Foundation auth: default context or optional pass if (!token) {
return; 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, { export const authPlugin = fp(authPluginCallback, {
name: 'auth-plugin', name: 'auth-plugin',
dependencies: ['request-context-plugin'],
}); });
+1
View File
@@ -18,6 +18,7 @@ describe('ROUND_ROBIN concurrency safety', () => {
teamId: 't', teamId: 't',
name: id, name: id,
active: true, active: true,
userId: null,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
skills: [], skills: [],
+40
View File
@@ -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<string> {
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}` };
}
@@ -2,16 +2,19 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app'; import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database'; import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
/** Covers specs/005-ai-support/contracts/ai-support-contract.md's confidence-policy admin /** 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 * 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. */ * Postgres, unlike the AI-diagnosis/reasoning tests in this same directory. */
describe('AI confidence policy — admin config (FR-005)', () => { describe('AI confidence policy — admin config (FR-005)', () => {
let app: FastifyInstance; let app: FastifyInstance;
let token: string;
const externalProductId = `TEST_AI_POLICY_PROD_${Date.now()}`; const externalProductId = `TEST_AI_POLICY_PROD_${Date.now()}`;
beforeAll(async () => { beforeAll(async () => {
app = await buildApp(); app = await buildApp();
token = await loginAs(app, 'ADMIN');
await prismaClient.product.create({ await prismaClient.product.create({
data: { externalProductId, name: 'AI Policy Test Product', status: 'active' }, 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({ const response = await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/products/${externalProductId}/ai-policy`, url: `/admin/products/${externalProductId}/ai-policy`,
headers: authHeader(token),
payload: { highThreshold: 0.4, lowThreshold: 0.4, maxClarifyingQuestions: 2 }, payload: { highThreshold: 0.4, lowThreshold: 0.4, maxClarifyingQuestions: 2 },
}); });
expect(response.statusCode).toBe(400); expect(response.statusCode).toBe(400);
@@ -39,6 +43,7 @@ describe('AI confidence policy — admin config (FR-005)', () => {
const putResponse = await app.inject({ const putResponse = await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/products/${externalProductId}/ai-policy`, url: `/admin/products/${externalProductId}/ai-policy`,
headers: authHeader(token),
payload: { highThreshold: 0.8, lowThreshold: 0.3, maxClarifyingQuestions: 3 }, payload: { highThreshold: 0.8, lowThreshold: 0.3, maxClarifyingQuestions: 3 },
}); });
expect(putResponse.statusCode).toBe(200); expect(putResponse.statusCode).toBe(200);
@@ -47,6 +52,7 @@ describe('AI confidence policy — admin config (FR-005)', () => {
const getResponse = await app.inject({ const getResponse = await app.inject({
method: 'GET', method: 'GET',
url: `/admin/products/${externalProductId}/ai-policy`, url: `/admin/products/${externalProductId}/ai-policy`,
headers: authHeader(token),
}); });
expect(getResponse.statusCode).toBe(200); expect(getResponse.statusCode).toBe(200);
const body = getResponse.json().data; const body = getResponse.json().data;
@@ -61,11 +67,13 @@ describe('AI confidence policy — admin config (FR-005)', () => {
await app.inject({ await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/products/${externalProductId}/ai-policy`, url: `/admin/products/${externalProductId}/ai-policy`,
headers: authHeader(token),
payload: { highThreshold: 0.9, lowThreshold: 0.2, maxClarifyingQuestions: 1 }, payload: { highThreshold: 0.9, lowThreshold: 0.2, maxClarifyingQuestions: 1 },
}); });
const getResponse = await app.inject({ const getResponse = await app.inject({
method: 'GET', method: 'GET',
url: `/admin/products/${externalProductId}/ai-policy`, url: `/admin/products/${externalProductId}/ai-policy`,
headers: authHeader(token),
}); });
const configured = getResponse.json().data.configured; const configured = getResponse.json().data.configured;
expect(configured).toHaveLength(1); expect(configured).toHaveLength(1);
@@ -76,6 +84,7 @@ describe('AI confidence policy — admin config (FR-005)', () => {
const response = await app.inject({ const response = await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/products/TEST_NEVER_REGISTERED_${Date.now()}/ai-policy`, url: `/admin/products/TEST_NEVER_REGISTERED_${Date.now()}/ai-policy`,
headers: authHeader(token),
payload: { highThreshold: 0.8, lowThreshold: 0.3, maxClarifyingQuestions: 2 }, payload: { highThreshold: 0.8, lowThreshold: 0.3, maxClarifyingQuestions: 2 },
}); });
expect(response.statusCode).toBe(404); expect(response.statusCode).toBe(404);
@@ -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);
});
});
@@ -3,6 +3,7 @@ import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database'; import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { issueIntegrationToken } from '@/modules/catalog/products'; 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 * 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', () => { describe('Inbound rate limiting', () => {
let app: FastifyInstance; let app: FastifyInstance;
let adminToken: string;
const externalProductId = `TEST_RATELIMIT_PROD_${Date.now()}`; const externalProductId = `TEST_RATELIMIT_PROD_${Date.now()}`;
afterAll(async () => { 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 () => { it('throttles an integration once it exceeds its per-minute limit, and independently throttles a single user within it', async () => {
app = await buildApp(); app = await buildApp();
adminToken = await loginAs(app, 'ADMIN');
const registerResponse = await app.inject({ const registerResponse = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/products/${externalProductId}/integration`, url: `/admin/products/${externalProductId}/integration`,
headers: authHeader(adminToken),
payload: { payload: {
name: 'Rate Limit Test Product', name: 'Rate Limit Test Product',
allowedScope: { tenantIds: ['tenant-1'] }, allowedScope: { tenantIds: ['tenant-1'] },
@@ -80,6 +84,7 @@ describe('Inbound rate limiting', () => {
const registerResponse = await app.inject({ const registerResponse = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/products/${externalProductId}-int/integration`, url: `/admin/products/${externalProductId}-int/integration`,
headers: authHeader(adminToken),
payload: { payload: {
name: 'Rate Limit Test Product (integration-level)', name: 'Rate Limit Test Product (integration-level)',
allowedScope: { tenantIds: ['tenant-1'] }, allowedScope: { tenantIds: ['tenant-1'] },
+16 -1
View File
@@ -2,15 +2,18 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app'; import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database'; import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify'; 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. */ /** Covers specs/004-product-knowledge/quickstart.md Scenarios 1, 2, 3 against a real Postgres. */
describe('Knowledge entry authoring, publishing, and versioning', () => { describe('Knowledge entry authoring, publishing, and versioning', () => {
let app: FastifyInstance; let app: FastifyInstance;
let token: string;
const externalProductId = `TEST_KNOWLEDGE_PROD_${Date.now()}`; const externalProductId = `TEST_KNOWLEDGE_PROD_${Date.now()}`;
const code = `KB-TEST-${Date.now()}`; const code = `KB-TEST-${Date.now()}`;
beforeAll(async () => { beforeAll(async () => {
app = await buildApp(); app = await buildApp();
token = await loginAs(app, 'ADMIN');
await prismaClient.product.create({ await prismaClient.product.create({
data: { externalProductId, name: 'Knowledge Test Product', status: 'active' }, data: { externalProductId, name: 'Knowledge Test Product', status: 'active' },
}); });
@@ -26,6 +29,7 @@ describe('Knowledge entry authoring, publishing, and versioning', () => {
const createResponse = await app.inject({ const createResponse = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/products/${externalProductId}/knowledge`, url: `/admin/products/${externalProductId}/knowledge`,
headers: authHeader(token),
payload: { code, type: 'known_issue', problem: 'PDF conversion fails' }, payload: { code, type: 'known_issue', problem: 'PDF conversion fails' },
}); });
expect(createResponse.statusCode).toBe(201); expect(createResponse.statusCode).toBe(201);
@@ -42,6 +46,7 @@ describe('Knowledge entry authoring, publishing, and versioning', () => {
const publishResponse = await app.inject({ const publishResponse = await app.inject({
method: 'PATCH', method: 'PATCH',
url: `/admin/knowledge/${code}/publish`, url: `/admin/knowledge/${code}/publish`,
headers: authHeader(token),
}); });
expect(publishResponse.statusCode).toBe(200); expect(publishResponse.statusCode).toBe(200);
expect(publishResponse.json().data.status).toBe('published'); expect(publishResponse.json().data.status).toBe('published');
@@ -57,6 +62,7 @@ describe('Knowledge entry authoring, publishing, and versioning', () => {
const editResponse = await app.inject({ const editResponse = await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/knowledge/${code}`, url: `/admin/knowledge/${code}`,
headers: authHeader(token),
payload: { payload: {
type: 'known_issue', type: 'known_issue',
problem: 'PDF conversion fails — updated', problem: 'PDF conversion fails — updated',
@@ -69,6 +75,7 @@ describe('Knowledge entry authoring, publishing, and versioning', () => {
const versionsResponse = await app.inject({ const versionsResponse = await app.inject({
method: 'GET', method: 'GET',
url: `/admin/knowledge/${code}/versions`, url: `/admin/knowledge/${code}/versions`,
headers: authHeader(token),
}); });
const versions = versionsResponse.json().data; const versions = versionsResponse.json().data;
expect(versions).toHaveLength(2); expect(versions).toHaveLength(2);
@@ -91,11 +98,13 @@ describe('Knowledge entry authoring, publishing, and versioning', () => {
const first = await app.inject({ const first = await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/knowledge/${code}`, url: `/admin/knowledge/${code}`,
headers: authHeader(token),
payload: { type: 'known_issue', problem: 'edit A', expectedVersion: 2 }, payload: { type: 'known_issue', problem: 'edit A', expectedVersion: 2 },
}); });
const second = await app.inject({ const second = await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/knowledge/${code}`, url: `/admin/knowledge/${code}`,
headers: authHeader(token),
payload: { type: 'known_issue', problem: 'edit B', expectedVersion: 2 }, 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({ const response = await app.inject({
method: 'PATCH', method: 'PATCH',
url: `/admin/knowledge/${code}/validate`, url: `/admin/knowledge/${code}/validate`,
headers: authHeader(token),
payload: { validationStatus: 'validated' }, payload: { validationStatus: 'validated' },
}); });
expect(response.statusCode).toBe(200); 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 () => { 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({ const retrieveResponse = await app.inject({
method: 'GET', method: 'GET',
@@ -127,6 +141,7 @@ describe('Knowledge entry authoring, publishing, and versioning', () => {
const versionsResponse = await app.inject({ const versionsResponse = await app.inject({
method: 'GET', method: 'GET',
url: `/admin/knowledge/${code}/versions`, url: `/admin/knowledge/${code}/versions`,
headers: authHeader(token),
}); });
expect(versionsResponse.statusCode).toBe(200); expect(versionsResponse.statusCode).toBe(200);
}); });
+10 -1
View File
@@ -2,16 +2,19 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app'; import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database'; import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
/** Covers specs/004-product-knowledge/quickstart.md Scenario 6 against a real Postgres. */ /** Covers specs/004-product-knowledge/quickstart.md Scenario 6 against a real Postgres. */
describe('Knowledge retrieval — scoping and ranking', () => { describe('Knowledge retrieval — scoping and ranking', () => {
let app: FastifyInstance; let app: FastifyInstance;
let token: string;
const productAId = `TEST_RETRIEVE_A_${Date.now()}`; const productAId = `TEST_RETRIEVE_A_${Date.now()}`;
const productBId = `TEST_RETRIEVE_B_${Date.now()}`; const productBId = `TEST_RETRIEVE_B_${Date.now()}`;
const codes: string[] = []; const codes: string[] = [];
beforeAll(async () => { beforeAll(async () => {
app = await buildApp(); app = await buildApp();
token = await loginAs(app, 'ADMIN');
await prismaClient.product.create({ await prismaClient.product.create({
data: { externalProductId: productAId, name: 'Product A', status: 'active' }, data: { externalProductId: productAId, name: 'Product A', status: 'active' },
}); });
@@ -33,9 +36,14 @@ describe('Knowledge retrieval — scoping and ranking', () => {
await app.inject({ await app.inject({
method: 'POST', method: 'POST',
url: `/admin/products/${externalProductId}/knowledge`, url: `/admin/products/${externalProductId}/knowledge`,
headers: authHeader(token),
payload: { code, type: 'faq', problem: `problem for ${code}` }, 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 () => { it("never returns another product's entries", async () => {
@@ -62,6 +70,7 @@ describe('Knowledge retrieval — scoping and ranking', () => {
await app.inject({ await app.inject({
method: 'PATCH', method: 'PATCH',
url: `/admin/knowledge/${validatedCode}/validate`, url: `/admin/knowledge/${validatedCode}/validate`,
headers: authHeader(token),
payload: { validationStatus: 'validated' }, payload: { validationStatus: 'validated' },
}); });
+7
View File
@@ -2,15 +2,18 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app'; import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database'; import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
/** Covers specs/004-product-knowledge/quickstart.md Scenario 4 against a real Postgres. */ /** Covers specs/004-product-knowledge/quickstart.md Scenario 4 against a real Postgres. */
describe('Error codes and known issues', () => { describe('Error codes and known issues', () => {
let app: FastifyInstance; let app: FastifyInstance;
let token: string;
const externalProductId = `TEST_KNOWNISSUE_PROD_${Date.now()}`; const externalProductId = `TEST_KNOWNISSUE_PROD_${Date.now()}`;
const errorCode = 'LAYOUT_PARSE_042'; const errorCode = 'LAYOUT_PARSE_042';
beforeAll(async () => { beforeAll(async () => {
app = await buildApp(); app = await buildApp();
token = await loginAs(app, 'ADMIN');
await prismaClient.product.create({ await prismaClient.product.create({
data: { externalProductId, name: 'Known Issue Test Product', status: 'active' }, data: { externalProductId, name: 'Known Issue Test Product', status: 'active' },
}); });
@@ -27,6 +30,7 @@ describe('Error codes and known issues', () => {
const errorCodeResponse = await app.inject({ const errorCodeResponse = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/products/${externalProductId}/error-codes`, url: `/admin/products/${externalProductId}/error-codes`,
headers: authHeader(token),
payload: { code: errorCode, description: 'Layout parser failure' }, payload: { code: errorCode, description: 'Layout parser failure' },
}); });
expect(errorCodeResponse.statusCode).toBe(201); expect(errorCodeResponse.statusCode).toBe(201);
@@ -35,6 +39,7 @@ describe('Error codes and known issues', () => {
const knownIssueResponse = await app.inject({ const knownIssueResponse = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/products/${externalProductId}/known-issues`, url: `/admin/products/${externalProductId}/known-issues`,
headers: authHeader(token),
payload: { errorCodeId, description: 'Conversion fails for complex layouts' }, payload: { errorCodeId, description: 'Conversion fails for complex layouts' },
}); });
expect(knownIssueResponse.statusCode).toBe(201); expect(knownIssueResponse.statusCode).toBe(201);
@@ -42,6 +47,7 @@ describe('Error codes and known issues', () => {
const lookupResponse = await app.inject({ const lookupResponse = await app.inject({
method: 'GET', method: 'GET',
url: `/admin/products/${externalProductId}/known-issues/by-error-code/${errorCode}`, url: `/admin/products/${externalProductId}/known-issues/by-error-code/${errorCode}`,
headers: authHeader(token),
}); });
expect(lookupResponse.statusCode).toBe(200); expect(lookupResponse.statusCode).toBe(200);
const knownIssues = lookupResponse.json().data; const knownIssues = lookupResponse.json().data;
@@ -53,6 +59,7 @@ describe('Error codes and known issues', () => {
const response = await app.inject({ const response = await app.inject({
method: 'GET', method: 'GET',
url: `/admin/products/${externalProductId}/known-issues/by-error-code/NEVER_REGISTERED`, url: `/admin/products/${externalProductId}/known-issues/by-error-code/NEVER_REGISTERED`,
headers: authHeader(token),
}); });
expect(response.statusCode).toBe(404); expect(response.statusCode).toBe(404);
}); });
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app'; import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database'; import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
import { import {
encryptCredential, encryptCredential,
generateCredentialSecret, generateCredentialSecret,
@@ -15,6 +16,7 @@ import {
*/ */
describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)', () => { describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)', () => {
let app: FastifyInstance; let app: FastifyInstance;
let adminToken: string;
const externalProductId = `TEST_ORCH_PROD_${Date.now()}`; const externalProductId = `TEST_ORCH_PROD_${Date.now()}`;
const skillTag = `orch_skill_${Date.now()}`; const skillTag = `orch_skill_${Date.now()}`;
let productId: string; let productId: string;
@@ -26,6 +28,7 @@ describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)',
beforeAll(async () => { beforeAll(async () => {
app = await buildApp(); app = await buildApp();
adminToken = await loginAs(app, 'ADMIN');
const product = await prismaClient.product.create({ const product = await prismaClient.product.create({
data: { externalProductId, name: 'Orchestration Test Product', status: 'active' }, 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({ const team = await app.inject({
method: 'POST', method: 'POST',
url: '/admin/teams', url: '/admin/teams',
headers: authHeader(adminToken),
payload: { name: `Orch Team ${Date.now()}` }, payload: { name: `Orch Team ${Date.now()}` },
}); });
teamId = team.json().data.id; 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({ const agentA = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/teams/${teamId}/agents`, url: `/admin/teams/${teamId}/agents`,
headers: authHeader(adminToken),
payload: { name: 'Orch Agent A' }, payload: { name: 'Orch Agent A' },
}); });
agentAId = agentA.json().data.id; agentAId = agentA.json().data.id;
await app.inject({ await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/agents/${agentAId}/skills/${skillTag}`, url: `/admin/agents/${agentAId}/skills/${skillTag}`,
headers: authHeader(adminToken),
payload: { level: 3 }, payload: { level: 3 },
}); });
const agentB = await app.inject({ const agentB = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/teams/${teamId}/agents`, url: `/admin/teams/${teamId}/agents`,
headers: authHeader(adminToken),
payload: { name: 'Orch Agent B' }, payload: { name: 'Orch Agent B' },
}); });
agentBId = agentB.json().data.id; agentBId = agentB.json().data.id;
await app.inject({ await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/agents/${agentBId}/skills/${skillTag}`, url: `/admin/agents/${agentBId}/skills/${skillTag}`,
headers: authHeader(adminToken),
payload: { level: 3 }, payload: { level: 3 },
}); });
await app.inject({ await app.inject({
method: 'POST', method: 'POST',
url: '/admin/hierarchy-nodes', url: '/admin/hierarchy-nodes',
headers: authHeader(adminToken),
payload: { payload: {
name: 'Orch Node', name: 'Orch Node',
order: 0, 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.agent.deleteMany({ where: { teamId } });
await prismaClient.team.deleteMany({ where: { id: teamId } }); await prismaClient.team.deleteMany({ where: { id: teamId } });
await prismaClient.ticketMessage.deleteMany({ where: { ticketId } }); 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.ticket.deleteMany({ where: { id: ticketId } });
await prismaClient.problem.deleteMany({ where: { productId } }); await prismaClient.problem.deleteMany({ where: { productId } });
await prismaClient.productIntegration.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({ const escalate = await app.inject({
method: 'PATCH', method: 'PATCH',
url: `/tickets/${ticketId}/status`, url: `/tickets/${ticketId}/status`,
headers: authHeader(adminToken),
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version }, payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
}); });
expect(escalate.statusCode).toBe(200); 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({ const manual = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/tickets/${ticketId}/assignment`, url: `/admin/tickets/${ticketId}/assignment`,
headers: authHeader(adminToken),
payload: { agentId: otherAgentId, reason: 'Manual override for test' }, payload: { agentId: otherAgentId, reason: 'Manual override for test' },
}); });
expect(manual.statusCode).toBe(200); 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({ const notFound = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/tickets/${ticketId}/assignment`, url: `/admin/tickets/${ticketId}/assignment`,
headers: authHeader(adminToken),
payload: { agentId: 'nonexistent-agent-id' }, payload: { agentId: 'nonexistent-agent-id' },
}); });
expect(notFound.statusCode).toBe(404); expect(notFound.statusCode).toBe(404);
@@ -190,6 +206,7 @@ describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)',
await app.inject({ await app.inject({
method: 'PATCH', method: 'PATCH',
url: `/tickets/${ticketId}/status`, url: `/tickets/${ticketId}/status`,
headers: authHeader(adminToken),
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version }, payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
}); });
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app'; import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database'; import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
import { import {
encryptCredential, encryptCredential,
generateCredentialSecret, generateCredentialSecret,
@@ -12,14 +13,17 @@ import {
* SKILL_BASED, and the empty-eligible-set outcome) against a real Postgres/Redis. */ * SKILL_BASED, and the empty-eligible-set outcome) against a real Postgres/Redis. */
describe('Orchestration and assignment — strategies (User Story 2)', () => { describe('Orchestration and assignment — strategies (User Story 2)', () => {
let app: FastifyInstance; let app: FastifyInstance;
let authToken: string;
let secret: string; let secret: string;
let teamId: string; let teamId: string;
beforeAll(async () => { beforeAll(async () => {
app = await buildApp(); app = await buildApp();
authToken = await loginAs(app, 'ADMIN');
const team = await app.inject({ const team = await app.inject({
method: 'POST', method: 'POST',
url: '/admin/teams', url: '/admin/teams',
headers: authHeader(authToken),
payload: { name: `Strategy Team ${Date.now()}` }, payload: { name: `Strategy Team ${Date.now()}` },
}); });
teamId = team.json().data.id; teamId = team.json().data.id;
@@ -71,6 +75,7 @@ describe('Orchestration and assignment — strategies (User Story 2)', () => {
return app.inject({ return app.inject({
method: 'PATCH', method: 'PATCH',
url: `/tickets/${ticketId}/status`, url: `/tickets/${ticketId}/status`,
headers: authHeader(authToken),
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version }, payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
}); });
} }
@@ -80,28 +85,33 @@ describe('Orchestration and assignment — strategies (User Story 2)', () => {
const agentLow = await app.inject({ const agentLow = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/teams/${teamId}/agents`, url: `/admin/teams/${teamId}/agents`,
headers: authHeader(authToken),
payload: { name: 'Low Load Agent' }, payload: { name: 'Low Load Agent' },
}); });
const agentHigh = await app.inject({ const agentHigh = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/teams/${teamId}/agents`, url: `/admin/teams/${teamId}/agents`,
headers: authHeader(authToken),
payload: { name: 'High Load Agent' }, payload: { name: 'High Load Agent' },
}); });
for (const id of [agentLow.json().data.id, agentHigh.json().data.id]) { for (const id of [agentLow.json().data.id, agentHigh.json().data.id]) {
await app.inject({ await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/agents/${id}/skills/${skillTag}`, url: `/admin/agents/${id}/skills/${skillTag}`,
headers: authHeader(authToken),
payload: { level: 1 }, payload: { level: 1 },
}); });
} }
await app.inject({ await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/agents/${agentLow.json().data.id}/availability`, url: `/admin/agents/${agentLow.json().data.id}/availability`,
headers: authHeader(authToken),
payload: { status: 'available', workingHours: {}, currentLoad: 1 }, payload: { status: 'available', workingHours: {}, currentLoad: 1 },
}); });
await app.inject({ await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/agents/${agentHigh.json().data.id}/availability`, url: `/admin/agents/${agentHigh.json().data.id}/availability`,
headers: authHeader(authToken),
payload: { status: 'available', workingHours: {}, currentLoad: 9 }, payload: { status: 'available', workingHours: {}, currentLoad: 9 },
}); });
@@ -109,6 +119,7 @@ describe('Orchestration and assignment — strategies (User Story 2)', () => {
await app.inject({ await app.inject({
method: 'POST', method: 'POST',
url: '/admin/hierarchy-nodes', url: '/admin/hierarchy-nodes',
headers: authHeader(authToken),
payload: { payload: {
name: `LL Node ${Date.now()}`, name: `LL Node ${Date.now()}`,
order: 0, order: 0,
@@ -128,21 +139,25 @@ describe('Orchestration and assignment — strategies (User Story 2)', () => {
const agentExpert = await app.inject({ const agentExpert = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/teams/${teamId}/agents`, url: `/admin/teams/${teamId}/agents`,
headers: authHeader(authToken),
payload: { name: 'Expert Agent' }, payload: { name: 'Expert Agent' },
}); });
const agentNovice = await app.inject({ const agentNovice = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/teams/${teamId}/agents`, url: `/admin/teams/${teamId}/agents`,
headers: authHeader(authToken),
payload: { name: 'Novice Agent' }, payload: { name: 'Novice Agent' },
}); });
await app.inject({ await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/agents/${agentExpert.json().data.id}/skills/${skillTag}`, url: `/admin/agents/${agentExpert.json().data.id}/skills/${skillTag}`,
headers: authHeader(authToken),
payload: { level: 9 }, payload: { level: 9 },
}); });
await app.inject({ await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/agents/${agentNovice.json().data.id}/skills/${skillTag}`, url: `/admin/agents/${agentNovice.json().data.id}/skills/${skillTag}`,
headers: authHeader(authToken),
payload: { level: 1 }, payload: { level: 1 },
}); });
@@ -150,6 +165,7 @@ describe('Orchestration and assignment — strategies (User Story 2)', () => {
await app.inject({ await app.inject({
method: 'POST', method: 'POST',
url: '/admin/hierarchy-nodes', url: '/admin/hierarchy-nodes',
headers: authHeader(authToken),
payload: { payload: {
name: `SB Node ${Date.now()}`, name: `SB Node ${Date.now()}`,
order: 0, order: 0,
@@ -170,6 +186,7 @@ describe('Orchestration and assignment — strategies (User Story 2)', () => {
await app.inject({ await app.inject({
method: 'POST', method: 'POST',
url: '/admin/hierarchy-nodes', url: '/admin/hierarchy-nodes',
headers: authHeader(authToken),
payload: { payload: {
name: `Empty Node ${Date.now()}`, name: `Empty Node ${Date.now()}`,
order: 0, order: 0,
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app'; import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database'; import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
import { resolutionsService } from '@/modules/problem-management/resolutions'; import { resolutionsService } from '@/modules/problem-management/resolutions';
import { ticketsService } from '@/modules/ticketing/tickets'; import { ticketsService } from '@/modules/ticketing/tickets';
import { import {
@@ -18,6 +19,7 @@ import {
*/ */
describe('Problem resolution — full flow (User Stories 1-6)', () => { describe('Problem resolution — full flow (User Stories 1-6)', () => {
let app: FastifyInstance; let app: FastifyInstance;
let authToken: string;
const externalProductId = `TEST_PR_PROD_${Date.now()}`; const externalProductId = `TEST_PR_PROD_${Date.now()}`;
const skillTag = `pr_skill_${Date.now()}`; const skillTag = `pr_skill_${Date.now()}`;
let productId: string; let productId: string;
@@ -67,6 +69,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
await app.inject({ await app.inject({
method: 'PATCH', method: 'PATCH',
url: `/tickets/${ticketId}/status`, url: `/tickets/${ticketId}/status`,
headers: authHeader(authToken),
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version }, payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
}); });
} }
@@ -76,42 +79,51 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
await app.inject({ await app.inject({
method: 'POST', method: 'POST',
url: `/admin/problems/${problemId}/investigations`, url: `/admin/problems/${problemId}/investigations`,
headers: authHeader(authToken),
payload: { investigator: 'agent-1', findings: { note: 'checked logs' } }, payload: { investigator: 'agent-1', findings: { note: 'checked logs' } },
}); });
await app.inject({ await app.inject({
method: 'POST', method: 'POST',
url: `/admin/problems/${problemId}/root-causes`, url: `/admin/problems/${problemId}/root-causes`,
headers: authHeader(authToken),
payload: { type: 'technical', description: 'a bug' }, payload: { type: 'technical', description: 'a bug' },
}); });
const solutionRes = await app.inject({ const solutionRes = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/problems/${problemId}/solutions`, url: `/admin/problems/${problemId}/solutions`,
headers: authHeader(authToken),
payload: { proposed: 'apply fix' }, payload: { proposed: 'apply fix' },
}); });
const solutionId = solutionRes.json().data.id; const solutionId = solutionRes.json().data.id;
await app.inject({ await app.inject({
method: 'PATCH', method: 'PATCH',
url: `/admin/solutions/${solutionId}/approve`, url: `/admin/solutions/${solutionId}/approve`,
headers: authHeader(authToken),
}); });
await app.inject({ await app.inject({
method: 'POST', method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`, url: `/admin/solutions/${solutionId}/implementation`,
headers: authHeader(authToken),
payload: { implementedBy: 'agent-1' }, payload: { implementedBy: 'agent-1' },
}); });
await app.inject({ await app.inject({
method: 'POST', method: 'POST',
url: `/admin/solutions/${solutionId}/verification`, url: `/admin/solutions/${solutionId}/verification`,
headers: authHeader(authToken),
payload: { method: 'agent_confirmation', result: 'success' }, payload: { method: 'agent_confirmation', result: 'success' },
}); });
await app.inject({ await app.inject({
method: 'POST', method: 'POST',
url: `/admin/tickets/${ticketId}/resolution`, url: `/admin/tickets/${ticketId}/resolution`,
headers: authHeader(authToken),
payload: { outcome: 'fixed', resolvedBy: 'agent-1' }, payload: { outcome: 'fixed', resolvedBy: 'agent-1' },
}); });
} }
beforeAll(async () => { beforeAll(async () => {
app = await buildApp(); app = await buildApp();
authToken = await loginAs(app, 'ADMIN');
const product = await prismaClient.product.create({ const product = await prismaClient.product.create({
data: { externalProductId, name: 'Problem Resolution Test Product', status: 'active' }, 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({ const team = await app.inject({
method: 'POST', method: 'POST',
url: '/admin/teams', url: '/admin/teams',
headers: authHeader(authToken),
payload: { name: `PR Team ${Date.now()}` }, payload: { name: `PR Team ${Date.now()}` },
}); });
teamId = team.json().data.id; teamId = team.json().data.id;
@@ -140,18 +153,21 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const agent = await app.inject({ const agent = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/teams/${teamId}/agents`, url: `/admin/teams/${teamId}/agents`,
headers: authHeader(authToken),
payload: { name: 'PR Agent' }, payload: { name: 'PR Agent' },
}); });
agentId = agent.json().data.id; agentId = agent.json().data.id;
await app.inject({ await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/agents/${agentId}/skills/${skillTag}`, url: `/admin/agents/${agentId}/skills/${skillTag}`,
headers: authHeader(authToken),
payload: { level: 3 }, payload: { level: 3 },
}); });
await app.inject({ await app.inject({
method: 'POST', method: 'POST',
url: '/admin/hierarchy-nodes', url: '/admin/hierarchy-nodes',
headers: authHeader(authToken),
payload: { payload: {
name: 'PR Node', name: 'PR Node',
order: 0, order: 0,
@@ -204,6 +220,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const record = await app.inject({ const record = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/problems/${problemId}/investigations`, url: `/admin/problems/${problemId}/investigations`,
headers: authHeader(authToken),
payload: { payload: {
investigator: 'agent-1', investigator: 'agent-1',
findings: { checked: 'logs' }, findings: { checked: 'logs' },
@@ -216,6 +233,8 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const agentRead = await app.inject({ const agentRead = await app.inject({
method: 'GET', method: 'GET',
url: `/admin/problems/${problemId}/investigations`, url: `/admin/problems/${problemId}/investigations`,
headers: authHeader(authToken),
}); });
expect(agentRead.json().data[0].internalNotes).toBe('suspect race condition'); 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({ const second = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/problems/${problemId}/investigations`, url: `/admin/problems/${problemId}/investigations`,
headers: authHeader(authToken),
payload: { investigator: 'agent-1', findings: { checked: 'more logs' } }, payload: { investigator: 'agent-1', findings: { checked: 'more logs' } },
}); });
expect(second.statusCode).toBe(201); expect(second.statusCode).toBe(201);
@@ -235,6 +255,8 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const both = await app.inject({ const both = await app.inject({
method: 'GET', method: 'GET',
url: `/admin/problems/${problemId}/investigations`, url: `/admin/problems/${problemId}/investigations`,
headers: authHeader(authToken),
}); });
expect(both.json().data.length).toBe(2); 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({ const beforeInvestigation = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/problems/${problemId}/root-causes`, url: `/admin/problems/${problemId}/root-causes`,
headers: authHeader(authToken),
payload: { type: 'technical', description: 'x' }, payload: { type: 'technical', description: 'x' },
}); });
expect(beforeInvestigation.statusCode).toBe(409); expect(beforeInvestigation.statusCode).toBe(409);
@@ -252,12 +275,14 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
await app.inject({ await app.inject({
method: 'POST', method: 'POST',
url: `/admin/problems/${problemId}/investigations`, url: `/admin/problems/${problemId}/investigations`,
headers: authHeader(authToken),
payload: { investigator: 'agent-1', findings: {} }, payload: { investigator: 'agent-1', findings: {} },
}); });
const afterInvestigation = await app.inject({ const afterInvestigation = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/problems/${problemId}/root-causes`, url: `/admin/problems/${problemId}/root-causes`,
headers: authHeader(authToken),
payload: { type: 'technical', description: 'a real cause' }, payload: { type: 'technical', description: 'a real cause' },
}); });
expect(afterInvestigation.statusCode).toBe(201); expect(afterInvestigation.statusCode).toBe(201);
@@ -265,6 +290,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const invalidType = await app.inject({ const invalidType = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/problems/${problemId}/root-causes`, url: `/admin/problems/${problemId}/root-causes`,
headers: authHeader(authToken),
payload: { type: 'not_a_real_type', description: 'x' }, payload: { type: 'not_a_real_type', description: 'x' },
}); });
expect(invalidType.statusCode).toBe(400); expect(invalidType.statusCode).toBe(400);
@@ -276,6 +302,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const beforeRootCause = await app.inject({ const beforeRootCause = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/problems/${problemId}/solutions`, url: `/admin/problems/${problemId}/solutions`,
headers: authHeader(authToken),
payload: { proposed: 'x' }, payload: { proposed: 'x' },
}); });
expect(beforeRootCause.statusCode).toBe(409); expect(beforeRootCause.statusCode).toBe(409);
@@ -283,17 +310,20 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
await app.inject({ await app.inject({
method: 'POST', method: 'POST',
url: `/admin/problems/${problemId}/investigations`, url: `/admin/problems/${problemId}/investigations`,
headers: authHeader(authToken),
payload: { investigator: 'agent-1', findings: {} }, payload: { investigator: 'agent-1', findings: {} },
}); });
await app.inject({ await app.inject({
method: 'POST', method: 'POST',
url: `/admin/problems/${problemId}/root-causes`, url: `/admin/problems/${problemId}/root-causes`,
headers: authHeader(authToken),
payload: { type: 'technical', description: 'x' }, payload: { type: 'technical', description: 'x' },
}); });
const proposed = await app.inject({ const proposed = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/problems/${problemId}/solutions`, url: `/admin/problems/${problemId}/solutions`,
headers: authHeader(authToken),
payload: { proposed: 'apply the fix' }, payload: { proposed: 'apply the fix' },
}); });
expect(proposed.statusCode).toBe(201); expect(proposed.statusCode).toBe(201);
@@ -303,15 +333,21 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const implBeforeApproval = await app.inject({ const implBeforeApproval = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`, url: `/admin/solutions/${solutionId}/implementation`,
headers: authHeader(authToken),
payload: { implementedBy: 'agent-1' }, payload: { implementedBy: 'agent-1' },
}); });
expect(implBeforeApproval.statusCode).toBe(409); 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({ const impl = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`, url: `/admin/solutions/${solutionId}/implementation`,
headers: authHeader(authToken),
payload: { implementedBy: 'agent-1' }, payload: { implementedBy: 'agent-1' },
}); });
expect(impl.statusCode).toBe(201); expect(impl.statusCode).toBe(201);
@@ -319,6 +355,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const secondImpl = await app.inject({ const secondImpl = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`, url: `/admin/solutions/${solutionId}/implementation`,
headers: authHeader(authToken),
payload: { implementedBy: 'agent-1' }, payload: { implementedBy: 'agent-1' },
}); });
expect(secondImpl.statusCode).toBe(409); expect(secondImpl.statusCode).toBe(409);
@@ -329,29 +366,38 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
await app.inject({ await app.inject({
method: 'POST', method: 'POST',
url: `/admin/problems/${problemId}/investigations`, url: `/admin/problems/${problemId}/investigations`,
headers: authHeader(authToken),
payload: { investigator: 'agent-1', findings: {} }, payload: { investigator: 'agent-1', findings: {} },
}); });
await app.inject({ await app.inject({
method: 'POST', method: 'POST',
url: `/admin/problems/${problemId}/root-causes`, url: `/admin/problems/${problemId}/root-causes`,
headers: authHeader(authToken),
payload: { type: 'technical', description: 'x' }, payload: { type: 'technical', description: 'x' },
}); });
const proposed = await app.inject({ const proposed = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/problems/${problemId}/solutions`, url: `/admin/problems/${problemId}/solutions`,
headers: authHeader(authToken),
payload: { proposed: 'fix' }, payload: { proposed: 'fix' },
}); });
const solutionId = proposed.json().data.id; 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({ await app.inject({
method: 'POST', method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`, url: `/admin/solutions/${solutionId}/implementation`,
headers: authHeader(authToken),
payload: { implementedBy: 'agent-1' }, payload: { implementedBy: 'agent-1' },
}); });
const success = await app.inject({ const success = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/solutions/${solutionId}/verification`, url: `/admin/solutions/${solutionId}/verification`,
headers: authHeader(authToken),
payload: { method: 'agent_confirmation', result: 'success' }, payload: { method: 'agent_confirmation', result: 'success' },
}); });
expect(success.statusCode).toBe(201); expect(success.statusCode).toBe(201);
@@ -361,28 +407,37 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
await app.inject({ await app.inject({
method: 'POST', method: 'POST',
url: `/admin/problems/${problem2}/investigations`, url: `/admin/problems/${problem2}/investigations`,
headers: authHeader(authToken),
payload: { investigator: 'agent-1', findings: {} }, payload: { investigator: 'agent-1', findings: {} },
}); });
await app.inject({ await app.inject({
method: 'POST', method: 'POST',
url: `/admin/problems/${problem2}/root-causes`, url: `/admin/problems/${problem2}/root-causes`,
headers: authHeader(authToken),
payload: { type: 'technical', description: 'x' }, payload: { type: 'technical', description: 'x' },
}); });
const proposed2 = await app.inject({ const proposed2 = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/problems/${problem2}/solutions`, url: `/admin/problems/${problem2}/solutions`,
headers: authHeader(authToken),
payload: { proposed: 'a wrong fix' }, payload: { proposed: 'a wrong fix' },
}); });
const solution2Id = proposed2.json().data.id; 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({ await app.inject({
method: 'POST', method: 'POST',
url: `/admin/solutions/${solution2Id}/implementation`, url: `/admin/solutions/${solution2Id}/implementation`,
headers: authHeader(authToken),
payload: { implementedBy: 'agent-1' }, payload: { implementedBy: 'agent-1' },
}); });
const failed = await app.inject({ const failed = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/solutions/${solution2Id}/verification`, url: `/admin/solutions/${solution2Id}/verification`,
headers: authHeader(authToken),
payload: { method: 'agent_confirmation', result: 'failed' }, payload: { method: 'agent_confirmation', result: 'failed' },
}); });
expect(failed.statusCode).toBe(201); expect(failed.statusCode).toBe(201);
@@ -391,6 +446,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const rejectedResolution = await app.inject({ const rejectedResolution = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/tickets/${ticket2}/resolution`, url: `/admin/tickets/${ticket2}/resolution`,
headers: authHeader(authToken),
payload: { outcome: 'x', resolvedBy: 'agent-1' }, payload: { outcome: 'x', resolvedBy: 'agent-1' },
}); });
expect(rejectedResolution.statusCode).toBe(409); expect(rejectedResolution.statusCode).toBe(409);
@@ -399,12 +455,15 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const reInvestigate = await app.inject({ const reInvestigate = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/problems/${problem2}/investigations`, url: `/admin/problems/${problem2}/investigations`,
headers: authHeader(authToken),
payload: { investigator: 'agent-2', findings: { retried: true } }, payload: { investigator: 'agent-2', findings: { retried: true } },
}); });
expect(reInvestigate.statusCode).toBe(201); expect(reInvestigate.statusCode).toBe(201);
const allInvestigations = await app.inject({ const allInvestigations = await app.inject({
method: 'GET', method: 'GET',
url: `/admin/problems/${problem2}/investigations`, url: `/admin/problems/${problem2}/investigations`,
headers: authHeader(authToken),
}); });
expect(allInvestigations.json().data.length).toBe(2); 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({ const escalate = await app.inject({
method: 'PATCH', method: 'PATCH',
url: `/tickets/${ticket2}/status`, url: `/tickets/${ticket2}/status`,
headers: authHeader(authToken),
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticketBeforeEscalate.version }, payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticketBeforeEscalate.version },
}); });
expect(escalate.statusCode).toBe(200); expect(escalate.statusCode).toBe(200);
@@ -432,6 +492,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const rejected = await app.inject({ const rejected = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/tickets/${ticketId}/resolution`, url: `/admin/tickets/${ticketId}/resolution`,
headers: authHeader(authToken),
payload: { outcome: 'x', resolvedBy: 'agent-1' }, payload: { outcome: 'x', resolvedBy: 'agent-1' },
}); });
expect(rejected.statusCode).toBe(409); expect(rejected.statusCode).toBe(409);
@@ -439,34 +500,44 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
await app.inject({ await app.inject({
method: 'POST', method: 'POST',
url: `/admin/problems/${problemId}/investigations`, url: `/admin/problems/${problemId}/investigations`,
headers: authHeader(authToken),
payload: { investigator: 'agent-1', findings: {} }, payload: { investigator: 'agent-1', findings: {} },
}); });
await app.inject({ await app.inject({
method: 'POST', method: 'POST',
url: `/admin/problems/${problemId}/root-causes`, url: `/admin/problems/${problemId}/root-causes`,
headers: authHeader(authToken),
payload: { type: 'technical', description: 'x' }, payload: { type: 'technical', description: 'x' },
}); });
const proposed = await app.inject({ const proposed = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/problems/${problemId}/solutions`, url: `/admin/problems/${problemId}/solutions`,
headers: authHeader(authToken),
payload: { proposed: 'fix' }, payload: { proposed: 'fix' },
}); });
const solutionId = proposed.json().data.id; 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({ await app.inject({
method: 'POST', method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`, url: `/admin/solutions/${solutionId}/implementation`,
headers: authHeader(authToken),
payload: { implementedBy: 'agent-1' }, payload: { implementedBy: 'agent-1' },
}); });
await app.inject({ await app.inject({
method: 'POST', method: 'POST',
url: `/admin/solutions/${solutionId}/verification`, url: `/admin/solutions/${solutionId}/verification`,
headers: authHeader(authToken),
payload: { method: 'agent_confirmation', result: 'success' }, payload: { method: 'agent_confirmation', result: 'success' },
}); });
const resolved = await app.inject({ const resolved = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/tickets/${ticketId}/resolution`, url: `/admin/tickets/${ticketId}/resolution`,
headers: authHeader(authToken),
payload: { outcome: 'fixed', resolvedBy: 'agent-1' }, payload: { outcome: 'fixed', resolvedBy: 'agent-1' },
}); });
expect(resolved.statusCode).toBe(201); expect(resolved.statusCode).toBe(201);
@@ -543,6 +614,8 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const agentReopen = await app.inject({ const agentReopen = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/tickets/${ticket2}/reopen`, url: `/admin/tickets/${ticket2}/reopen`,
headers: authHeader(authToken),
}); });
expect(agentReopen.statusCode).toBe(200); expect(agentReopen.statusCode).toBe(200);
expect(agentReopen.json().data.status).toBe('IN_PROGRESS'); expect(agentReopen.json().data.status).toBe('IN_PROGRESS');
@@ -2,6 +2,7 @@ import { describe, it, expect, afterAll } from 'vitest';
import { buildApp } from '@/app'; import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database'; import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
import { issueIntegrationToken } from '@/modules/catalog/products'; import { issueIntegrationToken } from '@/modules/catalog/products';
/** /**
@@ -12,6 +13,7 @@ import { issueIntegrationToken } from '@/modules/catalog/products';
*/ */
describe('Product Integration Admin Lifecycle', () => { describe('Product Integration Admin Lifecycle', () => {
let app: FastifyInstance; let app: FastifyInstance;
let token: string;
const externalProductId = `TEST_ADMIN_PROD_${Date.now()}`; const externalProductId = `TEST_ADMIN_PROD_${Date.now()}`;
afterAll(async () => { 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 () => { 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(); app = await buildApp();
token = await loginAs(app, 'ADMIN');
const registerResponse = await app.inject({ const registerResponse = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/products/${externalProductId}/integration`, url: `/admin/products/${externalProductId}/integration`,
headers: authHeader(token),
payload: { payload: {
name: 'Admin Test Product', name: 'Admin Test Product',
allowedScope: { tenantIds: ['tenant-1'] }, allowedScope: { tenantIds: ['tenant-1'] },
@@ -48,6 +53,7 @@ describe('Product Integration Admin Lifecycle', () => {
const rotateResponse = await app.inject({ const rotateResponse = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/integrations/${integrationId}/rotate`, url: `/admin/integrations/${integrationId}/rotate`,
headers: authHeader(token),
}); });
expect(rotateResponse.statusCode).toBe(200); expect(rotateResponse.statusCode).toBe(200);
const rotated = rotateResponse.json().data; const rotated = rotateResponse.json().data;
@@ -89,6 +95,7 @@ describe('Product Integration Admin Lifecycle', () => {
const auditResponse = await app.inject({ const auditResponse = await app.inject({
method: 'GET', method: 'GET',
url: `/admin/integrations/${integrationId}/audit-trail`, url: `/admin/integrations/${integrationId}/audit-trail`,
headers: authHeader(token),
}); });
expect(auditResponse.statusCode).toBe(200); expect(auditResponse.statusCode).toBe(200);
const actions = auditResponse.json().data.map((e: { action: string }) => e.action); 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({ const registerResponse = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/products/${externalProductId}-revoke/integration`, url: `/admin/products/${externalProductId}-revoke/integration`,
headers: authHeader(token),
payload: { payload: {
name: 'Admin Test Product Revoke', name: 'Admin Test Product Revoke',
allowedScope: { tenantIds: ['tenant-1'] }, allowedScope: { tenantIds: ['tenant-1'] },
@@ -111,6 +119,7 @@ describe('Product Integration Admin Lifecycle', () => {
const revokeResponse = await app.inject({ const revokeResponse = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/integrations/${integrationId}/revoke`, url: `/admin/integrations/${integrationId}/revoke`,
headers: authHeader(token),
}); });
expect(revokeResponse.statusCode).toBe(200); expect(revokeResponse.statusCode).toBe(200);
+9
View File
@@ -2,15 +2,18 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app'; import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database'; import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
/** Covers specs/004-product-knowledge/quickstart.md Scenario 5 against a real Postgres. */ /** Covers specs/004-product-knowledge/quickstart.md Scenario 5 against a real Postgres. */
describe('Runbooks', () => { describe('Runbooks', () => {
let app: FastifyInstance; let app: FastifyInstance;
let token: string;
const externalProductId = `TEST_RUNBOOK_PROD_${Date.now()}`; const externalProductId = `TEST_RUNBOOK_PROD_${Date.now()}`;
const key = 'PDF_HTML_CONVERSION_FAILURE'; const key = 'PDF_HTML_CONVERSION_FAILURE';
beforeAll(async () => { beforeAll(async () => {
app = await buildApp(); app = await buildApp();
token = await loginAs(app, 'ADMIN');
await prismaClient.product.create({ await prismaClient.product.create({
data: { externalProductId, name: 'Runbook Test Product', status: 'active' }, data: { externalProductId, name: 'Runbook Test Product', status: 'active' },
}); });
@@ -32,6 +35,7 @@ describe('Runbooks', () => {
const createResponse = await app.inject({ const createResponse = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/products/${externalProductId}/runbooks`, url: `/admin/products/${externalProductId}/runbooks`,
headers: authHeader(token),
payload: { key, steps }, payload: { key, steps },
}); });
expect(createResponse.statusCode).toBe(201); expect(createResponse.statusCode).toBe(201);
@@ -39,6 +43,7 @@ describe('Runbooks', () => {
const lookupResponse = await app.inject({ const lookupResponse = await app.inject({
method: 'GET', method: 'GET',
url: `/admin/products/${externalProductId}/runbooks/${key}`, url: `/admin/products/${externalProductId}/runbooks/${key}`,
headers: authHeader(token),
}); });
expect(lookupResponse.statusCode).toBe(200); expect(lookupResponse.statusCode).toBe(200);
expect(lookupResponse.json().data.steps).toEqual(steps); expect(lookupResponse.json().data.steps).toEqual(steps);
@@ -46,12 +51,14 @@ describe('Runbooks', () => {
const deactivateResponse = await app.inject({ const deactivateResponse = await app.inject({
method: 'PATCH', method: 'PATCH',
url: `/admin/products/${externalProductId}/runbooks/${key}/deactivate`, url: `/admin/products/${externalProductId}/runbooks/${key}/deactivate`,
headers: authHeader(token),
}); });
expect(deactivateResponse.statusCode).toBe(200); expect(deactivateResponse.statusCode).toBe(200);
const lookupAfterDeactivate = await app.inject({ const lookupAfterDeactivate = await app.inject({
method: 'GET', method: 'GET',
url: `/admin/products/${externalProductId}/runbooks/${key}`, url: `/admin/products/${externalProductId}/runbooks/${key}`,
headers: authHeader(token),
}); });
expect(lookupAfterDeactivate.statusCode).toBe(404); expect(lookupAfterDeactivate.statusCode).toBe(404);
}); });
@@ -61,12 +68,14 @@ describe('Runbooks', () => {
await app.inject({ await app.inject({
method: 'POST', method: 'POST',
url: `/admin/products/${externalProductId}/runbooks`, url: `/admin/products/${externalProductId}/runbooks`,
headers: authHeader(token),
payload: { key: key2, steps: [{ step: 1, description: 'Original step' }] }, payload: { key: key2, steps: [{ step: 1, description: 'Original step' }] },
}); });
const editResponse = await app.inject({ const editResponse = await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/products/${externalProductId}/runbooks/${key2}`, url: `/admin/products/${externalProductId}/runbooks/${key2}`,
headers: authHeader(token),
payload: { payload: {
steps: [{ step: 1, description: 'Updated step' }], steps: [{ step: 1, description: 'Updated step' }],
expectedVersion: 1, expectedVersion: 1,
+54 -8
View File
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app'; import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database'; import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
import { slaService } from '@/modules/orchestration/sla'; import { slaService } from '@/modules/orchestration/sla';
import { import {
encryptCredential, encryptCredential,
@@ -17,6 +18,7 @@ import {
*/ */
describe('SLA and escalation — full flow (User Stories 1-6)', () => { describe('SLA and escalation — full flow (User Stories 1-6)', () => {
let app: FastifyInstance; let app: FastifyInstance;
let authToken: string;
const externalProductId = `TEST_SLA_PROD_${Date.now()}`; const externalProductId = `TEST_SLA_PROD_${Date.now()}`;
const skillTag = `sla_skill_${Date.now()}`; const skillTag = `sla_skill_${Date.now()}`;
let productId: string; let productId: string;
@@ -60,12 +62,14 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
await app.inject({ await app.inject({
method: 'PATCH', method: 'PATCH',
url: `/tickets/${ticketId}/status`, url: `/tickets/${ticketId}/status`,
headers: authHeader(authToken),
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version }, payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
}); });
} }
beforeAll(async () => { beforeAll(async () => {
app = await buildApp(); app = await buildApp();
authToken = await loginAs(app, 'ADMIN');
const product = await prismaClient.product.create({ const product = await prismaClient.product.create({
data: { externalProductId, name: 'SLA Test Product', status: 'active' }, 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({ const team = await app.inject({
method: 'POST', method: 'POST',
url: '/admin/teams', url: '/admin/teams',
headers: authHeader(authToken),
payload: { name: `SLA Team ${Date.now()}` }, payload: { name: `SLA Team ${Date.now()}` },
}); });
teamId = team.json().data.id; teamId = team.json().data.id;
@@ -94,30 +99,35 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
const agentA = await app.inject({ const agentA = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/teams/${teamId}/agents`, url: `/admin/teams/${teamId}/agents`,
headers: authHeader(authToken),
payload: { name: 'SLA Agent A' }, payload: { name: 'SLA Agent A' },
}); });
agentAId = agentA.json().data.id; agentAId = agentA.json().data.id;
await app.inject({ await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/agents/${agentAId}/skills/${skillTag}`, url: `/admin/agents/${agentAId}/skills/${skillTag}`,
headers: authHeader(authToken),
payload: { level: 3 }, payload: { level: 3 },
}); });
const agentB = await app.inject({ const agentB = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/teams/${teamId}/agents`, url: `/admin/teams/${teamId}/agents`,
headers: authHeader(authToken),
payload: { name: 'SLA Agent B' }, payload: { name: 'SLA Agent B' },
}); });
agentBId = agentB.json().data.id; agentBId = agentB.json().data.id;
await app.inject({ await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/agents/${agentBId}/skills/${skillTag}`, url: `/admin/agents/${agentBId}/skills/${skillTag}`,
headers: authHeader(authToken),
payload: { level: 3 }, payload: { level: 3 },
}); });
const nodeA = await app.inject({ const nodeA = await app.inject({
method: 'POST', method: 'POST',
url: '/admin/hierarchy-nodes', url: '/admin/hierarchy-nodes',
headers: authHeader(authToken),
payload: { payload: {
name: 'SLA Node A', name: 'SLA Node A',
order: 0, order: 0,
@@ -131,6 +141,7 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
const nodeB = await app.inject({ const nodeB = await app.inject({
method: 'POST', method: 'POST',
url: '/admin/hierarchy-nodes', url: '/admin/hierarchy-nodes',
headers: authHeader(authToken),
payload: { payload: {
// Scoped to this test's own product, not a wildcard ([] matches every product per // 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 // 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({ const globalPolicy = await app.inject({
method: 'POST', method: 'POST',
url: '/admin/sla-policies', url: '/admin/sla-policies',
headers: authHeader(authToken),
payload: { name: 'Global policy', firstResponseMinutes: 60, resolutionMinutes: 480 }, payload: { name: 'Global policy', firstResponseMinutes: 60, resolutionMinutes: 480 },
}); });
globalPolicyId = globalPolicy.json().data.id; globalPolicyId = globalPolicy.json().data.id;
@@ -157,6 +169,7 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
const productPolicy = await app.inject({ const productPolicy = await app.inject({
method: 'POST', method: 'POST',
url: '/admin/sla-policies', url: '/admin/sla-policies',
headers: authHeader(authToken),
payload: { payload: {
name: 'Product policy', name: 'Product policy',
productId, productId,
@@ -170,10 +183,20 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
afterAll(async () => { afterAll(async () => {
const ticketFilter = { ticketId: { in: createdTicketIds } }; const ticketFilter = { ticketId: { in: createdTicketIds } };
await prismaClient.escalationEvent.deleteMany({ where: ticketFilter }); 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.escalationPolicy.deleteMany({ where: { productId } });
await prismaClient.sLARun.deleteMany({ where: ticketFilter }); 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.assignmentHistory.deleteMany({ where: ticketFilter });
await prismaClient.assignment.deleteMany({ where: ticketFilter }); await prismaClient.assignment.deleteMany({ where: ticketFilter });
await prismaClient.hierarchyNode.deleteMany({ where: { id: { in: [nodeAId, nodeBId] } } }); 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({ const outsidePolicy = await app.inject({
method: 'GET', method: 'GET',
url: `/admin/sla-policies/${productPolicyId}`, url: `/admin/sla-policies/${productPolicyId}`,
headers: authHeader(authToken),
}); });
expect(outsidePolicy.statusCode).toBe(200); expect(outsidePolicy.statusCode).toBe(200);
// Deactivate both policies temporarily to prove the no-match path. // Deactivate both policies temporarily to prove the no-match path.
await app.inject({ method: 'DELETE', url: `/admin/sla-policies/${productPolicyId}` }); await app.inject({
await app.inject({ method: 'DELETE', url: `/admin/sla-policies/${globalPolicyId}` }); 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(); const ticketId = await createTicket();
await escalateAndAssign(ticketId); await escalateAndAssign(ticketId);
@@ -247,6 +279,7 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
await app.inject({ await app.inject({
method: 'PATCH', method: 'PATCH',
url: `/tickets/${ticketId}/status`, url: `/tickets/${ticketId}/status`,
headers: authHeader(authToken),
payload: { status: 'WAITING_FOR_CUSTOMER', expectedVersion: ticket.version }, 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({ await app.inject({
method: 'PATCH', method: 'PATCH',
url: `/tickets/${ticketId}/status`, url: `/tickets/${ticketId}/status`,
headers: authHeader(authToken),
payload: { status: 'IN_PROGRESS', expectedVersion: ticketAfterRestart.version }, 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 escalateAndAssign(pausedTicketId);
await prismaClient.sLARun.update({ await prismaClient.sLARun.update({
where: { ticketId: pausedTicketId }, 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(); 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.status).toBe('breached');
expect(overdue.breachedAt).not.toBeNull(); expect(overdue.breachedAt).not.toBeNull();
@@ -308,14 +348,17 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
}); });
expect(completed.status).toBe('completed'); 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'); 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({ const policy = await app.inject({
method: 'POST', method: 'POST',
url: '/admin/escalation-policies', url: '/admin/escalation-policies',
headers: authHeader(authToken),
payload: { name: 'Product escalation policy', productId }, payload: { name: 'Product escalation policy', productId },
}); });
const escalationPolicyId = policy.json().data.id; const escalationPolicyId = policy.json().data.id;
@@ -323,6 +366,7 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
await app.inject({ await app.inject({
method: 'POST', method: 'POST',
url: `/admin/escalation-policies/${escalationPolicyId}/rules`, url: `/admin/escalation-policies/${escalationPolicyId}/rules`,
headers: authHeader(authToken),
payload: { payload: {
triggerType: 'resolution_breach', triggerType: 'resolution_breach',
condition: {}, condition: {},
@@ -377,6 +421,7 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
const notFound = await app.inject({ const notFound = await app.inject({
method: 'POST', method: 'POST',
url: `/tickets/${ticketId}/escalate`, url: `/tickets/${ticketId}/escalate`,
headers: authHeader(authToken),
payload: { targetNodeId: 'nonexistent-node-id', reason: 'test' }, payload: { targetNodeId: 'nonexistent-node-id', reason: 'test' },
}); });
expect(notFound.statusCode).toBe(404); expect(notFound.statusCode).toBe(404);
@@ -385,6 +430,7 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
const manual = await app.inject({ const manual = await app.inject({
method: 'POST', method: 'POST',
url: `/tickets/${ticketId}/escalate`, url: `/tickets/${ticketId}/escalate`,
headers: authHeader(authToken),
payload: { targetNodeId: nodeBId, reason: 'Customer requested a specialist' }, payload: { targetNodeId: nodeBId, reason: 'Customer requested a specialist' },
}); });
expect(manual.statusCode).toBe(201); expect(manual.statusCode).toBe(201);
@@ -2,10 +2,12 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app'; import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database'; import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
/** Covers specs/006-support-organization/quickstart.md Scenario 4 against a real Postgres. */ /** Covers specs/006-support-organization/quickstart.md Scenario 4 against a real Postgres. */
describe('Support organization — capability eligibility lookup (User Story 4)', () => { describe('Support organization — capability eligibility lookup (User Story 4)', () => {
let app: FastifyInstance; let app: FastifyInstance;
let token: string;
const teamName = `Test Capability Team ${Date.now()}`; const teamName = `Test Capability Team ${Date.now()}`;
const skillX = `skill_x_${Date.now()}`; const skillX = `skill_x_${Date.now()}`;
const skillY = `skill_y_${Date.now()}`; const skillY = `skill_y_${Date.now()}`;
@@ -17,9 +19,11 @@ describe('Support organization — capability eligibility lookup (User Story 4)'
beforeAll(async () => { beforeAll(async () => {
app = await buildApp(); app = await buildApp();
token = await loginAs(app, 'ADMIN');
const team = await app.inject({ const team = await app.inject({
method: 'POST', method: 'POST',
url: '/admin/teams', url: '/admin/teams',
headers: authHeader(token),
payload: { name: teamName }, payload: { name: teamName },
}); });
teamId = team.json().data.id; teamId = team.json().data.id;
@@ -27,24 +31,28 @@ describe('Support organization — capability eligibility lookup (User Story 4)'
const agentA = await app.inject({ const agentA = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/teams/${teamId}/agents`, url: `/admin/teams/${teamId}/agents`,
headers: authHeader(token),
payload: { name: 'Agent A' }, payload: { name: 'Agent A' },
}); });
agentAId = agentA.json().data.id; agentAId = agentA.json().data.id;
await app.inject({ await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/agents/${agentAId}/skills/${skillX}`, url: `/admin/agents/${agentAId}/skills/${skillX}`,
headers: authHeader(token),
payload: { level: 3 }, payload: { level: 3 },
}); });
const agentB = await app.inject({ const agentB = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/teams/${teamId}/agents`, url: `/admin/teams/${teamId}/agents`,
headers: authHeader(token),
payload: { name: 'Agent B' }, payload: { name: 'Agent B' },
}); });
agentBId = agentB.json().data.id; agentBId = agentB.json().data.id;
await app.inject({ await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/agents/${agentBId}/skills/${skillY}`, url: `/admin/agents/${agentBId}/skills/${skillY}`,
headers: authHeader(token),
payload: { level: 3 }, payload: { level: 3 },
}); });
}); });
@@ -75,6 +83,7 @@ describe('Support organization — capability eligibility lookup (User Story 4)'
await app.inject({ await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/agents/${agentAId}/availability`, url: `/admin/agents/${agentAId}/availability`,
headers: authHeader(token),
payload: { status: 'offline', workingHours: {} }, payload: { status: 'offline', workingHours: {} },
}); });
const stillOffline = await app.inject({ const stillOffline = await app.inject({
@@ -86,6 +95,7 @@ describe('Support organization — capability eligibility lookup (User Story 4)'
await app.inject({ await app.inject({
method: 'PATCH', method: 'PATCH',
url: `/admin/agents/${agentAId}`, url: `/admin/agents/${agentAId}`,
headers: authHeader(token),
payload: { active: false }, payload: { active: false },
}); });
const afterDeactivation = await app.inject({ const afterDeactivation = await app.inject({
@@ -106,17 +116,20 @@ describe('Support organization — capability eligibility lookup (User Story 4)'
await app.inject({ await app.inject({
method: 'PATCH', method: 'PATCH',
url: `/admin/agents/${agentAId}`, url: `/admin/agents/${agentAId}`,
headers: authHeader(token),
payload: { active: true }, payload: { active: true },
}); });
await app.inject({ await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/agents/${agentAId}/skills/${skillZ}`, url: `/admin/agents/${agentAId}/skills/${skillZ}`,
headers: authHeader(token),
payload: { level: 2 }, payload: { level: 2 },
}); });
const productId = `TEST_CAP_PRODUCT_${Date.now()}`; const productId = `TEST_CAP_PRODUCT_${Date.now()}`;
const node = await app.inject({ const node = await app.inject({
method: 'POST', method: 'POST',
url: '/admin/hierarchy-nodes', url: '/admin/hierarchy-nodes',
headers: authHeader(token),
payload: { payload: {
name: 'Capability Scope Node', name: 'Capability Scope Node',
order: 0, order: 0,
@@ -2,16 +2,19 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app'; import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database'; import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
/** Covers specs/006-support-organization/quickstart.md Scenario 3 against a real Postgres. */ /** Covers specs/006-support-organization/quickstart.md Scenario 3 against a real Postgres. */
describe('Support organization — dynamic hierarchy (User Story 3)', () => { describe('Support organization — dynamic hierarchy (User Story 3)', () => {
let app: FastifyInstance; let app: FastifyInstance;
let token: string;
const rootName = `Test Root ${Date.now()}`; const rootName = `Test Root ${Date.now()}`;
let rootId: string; let rootId: string;
let childId: string; let childId: string;
beforeAll(async () => { beforeAll(async () => {
app = await buildApp(); app = await buildApp();
token = await loginAs(app, 'ADMIN');
}); });
afterAll(async () => { afterAll(async () => {
@@ -28,6 +31,7 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => {
const createRoot = await app.inject({ const createRoot = await app.inject({
method: 'POST', method: 'POST',
url: '/admin/hierarchy-nodes', url: '/admin/hierarchy-nodes',
headers: authHeader(token),
payload: { name: rootName, order: 0, assignmentStrategy: 'ROUND_ROBIN' }, payload: { name: rootName, order: 0, assignmentStrategy: 'ROUND_ROBIN' },
}); });
expect(createRoot.statusCode).toBe(201); expect(createRoot.statusCode).toBe(201);
@@ -36,6 +40,7 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => {
const createChild = await app.inject({ const createChild = await app.inject({
method: 'POST', method: 'POST',
url: '/admin/hierarchy-nodes', url: '/admin/hierarchy-nodes',
headers: authHeader(token),
payload: { payload: {
name: `${rootName} Child`, name: `${rootName} Child`,
parentId: rootId, parentId: rootId,
@@ -49,12 +54,14 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => {
const children = await app.inject({ const children = await app.inject({
method: 'GET', method: 'GET',
url: `/admin/hierarchy-nodes/${rootId}/children`, url: `/admin/hierarchy-nodes/${rootId}/children`,
headers: authHeader(token),
}); });
expect(children.json().data.map((n: { id: string }) => n.id)).toContain(childId); expect(children.json().data.map((n: { id: string }) => n.id)).toContain(childId);
const badParent = await app.inject({ const badParent = await app.inject({
method: 'POST', method: 'POST',
url: '/admin/hierarchy-nodes', url: '/admin/hierarchy-nodes',
headers: authHeader(token),
payload: { payload: {
name: 'Orphan', name: 'Orphan',
parentId: 'nonexistent-node-id', parentId: 'nonexistent-node-id',
@@ -67,6 +74,7 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => {
const selfCycle = await app.inject({ const selfCycle = await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/hierarchy-nodes/${childId}`, url: `/admin/hierarchy-nodes/${childId}`,
headers: authHeader(token),
payload: { payload: {
name: `${rootName} Child`, name: `${rootName} Child`,
parentId: childId, parentId: childId,
@@ -80,15 +88,21 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => {
const transitiveCycle = await app.inject({ const transitiveCycle = await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/hierarchy-nodes/${rootId}`, url: `/admin/hierarchy-nodes/${rootId}`,
headers: authHeader(token),
payload: { name: rootName, parentId: childId, order: 0, assignmentStrategy: 'ROUND_ROBIN' }, payload: { name: rootName, parentId: childId, order: 0, assignmentStrategy: 'ROUND_ROBIN' },
}); });
expect(transitiveCycle.statusCode).toBe(400); expect(transitiveCycle.statusCode).toBe(400);
expect(transitiveCycle.json().error.code).toBe('CYCLE_DETECTED'); 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({ const activeTree = await app.inject({
method: 'GET', method: 'GET',
url: '/admin/hierarchy-nodes?active=true', url: '/admin/hierarchy-nodes?active=true',
headers: authHeader(token),
}); });
const activeIds = activeTree.json().data.map((n: { id: string }) => n.id); const activeIds = activeTree.json().data.map((n: { id: string }) => n.id);
expect(activeIds).not.toContain(rootId); expect(activeIds).not.toContain(rootId);
@@ -96,6 +110,7 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => {
const childAfterParentDeactivation = await app.inject({ const childAfterParentDeactivation = await app.inject({
method: 'GET', method: 'GET',
url: `/admin/hierarchy-nodes/${childId}`, url: `/admin/hierarchy-nodes/${childId}`,
headers: authHeader(token),
}); });
expect(childAfterParentDeactivation.json().data.active).toBe(true); expect(childAfterParentDeactivation.json().data.active).toBe(true);
}); });
@@ -104,6 +119,7 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => {
const parent = await app.inject({ const parent = await app.inject({
method: 'POST', method: 'POST',
url: '/admin/hierarchy-nodes', url: '/admin/hierarchy-nodes',
headers: authHeader(token),
payload: { name: `${rootName} Order Parent`, order: 0, assignmentStrategy: 'ROUND_ROBIN' }, payload: { name: `${rootName} Order Parent`, order: 0, assignmentStrategy: 'ROUND_ROBIN' },
}); });
const parentId = parent.json().data.id; const parentId = parent.json().data.id;
@@ -111,17 +127,20 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => {
const second = await app.inject({ const second = await app.inject({
method: 'POST', method: 'POST',
url: '/admin/hierarchy-nodes', url: '/admin/hierarchy-nodes',
headers: authHeader(token),
payload: { name: 'Second', parentId, order: 2, assignmentStrategy: 'ROUND_ROBIN' }, payload: { name: 'Second', parentId, order: 2, assignmentStrategy: 'ROUND_ROBIN' },
}); });
const first = await app.inject({ const first = await app.inject({
method: 'POST', method: 'POST',
url: '/admin/hierarchy-nodes', url: '/admin/hierarchy-nodes',
headers: authHeader(token),
payload: { name: 'First', parentId, order: 1, assignmentStrategy: 'ROUND_ROBIN' }, payload: { name: 'First', parentId, order: 1, assignmentStrategy: 'ROUND_ROBIN' },
}); });
const children = await app.inject({ const children = await app.inject({
method: 'GET', method: 'GET',
url: `/admin/hierarchy-nodes/${parentId}/children`, url: `/admin/hierarchy-nodes/${parentId}/children`,
headers: authHeader(token),
}); });
const orderedIds = children.json().data.map((n: { id: string }) => n.id); const orderedIds = children.json().data.map((n: { id: string }) => n.id);
expect(orderedIds).toEqual([first.json().data.id, second.json().data.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({ const created = await app.inject({
method: 'POST', method: 'POST',
url: '/admin/hierarchy-nodes', url: '/admin/hierarchy-nodes',
headers: authHeader(token),
payload: { name: `${rootName} Audited`, order: 0, assignmentStrategy: 'ROUND_ROBIN' }, payload: { name: `${rootName} Audited`, order: 0, assignmentStrategy: 'ROUND_ROBIN' },
}); });
const nodeId = created.json().data.id; const nodeId = created.json().data.id;
await app.inject({ method: 'PATCH', url: `/admin/hierarchy-nodes/${nodeId}/deactivate` }); await app.inject({
await app.inject({ method: 'PATCH', url: `/admin/hierarchy-nodes/${nodeId}/activate` }); 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({ const auditRows = await prismaClient.auditLog.findMany({
where: { entityType: 'HierarchyNode', entityId: nodeId }, where: { entityType: 'HierarchyNode', entityId: nodeId },
@@ -2,25 +2,30 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app'; import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database'; import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
/** Covers specs/006-support-organization/quickstart.md Scenario 2 against a real Postgres. */ /** Covers specs/006-support-organization/quickstart.md Scenario 2 against a real Postgres. */
describe('Support organization — agent skills and availability (User Story 2)', () => { describe('Support organization — agent skills and availability (User Story 2)', () => {
let app: FastifyInstance; let app: FastifyInstance;
let token: string;
const teamName = `Test Skills Team ${Date.now()}`; const teamName = `Test Skills Team ${Date.now()}`;
let teamId: string; let teamId: string;
let agentId: string; let agentId: string;
beforeAll(async () => { beforeAll(async () => {
app = await buildApp(); app = await buildApp();
token = await loginAs(app, 'ADMIN');
const team = await app.inject({ const team = await app.inject({
method: 'POST', method: 'POST',
url: '/admin/teams', url: '/admin/teams',
headers: authHeader(token),
payload: { name: teamName }, payload: { name: teamName },
}); });
teamId = team.json().data.id; teamId = team.json().data.id;
const agent = await app.inject({ const agent = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/teams/${teamId}/agents`, url: `/admin/teams/${teamId}/agents`,
headers: authHeader(token),
payload: { name: 'Skilled Agent' }, payload: { name: 'Skilled Agent' },
}); });
agentId = agent.json().data.id; agentId = agent.json().data.id;
@@ -38,6 +43,7 @@ describe('Support organization — agent skills and availability (User Story 2)'
const addSkill = await app.inject({ const addSkill = await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/agents/${agentId}/skills/pdf_conversion`, url: `/admin/agents/${agentId}/skills/pdf_conversion`,
headers: authHeader(token),
payload: { level: 3 }, payload: { level: 3 },
}); });
expect(addSkill.statusCode).toBe(200); expect(addSkill.statusCode).toBe(200);
@@ -45,11 +51,16 @@ describe('Support organization — agent skills and availability (User Story 2)'
const updateSkill = await app.inject({ const updateSkill = await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/agents/${agentId}/skills/pdf_conversion`, url: `/admin/agents/${agentId}/skills/pdf_conversion`,
headers: authHeader(token),
payload: { level: 5 }, payload: { level: 5 },
}); });
expect(updateSkill.statusCode).toBe(200); 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 const pdfSkills = skills
.json() .json()
.data.filter((s: { skillTag: string }) => s.skillTag === 'pdf_conversion'); .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({ const setAvailability = await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/agents/${agentId}/availability`, url: `/admin/agents/${agentId}/availability`,
headers: authHeader(token),
payload: { status: 'busy', workingHours: { mon: '9-17' } }, payload: { status: 'busy', workingHours: { mon: '9-17' } },
}); });
expect(setAvailability.statusCode).toBe(200); expect(setAvailability.statusCode).toBe(200);
@@ -67,6 +79,7 @@ describe('Support organization — agent skills and availability (User Story 2)'
const updateAvailability = await app.inject({ const updateAvailability = await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/agents/${agentId}/availability`, url: `/admin/agents/${agentId}/availability`,
headers: authHeader(token),
payload: { status: 'available', workingHours: { mon: '9-17' } }, payload: { status: 'available', workingHours: { mon: '9-17' } },
}); });
expect(updateAvailability.statusCode).toBe(200); expect(updateAvailability.statusCode).toBe(200);
@@ -74,6 +87,7 @@ describe('Support organization — agent skills and availability (User Story 2)'
const current = await app.inject({ const current = await app.inject({
method: 'GET', method: 'GET',
url: `/admin/agents/${agentId}/availability`, url: `/admin/agents/${agentId}/availability`,
headers: authHeader(token),
}); });
expect(current.json().data.status).toBe('available'); 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({ const response = await app.inject({
method: 'PUT', method: 'PUT',
url: `/admin/agents/${agentId}/availability`, url: `/admin/agents/${agentId}/availability`,
headers: authHeader(token),
payload: { status: 'not_a_real_status', workingHours: {} }, payload: { status: 'not_a_real_status', workingHours: {} },
}); });
expect(response.statusCode).toBe(400); expect(response.statusCode).toBe(400);
@@ -2,15 +2,18 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app'; import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database'; import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
/** Covers specs/006-support-organization/quickstart.md Scenario 1 against a real Postgres. */ /** Covers specs/006-support-organization/quickstart.md Scenario 1 against a real Postgres. */
describe('Support organization — teams and agents (User Story 1)', () => { describe('Support organization — teams and agents (User Story 1)', () => {
let app: FastifyInstance; let app: FastifyInstance;
let token: string;
const teamName = `Test Team ${Date.now()}`; const teamName = `Test Team ${Date.now()}`;
let teamId: string; let teamId: string;
beforeAll(async () => { beforeAll(async () => {
app = await buildApp(); app = await buildApp();
token = await loginAs(app, 'ADMIN');
}); });
afterAll(async () => { afterAll(async () => {
@@ -27,6 +30,7 @@ describe('Support organization — teams and agents (User Story 1)', () => {
const createTeam = await app.inject({ const createTeam = await app.inject({
method: 'POST', method: 'POST',
url: '/admin/teams', url: '/admin/teams',
headers: authHeader(token),
payload: { name: teamName }, payload: { name: teamName },
}); });
expect(createTeam.statusCode).toBe(201); expect(createTeam.statusCode).toBe(201);
@@ -36,48 +40,63 @@ describe('Support organization — teams and agents (User Story 1)', () => {
const createAgent = await app.inject({ const createAgent = await app.inject({
method: 'POST', method: 'POST',
url: `/admin/teams/${teamId}/agents`, url: `/admin/teams/${teamId}/agents`,
headers: authHeader(token),
payload: { name: 'Agent A' }, payload: { name: 'Agent A' },
}); });
expect(createAgent.statusCode).toBe(201); expect(createAgent.statusCode).toBe(201);
const agentId = createAgent.json().data.id; 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); expect(roster.json().data.agents.map((a: { id: string }) => a.id)).toContain(agentId);
await app.inject({ await app.inject({
method: 'PATCH', method: 'PATCH',
url: `/admin/agents/${agentId}`, url: `/admin/agents/${agentId}`,
headers: authHeader(token),
payload: { active: false }, payload: { active: false },
}); });
const activeListing = await app.inject({ const activeListing = await app.inject({
method: 'GET', method: 'GET',
url: `/admin/agents?active=true&teamId=${teamId}`, url: `/admin/agents?active=true&teamId=${teamId}`,
headers: authHeader(token),
}); });
expect(activeListing.json().data.map((a: { id: string }) => a.id)).not.toContain(agentId); 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.statusCode).toBe(200);
expect(directFetch.json().data.active).toBe(false); expect(directFetch.json().data.active).toBe(false);
await app.inject({ await app.inject({
method: 'PATCH', method: 'PATCH',
url: `/admin/agents/${agentId}`, url: `/admin/agents/${agentId}`,
headers: authHeader(token),
payload: { active: true }, payload: { active: true },
}); });
const reactivatedListing = await app.inject({ const reactivatedListing = await app.inject({
method: 'GET', method: 'GET',
url: `/admin/agents?active=true&teamId=${teamId}`, url: `/admin/agents?active=true&teamId=${teamId}`,
headers: authHeader(token),
}); });
expect(reactivatedListing.json().data.map((a: { id: string }) => a.id)).toContain(agentId); expect(reactivatedListing.json().data.map((a: { id: string }) => a.id)).toContain(agentId);
await app.inject({ await app.inject({
method: 'PATCH', method: 'PATCH',
url: `/admin/teams/${teamId}`, url: `/admin/teams/${teamId}`,
headers: authHeader(token),
payload: { active: false }, payload: { active: false },
}); });
const agentAfterTeamDeactivation = await app.inject({ const agentAfterTeamDeactivation = await app.inject({
method: 'GET', method: 'GET',
url: `/admin/agents/${agentId}`, url: `/admin/agents/${agentId}`,
headers: authHeader(token),
}); });
expect(agentAfterTeamDeactivation.json().data.active).toBe(true); 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({ const response = await app.inject({
method: 'POST', method: 'POST',
url: '/admin/teams/nonexistent-team-id/agents', url: '/admin/teams/nonexistent-team-id/agents',
headers: authHeader(token),
payload: { name: 'Ghost Agent' }, payload: { name: 'Ghost Agent' },
}); });
expect(response.statusCode).toBe(404); expect(response.statusCode).toBe(404);
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app'; import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database'; import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
import { import {
encryptCredential, encryptCredential,
generateCredentialSecret, 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. */ /** Covers specs/003-ticketing/quickstart.md Scenario 5 against a real Postgres/Redis/MinIO. */
describe('Ticket attachments — upload, confirm, scan-gated download', () => { describe('Ticket attachments — upload, confirm, scan-gated download', () => {
let app: FastifyInstance; let app: FastifyInstance;
let agentToken: string;
let ticketId: string; let ticketId: string;
const externalProductId = `TEST_ATT_PROD_${Date.now()}`; const externalProductId = `TEST_ATT_PROD_${Date.now()}`;
beforeAll(async () => { beforeAll(async () => {
app = await buildApp(); app = await buildApp();
agentToken = await loginAs(app, 'AGENT');
const product = await prismaClient.product.create({ const product = await prismaClient.product.create({
data: { externalProductId, name: 'Attachments Test Product', status: 'active' }, 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({ const response = await app.inject({
method: 'POST', method: 'POST',
url: `/tickets/${ticketId}/attachments/upload-url`, url: `/tickets/${ticketId}/attachments/upload-url`,
headers: authHeader(agentToken),
payload: { fileName: 'huge.pdf', mimeType: 'application/pdf', sizeBytes: 999_999_999 }, payload: { fileName: 'huge.pdf', mimeType: 'application/pdf', sizeBytes: 999_999_999 },
}); });
expect(response.statusCode).toBe(400); expect(response.statusCode).toBe(400);
@@ -79,6 +84,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => {
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: 'POST',
url: `/tickets/${ticketId}/attachments/upload-url`, url: `/tickets/${ticketId}/attachments/upload-url`,
headers: authHeader(agentToken),
payload: { fileName: 'evil.exe', mimeType: 'application/x-msdownload', sizeBytes: 100 }, payload: { fileName: 'evil.exe', mimeType: 'application/x-msdownload', sizeBytes: 100 },
}); });
expect(response.statusCode).toBe(400); expect(response.statusCode).toBe(400);
@@ -88,6 +94,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => {
const uploadUrlResponse = await app.inject({ const uploadUrlResponse = await app.inject({
method: 'POST', method: 'POST',
url: `/tickets/${ticketId}/attachments/upload-url`, url: `/tickets/${ticketId}/attachments/upload-url`,
headers: authHeader(agentToken),
payload: { fileName: 'screenshot.png', mimeType: 'image/png', sizeBytes: 1024 }, payload: { fileName: 'screenshot.png', mimeType: 'image/png', sizeBytes: 1024 },
}); });
expect(uploadUrlResponse.statusCode).toBe(200); expect(uploadUrlResponse.statusCode).toBe(200);
@@ -103,6 +110,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => {
const confirmResponse = await app.inject({ const confirmResponse = await app.inject({
method: 'POST', method: 'POST',
url: `/tickets/${ticketId}/attachments/confirm`, url: `/tickets/${ticketId}/attachments/confirm`,
headers: authHeader(agentToken),
payload: { storageKey, fileName: 'screenshot.png', mimeType: 'image/png', sizeBytes: 1024 }, payload: { storageKey, fileName: 'screenshot.png', mimeType: 'image/png', sizeBytes: 1024 },
}); });
expect(confirmResponse.statusCode).toBe(201); expect(confirmResponse.statusCode).toBe(201);
@@ -112,6 +120,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => {
const downloadWhilePending = await app.inject({ const downloadWhilePending = await app.inject({
method: 'GET', method: 'GET',
url: `/tickets/${ticketId}/attachments/${attachmentId}/download-url`, url: `/tickets/${ticketId}/attachments/${attachmentId}/download-url`,
headers: authHeader(agentToken),
}); });
expect(downloadWhilePending.statusCode).toBe(409); expect(downloadWhilePending.statusCode).toBe(409);
@@ -126,6 +135,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => {
const downloadAfterScan = await app.inject({ const downloadAfterScan = await app.inject({
method: 'GET', method: 'GET',
url: `/tickets/${ticketId}/attachments/${attachmentId}/download-url`, url: `/tickets/${ticketId}/attachments/${attachmentId}/download-url`,
headers: authHeader(agentToken),
}); });
// The placeholder scanner fails closed (always 'infected'), so this remains refused — // 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. // 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({ const uploadUrlResponse = await app.inject({
method: 'POST', method: 'POST',
url: `/tickets/${ticketId}/attachments/upload-url`, url: `/tickets/${ticketId}/attachments/upload-url`,
headers: authHeader(agentToken),
payload: { fileName: 'clean.pdf', mimeType: 'application/pdf', sizeBytes: 2048 }, payload: { fileName: 'clean.pdf', mimeType: 'application/pdf', sizeBytes: 2048 },
}); });
const { uploadUrl, storageKey } = uploadUrlResponse.json().data; const { uploadUrl, storageKey } = uploadUrlResponse.json().data;
@@ -153,6 +164,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => {
const confirmResponse = await app.inject({ const confirmResponse = await app.inject({
method: 'POST', method: 'POST',
url: `/tickets/${ticketId}/attachments/confirm`, url: `/tickets/${ticketId}/attachments/confirm`,
headers: authHeader(agentToken),
payload: { storageKey, fileName: 'clean.pdf', mimeType: 'application/pdf', sizeBytes: 2048 }, payload: { storageKey, fileName: 'clean.pdf', mimeType: 'application/pdf', sizeBytes: 2048 },
}); });
const attachmentId = confirmResponse.json().data.id; const attachmentId = confirmResponse.json().data.id;
@@ -169,6 +181,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => {
const downloadResponse = await app.inject({ const downloadResponse = await app.inject({
method: 'GET', method: 'GET',
url: `/tickets/${ticketId}/attachments/${attachmentId}/download-url`, url: `/tickets/${ticketId}/attachments/${attachmentId}/download-url`,
headers: authHeader(agentToken),
}); });
expect(downloadResponse.statusCode).toBe(200); expect(downloadResponse.statusCode).toBe(200);
const { downloadUrl } = downloadResponse.json().data; const { downloadUrl } = downloadResponse.json().data;
@@ -7,6 +7,7 @@ import {
generateCredentialSecret, generateCredentialSecret,
issueIntegrationToken, issueIntegrationToken,
} from '@/modules/catalog/products'; } 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 * 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', () => { describe('Ticket creation via the inbound trust boundary', () => {
let app: FastifyInstance; let app: FastifyInstance;
let secret: string; let secret: string;
let agentToken: string;
const externalProductId = `TEST_TICKET_PROD_${Date.now()}`; const externalProductId = `TEST_TICKET_PROD_${Date.now()}`;
beforeAll(async () => { beforeAll(async () => {
app = await buildApp(); app = await buildApp();
agentToken = await loginAs(app, 'AGENT');
const product = await prismaClient.product.create({ const product = await prismaClient.product.create({
data: { externalProductId, name: 'Ticket Test Product', status: 'active' }, 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({ const first = await app.inject({
method: 'PATCH', method: 'PATCH',
url: `/tickets/${ticketId}/status`, url: `/tickets/${ticketId}/status`,
headers: authHeader(agentToken),
payload: { status: 'AI_ANALYZING', expectedVersion: ticket.version }, payload: { status: 'AI_ANALYZING', expectedVersion: ticket.version },
}); });
const second = await app.inject({ const second = await app.inject({
method: 'PATCH', method: 'PATCH',
url: `/tickets/${ticketId}/status`, url: `/tickets/${ticketId}/status`,
headers: authHeader(agentToken),
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version }, payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
}); });
@@ -145,6 +150,7 @@ describe('Ticket creation via the inbound trust boundary', () => {
const response = await app.inject({ const response = await app.inject({
method: 'PATCH', method: 'PATCH',
url: `/tickets/${ticketId}/status`, url: `/tickets/${ticketId}/status`,
headers: authHeader(agentToken),
payload: { status: 'RESOLVED', expectedVersion: ticket.version }, payload: { status: 'RESOLVED', expectedVersion: ticket.version },
}); });
+11 -1
View File
@@ -8,15 +8,18 @@ import {
issueIntegrationToken, issueIntegrationToken,
} from '@/modules/catalog/products'; } from '@/modules/catalog/products';
import { MESSAGE_TYPES } from '@/modules/ticketing/messages/mapper/message-visibility'; 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. */ /** Covers specs/003-ticketing/quickstart.md Scenario 4 against a real Postgres. */
describe('Ticket messages — type-scoped visibility', () => { describe('Ticket messages — type-scoped visibility', () => {
let app: FastifyInstance; let app: FastifyInstance;
let ticketId: string; let ticketId: string;
let authToken: string;
const externalProductId = `TEST_MSG_PROD_${Date.now()}`; const externalProductId = `TEST_MSG_PROD_${Date.now()}`;
beforeAll(async () => { beforeAll(async () => {
app = await buildApp(); app = await buildApp();
authToken = await loginAs(app, 'AGENT');
const product = await prismaClient.product.create({ const product = await prismaClient.product.create({
data: { externalProductId, name: 'Messages Test Product', status: 'active' }, data: { externalProductId, name: 'Messages Test Product', status: 'active' },
@@ -57,6 +60,7 @@ describe('Ticket messages — type-scoped visibility', () => {
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: 'POST',
url: `/tickets/${ticketId}/messages`, url: `/tickets/${ticketId}/messages`,
headers: authHeader(authToken),
payload: { type, body: `Message of type ${type}` }, payload: { type, body: `Message of type ${type}` },
}); });
expect(response.statusCode).toBe(201); 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 () => { 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); expect(response.statusCode).toBe(200);
const types = response.json().data.map((m: { type: string }) => m.type); 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({ const response = await app.inject({
method: 'GET', method: 'GET',
url: `/agent/tickets/${ticketId}/messages`, url: `/agent/tickets/${ticketId}/messages`,
headers: authHeader(authToken),
}); });
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
const types = response.json().data.map((m: { type: string }) => m.type); 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({ const response = await app.inject({
method: 'POST', method: 'POST',
url: `/tickets/${ticketId}/messages`, url: `/tickets/${ticketId}/messages`,
headers: authHeader(authToken),
payload: { type: 'NOT_A_REAL_TYPE', body: 'x' }, payload: { type: 'NOT_A_REAL_TYPE', body: 'x' },
}); });
expect(response.statusCode).toBe(400); expect(response.statusCode).toBe(400);
@@ -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<Record<string, unknown>> = {}) {
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 });
});
});
+37
View File
@@ -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();
});
});
@@ -20,15 +20,16 @@ describe('EscalationService.handleBreach', () => {
} as never; } as never;
const rules = { findActiveRules: vi.fn().mockResolvedValue([rule]) } as never; const rules = { findActiveRules: vi.fn().mockResolvedValue([rule]) } as never;
const events = { create: vi.fn().mockResolvedValue({ id: 'event-1' }) } 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); const service = new EscalationService(policies, rules, events, assignmentEngine);
await service.handleBreach('t1', 'resolution_breach'); await service.handleBreach('t1', 'resolution_breach');
expect((rules as { findActiveRules: ReturnType<typeof vi.fn> }).findActiveRules).toHaveBeenCalledWith( expect(
'policy-1', (rules as { findActiveRules: ReturnType<typeof vi.fn> }).findActiveRules,
'resolution_breach', ).toHaveBeenCalledWith('policy-1', 'resolution_breach');
);
expect((events as { create: ReturnType<typeof vi.fn> }).create).toHaveBeenCalledWith( expect((events as { create: ReturnType<typeof vi.fn> }).create).toHaveBeenCalledWith(
expect.objectContaining({ ticketId: 't1', ruleId: 'rule-1', toNodeId: 'node-1' }), 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); const service = new EscalationService(policies, rules, events, assignmentEngine);
await service.handleBreach('t1', 'resolution_breach'); await service.handleBreach('t1', 'resolution_breach');
expect((rules as { findActiveRules: ReturnType<typeof vi.fn> }).findActiveRules).not.toHaveBeenCalled(); expect(
(rules as { findActiveRules: ReturnType<typeof vi.fn> }).findActiveRules,
).not.toHaveBeenCalled();
expect((events as { create: ReturnType<typeof vi.fn> }).create).not.toHaveBeenCalled(); expect((events as { create: ReturnType<typeof vi.fn> }).create).not.toHaveBeenCalled();
}); });
@@ -34,10 +34,7 @@ describe('SlaService.runBreachDetectionSweep', () => {
const service = new SlaService(undefined, runsRepo, undefined, undefined, escalation); const service = new SlaService(undefined, runsRepo, undefined, undefined, escalation);
await service.runBreachDetectionSweep(); await service.runBreachDetectionSweep();
expect(update).toHaveBeenCalledWith( expect(update).toHaveBeenCalledWith('r1', expect.objectContaining({ status: 'breached' }));
'r1',
expect.objectContaining({ status: 'breached' }),
);
expect(handleBreach).toHaveBeenCalledWith('t1', 'resolution_breach'); expect(handleBreach).toHaveBeenCalledWith('t1', 'resolution_breach');
}); });
@@ -53,8 +53,10 @@ describe('SlaService pause/resume', () => {
expect(patch.pausedAt).toBeNull(); expect(patch.pausedAt).toBeNull();
const shiftedResolution = (patch.resolutionDueAt as Date).getTime(); const shiftedResolution = (patch.resolutionDueAt as Date).getTime();
const expectedShiftMin = new Date('2026-01-05T17:00:00.000Z').getTime() + (before - pausedAt.getTime()); const expectedShiftMin =
const expectedShiftMax = new Date('2026-01-05T17:00:00.000Z').getTime() + (after - pausedAt.getTime()); 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).toBeGreaterThanOrEqual(expectedShiftMin);
expect(shiftedResolution).toBeLessThanOrEqual(expectedShiftMax); expect(shiftedResolution).toBeLessThanOrEqual(expectedShiftMax);
}); });
@@ -67,7 +69,11 @@ describe('SlaService pause/resume', () => {
}); });
const secondPausedAt = new Date(Date.now() - 10 * 60 * 1000); 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 update = vi.fn().mockResolvedValue(pausedAgain);
const runsRepo = { findByTicketId: vi.fn().mockResolvedValue(pausedAgain), update } as never; const runsRepo = { findByTicketId: vi.fn().mockResolvedValue(pausedAgain), update } as never;
const service = new SlaService(undefined, runsRepo); const service = new SlaService(undefined, runsRepo);
@@ -16,6 +16,7 @@ function fakeAgent(id: string) {
teamId: 't', teamId: 't',
name: id, name: id,
active: true, active: true,
userId: null,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
skills: [], skills: [],
@@ -1,5 +1,8 @@
import { describe, it, expect } from 'vitest'; 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 = { const MON_FRI_9_TO_5 = {
timezone: 'America/New_York', timezone: 'America/New_York',
@@ -66,7 +69,9 @@ describe('addBusinessMinutes', () => {
it('returns the start time unchanged when minutes is zero or negative', () => { it('returns the start time unchanged when minutes is zero or negative', () => {
const start = new Date(Date.UTC(2026, 0, 5, 15, 0)); 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)', () => { it('is correct across a DST transition (US spring-forward, March 2026)', () => {
@@ -1,5 +1,8 @@
import { describe, it, expect } from 'vitest'; 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', () => { describe('createRootCauseSchema', () => {
it('accepts every documented root cause type', () => { it('accepts every documented root cause type', () => {