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:
co-authored by
Claude Sonnet 5
parent
8327fafac2
commit
40687f68fa
Generated
+138
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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[]
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { env } from './env';
|
||||||
|
|
||||||
|
export const authConfig = {
|
||||||
|
jwtSecret: env.JWT_SECRET,
|
||||||
|
tokenLifetimeHours: env.AUTH_TOKEN_LIFETIME_HOURS,
|
||||||
|
};
|
||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
Vendored
+1
@@ -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';
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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'],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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'] },
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user