Author SHA1 Message Date
saqib mirandClaude Sonnet 5 40687f68fa 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>
2026-09-07 12:45:37 +05:30
saqib mirandClaude Sonnet 5 8327fafac2 tasks: task breakdown for identity and authentication feature (010)
36 tasks across 8 phases (5 user stories + setup/foundational/polish).
US1 (real login) and US2 (real route/role gating) are the P1 MVP; the
one task that touches code outside identity/* (T020, adding
requireRole('ADMIN') across 002-009's existing admin routes) is called
out explicitly to run each touched module's own test suite immediately
after, not only in the final regression pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 11:15:45 +05:30
saqib mirandClaude Sonnet 5 3b4c220a45 plan: design for identity and authentication feature (010)
Phase 0 research resolves the library choices (jsonwebtoken + bcryptjs,
chosen partly to avoid native-build friction on Windows dev
environments), the Redis-backed revocation-denylist shape (reusing
002's own jti-replay-protection pattern exactly), a 4-hour token
lifetime, and why fastify.authenticate populating the already-shared
reqContext.actorId/actorType retroactively makes every audit trail
since 007 accurate for real agent/admin actions instead of always
'unknown'.

Also surfaces and scopes a real gap found along the way: User (login
identity) and Agent (routing/skills profile) have never been linked.
Adds Agent.userId as a nullable FK now (cheap, additive) without
building the actual linking workflow, which belongs in 006's own
identity/agents admin screens as a later, separate piece of work.

Phase 1 adds data-model.md, the login/self-identity/account-creation/
logout contract, and five quickstart scenarios including a specific
requirement to re-verify at least one already-shipped admin route per
module (002-009), not just this feature's own new endpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 11:14:42 +05:30
saqib mirandClaude Sonnet 5 a49389dc2c docs: spec for identity and authentication (010)
Not on the original roadmap -- surfaced as a genuine blocking gap while
planning supporthub-web's own agent/admin UI feature: fastify.authenticate
has been a complete no-op stub since 002, and identity/auth's login
endpoint has never taken a password. User/UserRole (two seeded-but-
passwordless demo accounts) and the AuthUser/JwtPayload types were all
already scaffolded and clearly intended for exactly this -- this finishes
that original wiring rather than inventing a new design.

Scope: real login (password hash + JWT), fastify.authenticate actually
rejecting invalid sessions, role-based route gating, a self-identity
endpoint, admin-created accounts, and logout. Password reset, MFA, and
login rate-limiting are explicitly deferred to Phase 11's own security
hardening pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 11:09:46 +05:30
87 changed files with 2468 additions and 173 deletions
+138
View File
@@ -19,11 +19,13 @@
"@opentelemetry/api": "^1.8.0",
"@opentelemetry/sdk-trace-base": "^1.22.0",
"@prisma/client": "^5.12.1",
"bcryptjs": "^3.0.3",
"bullmq": "^5.7.1",
"dotenv": "^16.4.5",
"fastify": "^4.26.2",
"fastify-plugin": "^4.5.1",
"ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.3",
"luxon": "^3.7.2",
"pino": "^8.20.0",
"pino-pretty": "^11.0.0",
@@ -31,6 +33,8 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/luxon": "^3.7.5",
"@types/node": "^20.12.7",
"@typescript-eslint/eslint-plugin": "^7.6.0",
@@ -1968,6 +1972,13 @@
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1975,6 +1986,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/luxon": {
"version": "3.7.5",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.5.tgz",
@@ -1982,6 +2004,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
@@ -2547,6 +2576,15 @@
],
"license": "MIT"
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"license": "BSD-3-Clause",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -2618,6 +2656,12 @@
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bullmq": {
"version": "5.81.3",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.81.3.tgz",
@@ -3070,6 +3114,15 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -4319,6 +4372,49 @@
"dev": true,
"license": "MIT"
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -4544,6 +4640,42 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -4551,6 +4683,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/log-update": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
+4
View File
@@ -56,11 +56,13 @@
"@opentelemetry/api": "^1.8.0",
"@opentelemetry/sdk-trace-base": "^1.22.0",
"@prisma/client": "^5.12.1",
"bcryptjs": "^3.0.3",
"bullmq": "^5.7.1",
"dotenv": "^16.4.5",
"fastify": "^4.26.2",
"fastify-plugin": "^4.5.1",
"ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.3",
"luxon": "^3.7.2",
"pino": "^8.20.0",
"pino-pretty": "^11.0.0",
@@ -68,6 +70,8 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/luxon": "^3.7.5",
"@types/node": "^20.12.7",
"@typescript-eslint/eslint-plugin": "^7.6.0",
@@ -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;
+16 -6
View File
@@ -14,12 +14,17 @@ enum UserRole {
}
model User {
id String @id @default(uuid())
email String @unique
name String
role UserRole @default(CUSTOMER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
email String @unique
name String
role UserRole @default(CUSTOMER)
passwordHash String // bcryptjs hash — never the plaintext password; see
// specs/010-identity-auth/data-model.md
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
agent Agent?
@@map("users")
}
@@ -417,6 +422,11 @@ model Agent {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Nullable link to the login identity this routing/skills profile belongs to — schema
// capability only, no workflow sets it yet; see specs/010-identity-auth/research.md.
userId String? @unique
user User? @relation(fields: [userId], references: [id])
skills AgentSkill[]
availability AgentAvailability?
assignments Assignment[]
+7
View File
@@ -1,9 +1,15 @@
import { randomUUID } from 'crypto';
import { PrismaClient, UserRole } from '@prisma/client';
import bcrypt from 'bcryptjs';
export async function seedDemoData(prisma: PrismaClient): Promise<void> {
// eslint-disable-next-line no-console
console.log(' -> Seeding demo environment data...');
// Legacy demo row, pre-existing since before 010-identity-auth: a CUSTOMER-role User is
// never a real login identity (customer identity is exclusively SaaS-delegated, see
// specs/010-identity-auth/spec.md Assumptions) — passwordHash is populated only to satisfy
// the column's NOT NULL constraint; this account can never authenticate via /auth/login.
await prisma.user.upsert({
where: { email: 'john.doe@example.com' },
update: {},
@@ -11,6 +17,7 @@ export async function seedDemoData(prisma: PrismaClient): Promise<void> {
email: 'john.doe@example.com',
name: 'John Doe (Demo Customer)',
role: UserRole.CUSTOMER,
passwordHash: await bcrypt.hash(randomUUID(), 10),
},
});
}
+8
View File
@@ -1,4 +1,10 @@
import { PrismaClient, UserRole } from '@prisma/client';
import bcrypt from 'bcryptjs';
// Local/development bootstrap credentials only (specs/010-identity-auth/spec.md Edge Cases) —
// never used for a real deployment, which provisions its own first admin out of band.
const DEV_ADMIN_PASSWORD = 'ChangeMe123!';
const DEV_AGENT_PASSWORD = 'ChangeMe123!';
export async function seedRoles(prisma: PrismaClient): Promise<void> {
// eslint-disable-next-line no-console
@@ -11,6 +17,7 @@ export async function seedRoles(prisma: PrismaClient): Promise<void> {
email: 'admin@supporthub.internal',
name: 'System Admin',
role: UserRole.ADMIN,
passwordHash: await bcrypt.hash(DEV_ADMIN_PASSWORD, 10),
},
});
@@ -21,6 +28,7 @@ export async function seedRoles(prisma: PrismaClient): Promise<void> {
email: 'agent@supporthub.internal',
name: 'Default Support Agent',
role: UserRole.AGENT,
passwordHash: await bcrypt.hash(DEV_AGENT_PASSWORD, 10),
},
});
}
@@ -0,0 +1,68 @@
# Specification Quality Checklist: Identity and Authentication
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-09-07
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- This feature was not on the original 11-phase roadmap — it surfaced as a genuine blocking gap
while planning supporthub-web's `001-agent-admin-ui`: `fastify.authenticate` has been a
complete no-op stub since 002, and `identity/auth`'s login endpoint has never taken a
password. Numbered 010 in supporthub-api's own sequence since it's a real, immediately-needed
backend prerequisite, not deferred hardening.
- `User`/`UserRole` (with two seeded-but-passwordless demo accounts,
`admin@supporthub.internal`/`agent@supporthub.internal`) and the `AuthUser`/`JwtPayload`
types in `src/common/types` were all found already scaffolded, unwired, and clearly intended
for exactly this feature since the original pre-speckit scaffold — this is a "finish the
originally-intended wiring" feature, not a new design invented from nothing.
- Scope is deliberately narrow: real login + real route gating + role checks + a self-identity
endpoint + admin-created accounts + logout. Password reset, MFA, rate-limiting, and
registration are explicitly out of scope (Assumptions), matching Phase 11's own "security
hardening pass" as the more appropriate later home for those.
- All items pass; no revision iterations were needed.
- **Implementation-time finding**: making `fastify.authenticate` genuinely reject invalid/missing
tokens (FR-004) had a far larger blast radius than this feature's own tasks.md anticipated.
Dozens of routes across features 002-009 were already declared with `fastify.authenticate` as
a preHandler — safe to write against a no-op stub, but every one of those pre-existing
integration tests had been calling them with no `Authorization` header. Making the check real
broke ~18 integration test files suite-wide, requiring a `tests/helpers/auth.ts` (`loginAs`/
`authHeader`) and a file-by-file pass adding real bearer tokens, well beyond the mechanical
`requireRole('ADMIN')` rollout research.md had scoped for. A related, recurring bug: several
files already declared a local `const token = issueIntegrationToken(...)` for the unrelated
002 customer-trust-boundary flow, and naming the new admin/agent token variable `token` in the
same scope produced a `ReferenceError: Cannot access 'token' before initialization` — a genuine
temporal-dead-zone collision, not a tooling bug — fixed by using a non-colliding name
(`authToken`/`adminToken`/`agentToken`) per file.
- A second, subtler implementation-time finding: once admin-setup calls in test `beforeAll`
blocks started actually succeeding (previously they silently 401'd against the no-op stub),
wildcard/global SLA policies created by one integration test file could genuinely match tickets
created by another file running against the same shared throwaway Postgres, leaving orphaned
`sla_run` rows that RESTRICT-violated the FK on cleanup. Fixed by widening the affected files'
`afterAll` cleanup to delete `sla_run` rows by `ticketId` *and* by `policyId`, not just one or
the other.
@@ -0,0 +1,48 @@
# Contract: Identity and Authentication
## Login
- `POST /auth/login` — body `{ email, password }`. `401` on any failure (wrong password, no
such account, or a deactivated account) with an identical response body/status in every case
(FR-002/SC-003) — never a distinguishable "no such user" vs "wrong password." `200` with
`{ token, user: { id, email, name, role } }` on success.
## Self-identity
- `GET /auth/me` — gated by `fastify.authenticate`. `401` if the token is missing/invalid/
expired/revoked. `401` if the account behind a structurally-valid token no longer exists or
is deactivated (re-validated against current state, not the token's own claims alone). `200`
with `{ id, email, name, role }` on success.
## Account creation (admin-only)
- `POST /admin/users` — gated by `fastify.authenticate` + `requireRole('ADMIN')`. Body
`{ email, name, role, password }` (`role` one of `ADMIN`/`AGENT`). `403` for a valid non-admin
session. `409` if `email` is already in use. `201` with the created `{ id, email, name, role
}` (never the password or its hash) on success.
## Logout
- `POST /auth/logout` — gated by `fastify.authenticate`. Revokes the calling token's own `jti`
(Redis denylist, TTL = remaining lifetime) so it's rejected on any further use even before its
natural expiry. `200` on success.
## Guarantees (callable contract)
1. **Every route already gated by `fastify.authenticate` across 002-009 continues to accept a
valid session and now genuinely rejects a missing/invalid/expired/revoked one** — the gate
itself changes from a no-op to a real check; which routes carry the gate is unchanged
(FR-006, SC-001).
2. **A route additionally gated by `requireRole('ADMIN')` rejects a structurally valid session
whose role isn't `ADMIN`, with a response distinguishable from "no valid session at all"**
(403 vs 401) (FR-005, SC-002).
3. **A login failure never reveals whether the submitted email corresponds to an existing
account** — verified by comparing the exact response for a wrong password against a wholly
nonexistent email (FR-002, SC-003).
4. **No password is ever stored, logged, or returned anywhere in plaintext** — only
`passwordHash` is persisted, and no response body (login, self-identity, account creation)
ever includes it (FR-003, SC-004).
5. **An admin-created account can log in immediately with the password it was created with, no
manual step in between** (FR-008, SC-005).
6. **A token revoked via logout is rejected on any further use, even before its natural expiry**
(FR-009).
+60
View File
@@ -0,0 +1,60 @@
# Data Model: Identity and Authentication
## User (modified — two additive columns)
| Field | Type | Notes |
|---|---|---|
| `id` | `String @id @default(uuid())` | unchanged |
| `email` | `String @unique` | unchanged |
| `name` | `String` | unchanged |
| `role` | `UserRole @default(CUSTOMER)` | unchanged enum (`ADMIN \| AGENT \| CUSTOMER`) — this feature never assigns `CUSTOMER` (research.md/spec.md Assumptions); every row this feature creates or updates is `ADMIN` or `AGENT` |
| **`passwordHash`** | **`String`** | **new** — bcryptjs hash, never the plaintext password; `NOT NULL` since every account this feature manages must be able to log in (FR-010 requires both seeded demo accounts to get a real one) |
| **`active`** | **`Boolean @default(true)`** | **new** — mirrors `Agent.active`'s existing convention exactly; a deactivated account's session is rejected on re-validation (Edge Cases/User Story 3), without a hard delete |
| `createdAt` / `updatedAt` | `DateTime` | unchanged |
## Agent (modified — one additive, nullable column)
| Field | Type | Notes |
|---|---|---|
| **`userId`** | **`String? @unique`** | **new** — nullable FK to `User.id`, the schema capability to identify which login identity a routing/skills profile belongs to (research.md). No endpoint in this feature sets it; a follow-up in `identity/agents` (006) is expected to. |
No new Prisma model for "Session" — a session is a signed JWT the server never persists
(research.md's short-lived-JWT-plus-revocation-denylist decision); the denylist itself lives in
Redis (`auth:revoked:<jti>`, TTL = remaining token lifetime), not Postgres.
## JwtPayload (existing type, `src/common/types/auth.types.ts` — one additive field)
| Field | Type | Notes |
|---|---|---|
| `sub` | `string` | the `User.id` |
| `email` | `string` | unchanged |
| `role` | `string` | unchanged — `User.role` at issuance time |
| `actorType` | `ActorType` | unchanged — always `ActorType.USER` for these sessions (research.md) |
| **`jti`** | **`string`** | **new** — random UUID per issued token, the revocation-denylist key |
| `iat` / `exp` | `number` | unchanged, standard JWT claims |
## AuthUser (existing type, unchanged)
`{ id, email, role, actorType }` — what `fastify.authenticate` sets on `request.user` after
verifying the token; the same fields returned by login (FR-001) and the self-identity endpoint
(FR-007), minus `jti`/`iat`/`exp` (those are token bookkeeping, not identity).
## Validation rules
- Login: `email` a valid email string, `password` non-empty. The response for "no such user"
and "wrong password" MUST be byte-for-byte identical (FR-002/SC-003) — achieved by always
running the bcrypt comparison against either the found user's hash or a fixed dummy hash when
no user is found, so the response timing and shape never differ by branch.
- Account creation (User Story 4): `email` valid and not already in use, `name` non-empty,
`role` one of `ADMIN`/`AGENT` (never `CUSTOMER`, research.md), `password` non-empty (hashed
before storage, never persisted or logged in plaintext).
## State / lifecycle
- `User.active` (new column, above) is the deactivation flag. The self-identity endpoint (User
Story 3) re-fetches the `User` row by `sub` on every call and rejects if it no longer exists
or `active: false` — this is the feature's only server-side re-validation path; `fastify
.authenticate` itself does not re-fetch on every request (that would defeat the point of a
stateless JWT check), so a deactivated account's *other* already-issued-token requests remain
valid until that token's natural expiry or an explicit logout, exactly as spec.md's Edge Cases
already scopes it ("a short token lifetime bounds the rest").
+136
View File
@@ -0,0 +1,136 @@
# Implementation Plan: Identity and Authentication
**Branch**: `010-identity-auth` | **Date**: 2026-09-07 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/010-identity-auth/spec.md`
## Summary
Finishes the original, never-wired scaffold: `identity/auth`'s email-only login stub becomes a
real bcryptjs-verified, JWT-issuing login; `fastify.authenticate` (currently a complete no-op)
becomes a real signature/expiry/revocation check populating `request.user` and the already-
shared `request.reqContext.actorId`/`actorType` fields every module since 007 already reads; a
new `requireRole(...roles)` preHandler factory adds role-based gating on top. `User` gains
`passwordHash` and `active` columns. Logout revokes a token's `jti` via the same Redis-denylist
shape 002's replay protection already established.
## Technical Context
**Language/Version**: TypeScript 5.4 / Node.js 20+.
**Primary Dependencies**: `jsonwebtoken` (new — JWT sign/verify), `bcryptjs` (new — password
hashing, pure JS to avoid native-build friction on Windows dev environments). Reuses existing
`ioredis` for the revocation denylist.
**Storage**: PostgreSQL via Prisma (`User.passwordHash`, `User.active`). Redis for the
revocation denylist (`auth:revoked:<jti>`, TTL = remaining token lifetime) — same shape as
002's `hasSeenJti`/`markJtiSeen`.
**Testing**: Vitest — unit tests for password verification's identical-response-on-failure
behavior and the `requireRole` preHandler's role-matching logic; integration tests against real
Postgres/Redis for the full login → gated-route → logout flow, and specifically re-verifying at
least one already-shipped admin route per module (002-009) now genuinely rejects an invalid
session.
**Target Platform**: Same Fastify modular monolith. Modifies `src/plugins/auth.plugin.ts`,
populates `src/modules/identity/auth/`, adds `POST /admin/users` (a new small surface, placed
alongside `identity/agents`'s own admin routes since account management is an identity concern,
not `identity/auth`'s own — `identity/auth` owns login/logout/self-identity, not account CRUD).
**Project Type**: Backend service — single project.
**Performance Goals**: Token verification (signature + expiry + Redis denylist check) must stay
a single Redis round trip, not a Postgres query, on every gated request — only the self-identity
endpoint (User Story 3) re-fetches from Postgres, by design (research.md).
**Constraints**: MUST NOT reveal account existence via login's failure response (FR-002); MUST
NOT ever store or return a plaintext password (FR-003); MUST NOT change which existing routes
are gated, only make the gate real (FR-006); MUST re-validate against current account state on
the self-identity endpoint specifically, not on every request (data-model.md).
**Scale/Scope**: One modified plugin (`auth.plugin.ts`), one populated module
(`identity/auth`), one new small admin-account-creation surface, two new dependencies, two new
`User` columns, one seed-script update. Explicitly excludes: password reset, MFA, login-specific
rate-limiting, and retroactively adding `requireRole('ADMIN')` to every existing admin route
beyond a representative sample (research.md — tracked as this feature's own Polish-phase
mechanical task, not a redesign of any other feature's access model).
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle / Section | Check | Result |
|---|---|---|
| I. SaaS Is the Sole Identity & Access Authority | This feature authenticates SupportHub's own staff (`User`/`Agent`), explicitly never `CUSTOMER`-role accounts (research.md/spec.md Assumptions) — customer identity remains exclusively SaaS-delegated via 002's own trust boundary, untouched by this feature. Matches the constitution's own carve-out: "SupportHub is the sole authority only for its own domain: ... support org structure." | PASS |
| II. Configuration Over Hardcoding | Token lifetime and any future role list are read from a config value (research.md's 4-hour default), never a magic number duplicated at each call site. | PASS |
| III. Layered Architecture With Enforced Module Boundaries | `identity/auth` keeps its standard shape; `requireRole` is exported from `identity/auth`'s own public `index.ts` for other modules' routes to compose with, the same way `fastify.authenticate` itself is already a cross-cutting plugin-level primitive, not a module import. | PASS |
| IV. AI Recommends, Deterministic Policy Decides | Not applicable — no AI involvement in this feature. | PASS — N/A |
| V. Evidence-Based Verification | Not applicable — no resolution/verification concept in this feature. | PASS — N/A |
| VI. Durable Audit & History | This feature is what finally makes 007-009's own audit fields (`AssignmentHistory.actor`, `EscalationEvent.triggeredBy`, etc.) accurate for real agent/admin actions instead of always falling back to `'unknown'` (research.md) — directly strengthens, not just satisfies, this principle. | PASS |
| VII. Concurrency-Safe, Durable Job Handling | Token verification and revocation are stateless/Redis-TTL-based, not an in-memory timer; two concurrent login attempts for the same account are independently evaluated with no shared mutable state (spec.md Edge Cases). | PASS |
| VIII. Problem and Ticket Are Separate, Related Entities | Not applicable — this feature doesn't touch tickets or problems. | PASS — N/A |
| Technology & Platform Constraints | Two new, narrowly-scoped dependencies (`jsonwebtoken`, `bcryptjs`), both justified in research.md; reuses existing Redis infrastructure, no new infrastructure category introduced. | PASS |
No violations requiring Complexity Tracking justification.
## Post-Design Constitution Re-check
All gates above remain PASS after Phase 1 design. Principle VI is worth restating post-design:
this feature has no user-facing "audit" screen of its own, but its real effect is retroactively
correcting the audit trail of every feature since 007 that could only ever record `'unknown'`
as the acting agent/admin — a materially more accurate audit history the moment this ships.
## Project Structure
### Documentation (this feature)
```text
specs/010-identity-auth/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
└── tasks.md # Phase 2 output (/speckit-tasks — not created here)
```
### Source Code (repository root)
```text
supporthub-api/
├── prisma/
│ ├── schema.prisma # MODIFIED — User.passwordHash, User.active
│ └── seed/roles.seed.ts # MODIFIED — seeded accounts get real password hashes
├── src/
│ ├── plugins/
│ │ └── auth.plugin.ts # REPLACED stub — real JWT verify + revocation
│ │ check, populates request.user + reqContext
│ ├── infrastructure/
│ │ └── cache/ # MODIFIED — revocation denylist helpers
│ │ alongside the existing jti-replay helpers
│ └── modules/
│ └── identity/
│ ├── auth/ # REPLACED stub — full real login/logout/
│ │ ├── controller/ routes/ schema/ self-identity, requireRole exported from
│ │ │ repository/ service/ types/ its own public index.ts
│ │ │ mapper/ constants/ index.ts
│ │ └── (no engine/ — no real decision logic beyond password/token checks)
│ └── agents/ # MODIFIED — new POST /admin/users route
│ └── (existing module, account-creation surface added alongside its own
│ existing agent-roster admin routes)
└── tests/
├── unit/identity/ # password-failure-response-parity,
│ requireRole matching logic
└── integration/ # full login/gating/logout flow, spot-checks
across 002-009's own existing admin routes
```
**Structure Decision**: Single project. `POST /admin/users` (account creation) lives under
`identity/agents` rather than `identity/auth`, since `identity/auth` owns authentication
mechanics (login/logout/self-identity) while account/roster management is already that
module's own established concern — mirrors 006's own precedent of `identity/agents` owning
agent-roster CRUD.
## Complexity Tracking
*No constitution violations — table intentionally omitted.*
+52
View File
@@ -0,0 +1,52 @@
# Quickstart: Validating Identity and Authentication
Prerequisites: migrations applied; `npm run prisma:seed` run so the two demo accounts exist
with their new real passwords (documented in the seed script itself, local/dev use only).
## Scenario 1 — login (User Story 1)
1. `POST /auth/login` with the seeded admin's correct email/password. **Expected**: `200`, a
token, and `{id, email, name, role: 'ADMIN'}`.
2. Repeat with the correct email but a wrong password. **Expected**: `401`.
3. Repeat with an email that doesn't exist at all. **Expected**: the exact same `401` body/
status as step 2 — diff the two responses to confirm they're indistinguishable.
## Scenario 2 — route gating and role enforcement (User Story 2)
1. Call an existing admin route (e.g. `POST /admin/teams`) with no `Authorization` header.
**Expected**: `401`.
2. Repeat with a malformed token (`Bearer not-a-real-token`). **Expected**: `401`.
3. Log in as the seeded agent (role `AGENT`); call an admin-only route gated by
`requireRole('ADMIN')`. **Expected**: `403`.
4. Log in as the seeded admin; repeat step 3's call. **Expected**: `200`/`201` (whatever that
route normally returns on success).
## Scenario 3 — self-identity (User Story 3)
1. Log in; call `GET /auth/me` with the resulting token. **Expected**: `200`, identity matches
the login response exactly.
2. Directly deactivate that account (`active: false`) via a direct DB update (simulating an
admin action no UI exists for yet); repeat the same `GET /auth/me` call with the same,
still-unexpired token. **Expected**: `401` — re-validated against current account state, not
the token's own claims.
## Scenario 4 — admin creates an account (User Story 4)
1. Log in as admin; `POST /admin/users` with a new email/name/role `AGENT`/password.
**Expected**: `201`, response never includes the password or its hash.
2. Log in as a non-admin (the seeded agent); repeat step 1. **Expected**: `403`.
3. Immediately log in as the newly-created account with the password from step 1. **Expected**:
`200` — no manual step needed in between.
4. Repeat step 1 with an email already in use. **Expected**: `409`.
## Scenario 5 — logout (User Story 5)
1. Log in; call `POST /auth/logout` with the resulting token. **Expected**: `200`.
2. Immediately reuse that same token on any gated route. **Expected**: `401` — rejected even
though it hasn't naturally expired.
## What "done" looks like
All five scenarios pass, and Scenario 2 is additionally verified against at least one
already-shipped admin route from each of 002-009 (not just a route this feature itself adds),
proving the real gate actually protects what the no-op stub never did.
+149
View File
@@ -0,0 +1,149 @@
# Phase 0 Research: Identity and Authentication
## Decision: Finish the existing scaffold's own intended design, not a new one
- **Decision**: `User`/`UserRole`, the two seeded-but-passwordless demo accounts, and
`AuthUser`/`JwtPayload` in `src/common/types` are the real target — this feature adds a
`passwordHash` column, replaces `identity/auth`'s email-only stub with real password
verification and JWT issuance, and makes `fastify.authenticate` actually verify that JWT.
- **Rationale**: Every shape needed (the JWT payload's exact fields, the user/role model, even
the bootstrap accounts) was already scaffolded before this session's spec-driven rebuild
began — this is the same "give an existing, unwired scaffold its first real implementation"
pattern every other phase in this codebase has followed, not a new design decision.
- **Alternatives considered**: A separate, purpose-built `Session`/`Credential` model instead of
extending `User` — rejected; `User` already has exactly the fields a staff account needs
(email, name, role), and doc 06 never defined a competing entity for this.
## Decision: reuse the existing, already-required `JWT_SECRET` env var — don't invent a new one
- **Decision**: Token signing/verification uses `env.JWT_SECRET` — a `z.string().min(16)`,
no-default, required environment variable already defined in `src/config/env.ts` and already
set in `.env.test`/`.env.example`/`vitest.config.ts` since before this session's spec-driven
rebuild began. This feature adds no new secret env var, only `AUTH_TOKEN_LIFETIME_HOURS`
(a non-secret, defaultable number).
- **Rationale**: Same "finish the scaffold's own intended design" pattern as `User`/
`JwtPayload` themselves — `JWT_SECRET` was clearly provisioned for exactly this feature and
has simply never been read by any code until now.
- **Alternatives considered**: A feature-specific `AUTH_JWT_SECRET` — considered and rejected
once `JWT_SECRET` was found; would create two secrets doing the identical job.
## Decision: `jsonwebtoken` for signing/verifying, `bcryptjs` for password hashing
- **Decision**: Add `jsonwebtoken` (plain library, no Fastify plugin registration — kept
consistent with `auth.plugin.ts`'s existing manual-decorator style rather than introducing the
`@fastify/jwt` plugin ecosystem) and `bcryptjs` (pure JavaScript, no native compilation step —
`bcrypt`/`argon2`'s native bindings are a real source of friction on this team's Windows dev
environment, confirmed earlier this session when Docker/Prisma tooling already needed
workarounds for the same class of platform friction).
- **Rationale**: Both are the standard, widely-used choice for their job; `bcryptjs`
specifically avoids re-litigating the native-module build problems this session has already
hit more than once on Windows.
- **Alternatives considered**: `@fastify/jwt` — rejected only for consistency with this
codebase's existing hand-rolled decorator style, not a correctness concern. `argon2`
rejected for the same native-build-friction reason as `bcrypt`; `bcryptjs` is a well-
established, secure-enough choice for this scale (JWT `Vitest`-verified via existing
precedent, not a cryptographic novelty).
## Decision: Redis-backed revocation denylist, reusing 002's own jti-tracking mechanism
- **Decision**: `JwtPayload` gains a `jti` (JWT ID, a random UUID per issued token). Logout adds
that `jti` to a Redis key (`auth:revoked:<jti>`) with a TTL equal to the token's own remaining
lifetime. `fastify.authenticate` checks this key (in addition to verifying the signature and
expiry) before accepting a token.
- **Rationale**: This is the exact same shape as 002's `hasSeenJti`/`markJtiSeen` replay-
protection mechanism (`src/infrastructure/cache`) — reused directly rather than inventing a
second Redis-backed token-tracking pattern. A TTL equal to remaining lifetime means the
denylist entry is automatically cleaned up and never grows unbounded.
- **Alternatives considered**: A full server-side session table (every issued token recorded in
Postgres, checked on every request) — rejected as unnecessary weight; spec.md's own
Assumptions explicitly chose "short-lived JWT + revocation-on-logout-only" over full session
tracking, and Redis is already the right tool for this exact shape of check (fast, TTL-native).
## Decision: A 4-hour token lifetime
- **Decision**: Issued JWTs expire 4 hours after issuance (`exp` claim).
- **Rationale**: Long enough that a working agent isn't repeatedly forced to re-authenticate
mid-shift, short enough that a leaked/forgotten token's exposure window is bounded in hours,
not days — a reasonable default for an internal staff tool with no remember-me/refresh-token
flow in this feature's scope (Assumptions: no MFA/hardening pass yet either).
- **Alternatives considered**: A refresh-token pair (short-lived access token + long-lived
refresh token) — rejected as more mechanism than this feature's scope calls for; nothing in
spec.md's user stories requires silent re-authentication, and it can be added later without
breaking the token shape this feature establishes.
## Decision: `fastify.authenticate` also populates the existing, already-shared `reqContext.actorId`/`actorType`
- **Decision**: On a valid token, `fastify.authenticate` sets `request.user` (the full
`AuthUser`) AND `request.reqContext.actorId = user.id`, `request.reqContext.actorType =
ActorType.USER` — the same two `RequestContext` fields `authenticateProductIntegration`
already populates for customer-originated requests (002).
- **Rationale**: Every module from 007 onward already reads `request.reqContext?.actorId ??
'unknown'` in its controllers (`actorFrom(request)` helpers in assignments, tickets,
escalation, resolutions) expecting exactly this to eventually be populated by a real staff
auth mechanism — this was a forward-compatible convention already in place, not something
this feature needs to change call sites for. Every one of those audit trails (
`AssignmentHistory.actor`, `EscalationEvent.triggeredBy`, etc.) becomes accurate for real
agent/admin actions the moment this feature ships, with no changes to 007-009's own code.
- **Alternatives considered**: A separate `request.user`-only convention, leaving `reqContext
.actorId` customer-only — rejected; would require touching every existing `actorFrom` call
site across four already-shipped features for no benefit, when the field was clearly designed
to be auth-mechanism-agnostic from the start.
## Decision: Role-gating via a `requireRole(...roles)` preHandler factory, not a fixed decorator
- **Decision**: A new exported function, `requireRole(...allowedRoles: string[])`, returns a
Fastify preHandler that checks `request.user?.role` against the given list, throwing
`AuthorizationError` (403) if it doesn't match — used as
`{ preHandler: [fastify.authenticate, requireRole('ADMIN')] }`. Not a fixed
`fastify.requireAdmin` decorator, even though `ADMIN` is the only role checked today.
- **Rationale**: A factory function generalizes to any future role/permission check (e.g. a
hypothetical `SENIOR_AGENT`) without a new decorator per role; `fastify.authenticate` and
`requireRole` compose as two separate preHandlers, matching this codebase's existing
`[fastify.authenticateProductIntegration, fastify.checkIntegrationRateLimit]` two-step
preHandler-array convention exactly.
- **Alternatives considered**: A single combined `fastify.authenticateAdmin` decorator —
rejected; would duplicate `fastify.authenticate`'s own token-verification logic for every new
role instead of composing with it.
## Decision: `Agent.userId` is added as a nullable link, but linking is not this feature's own workflow
- **Decision**: `Agent` gains `userId String? @unique`, a nullable FK to `User.id` — the schema
capability to say "this login identity's routing/skills profile is that `Agent` row" — but
this feature does not add an endpoint or admin screen to set it. No seed data links the
demo agent account to an `Agent` row either (none is seeded for it today).
- **Rationale**: While investigating account creation (User Story 4), a real gap surfaced: a
`User` (the thing that logs in) and an `Agent` (the thing 006/007 route tickets to) have never
been connected — an `AGENT`-role `User` today has no way to be identified as a *specific*
`Agent` for "tickets assigned to me"-style queries supporthub-web's own agent dashboard will
need. Adding the column now is cheap and unblocks that later without a schema change at that
point; building the actual linking workflow (which almost certainly belongs in 006's
`identity/agents` admin screens, alongside team/skill assignment, not this identity/auth
feature) is a real, separate piece of scope this feature doesn't need to solve today.
- **Alternatives considered**: Building the full link-an-account-to-an-agent workflow as part of
this feature — rejected as scope creep; this feature's own job is proving a `User` *can*
authenticate and be authorized, not completing every downstream consumer of that identity.
Making `Agent.userId` required — rejected; an `Agent` created via 006's existing screens has
no `User` account requirement today and shouldn't suddenly need one just because this feature
exists.
## Decision: Which existing routes get gated is unchanged — only the gate itself becomes real
- **Decision**: This feature does not add `fastify.authenticate` to any route that doesn't
already have it, and does not add `requireRole('ADMIN')` to every existing admin route as
part of this feature's own implementation — SC-002 is satisfied by demonstrating the
mechanism works on a representative sample (one action per module), with the mechanical work
of adding `requireRole('ADMIN')` to every remaining `/admin/*` route across 002-009 tracked as
this feature's own Polish-phase task, not a scope expansion into re-designing any other
feature's authorization model.
- **Rationale**: FR-006 is explicit: "this feature does not change which routes are gated, only
makes the gate real." Deciding which of the many already-shipped admin routes should be
admin-only vs. any-authenticated-agent is a real per-route judgment call (e.g., should an
agent be able to create a hierarchy node? almost certainly not; should an agent read one?
probably yes) — this feature makes that judgment call possible to enforce, and applies it
everywhere in its own Polish phase, but doesn't silently redesign any other feature's own
intended access model beyond what's obviously admin-only (write/config endpoints) vs.
read/agent-usable.
- **Alternatives considered**: Leaving every existing route exactly as `fastify.authenticate`-
only (no `requireRole`) and treating role-based gating as entirely out of scope — rejected;
spec.md's own User Story 2/FR-005 explicitly requires admin-only enforcement to exist
somewhere concrete, not just as an available-but-unused mechanism.
+237
View File
@@ -0,0 +1,237 @@
# Feature Specification: Identity and Authentication
**Feature Branch**: `010-identity-auth`
**Created**: 2026-09-07
**Status**: Draft
**Input**: User description: "supporthub-web's admin/agent role-gating (its own Phase 1,
001-agent-admin-ui) has no real backend to build on: `fastify.authenticate` is a complete
no-op stub, and the existing `identity/auth` scaffold's login endpoint accepts an email alone
with no password and returns the raw user record, never a session. Build minimal, real
agent/admin authentication in supporthub-api first, as a prerequisite for the frontend feature."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - An agent or admin logs in and receives a session (Priority: P1)
A user with a SupportHub-issued account (never a SaaS-delegated identity — this is SupportHub's
own staff, per Constitution Principle I's "support org structure" being SupportHub's own
authority) logs in with their email and password and receives a session token that authorizes
their subsequent requests.
**Why this priority**: Every other story in this feature, and the entire admin/agent-facing
half of supporthub-web, has nothing to build on without this.
**Independent Test**: Log in with a seeded account's correct credentials; confirm a session
token is returned and a subsequent authenticated request using it succeeds.
**Acceptance Scenarios**:
1. **Given** a user account with a set password, **When** they submit the correct email and
password, **Then** they receive a session token and their own `id`/`email`/`name`/`role`.
2. **Given** a user account, **When** they submit an incorrect password, **Then** the request
is rejected with no session token issued — the rejection message MUST NOT reveal whether the
email itself was valid (never "wrong password" vs "no such user" as distinguishable
responses).
3. **Given** no account exists for a submitted email, **When** login is attempted, **Then** it
is rejected with the same indistinguishable-from-wrong-password response as Scenario 2.
---
### User Story 2 - Protected routes require a valid session; admin-only routes require the admin role (Priority: P1)
Every existing `/admin/*` route (and any other route already gated by the `fastify.authenticate`
stub across features 002-009) actually rejects a request with no valid session, and every route
that should be admin-only actually rejects a valid session whose role isn't `ADMIN`.
**Why this priority**: This is the entire point of the feature — without it, User Story 1
issues a token that nothing on the backend actually checks, which is no better than the current
no-op stub.
**Independent Test**: Call an existing admin route (e.g. creating a team) with no
`Authorization` header, with an expired/malformed token, with a valid agent-role token, and
with a valid admin-role token; confirm exactly the last one succeeds.
**Acceptance Scenarios**:
1. **Given** a request with no `Authorization` header, **When** it hits a route gated by
`fastify.authenticate`, **Then** it's rejected as unauthorized.
2. **Given** a request with a malformed, expired, or tampered token, **When** it hits a gated
route, **Then** it's rejected as unauthorized — never silently treated as anonymous/no-op the
way the current stub does.
3. **Given** a valid session for a user whose role is `AGENT`, **When** it hits a route that
requires the `ADMIN` role specifically, **Then** it's rejected as forbidden, distinct from
the unauthorized case above.
4. **Given** a valid session for a user whose role is `ADMIN`, **When** it hits any route gated
by either `fastify.authenticate` or an admin-only requirement, **Then** it succeeds.
---
### User Story 3 - An authenticated user can identify themselves (Priority: P2)
A logged-in user can ask "who am I" and get back their own identity and role, without needing
to decode their own session token client-side.
**Why this priority**: Depends on User Story 1. supporthub-web's role-gating (rendering the
admin portal only for admins) needs a reliable way to know the current session's role after
the token is already held — decoding a JWT's claims client-side is a reasonable fallback, but a
real endpoint is what lets that identity be revalidated against current server-side state (e.g.
a deactivated account) rather than trusting a possibly-stale token's own claims forever.
**Independent Test**: Log in, then call the "who am I" endpoint with the resulting session;
confirm it returns the same identity and role as the login response, and that it's rejected
under the same conditions as User Story 2.
**Acceptance Scenarios**:
1. **Given** a valid session, **When** the identity endpoint is called, **Then** it returns the
current `id`/`email`/`name`/`role` for that session.
2. **Given** a session for an account that has since been deactivated, **When** the identity
endpoint (or any gated route) is called, **Then** it's rejected — a session's validity is
re-checked against current account state, not just the token's own unexpired signature.
---
### User Story 4 - An admin creates additional agent/admin accounts (Priority: P2)
An admin creates a new user account (agent or admin role) with an initial password, since there
is no public self-signup for SupportHub's own staff accounts.
**Why this priority**: Depends on User Story 2 (admin-only gating). Without this, the only way
to add a second real account is a direct database write — fine for the one seeded bootstrap
admin, not for onboarding a real team.
**Independent Test**: As an admin, create a new agent account with a password; confirm the new
account can immediately log in (User Story 1) with those credentials.
**Acceptance Scenarios**:
1. **Given** an authenticated admin, **When** they create a new account with an email,
name, role, and initial password, **Then** it's created and can log in immediately.
2. **Given** a non-admin session, **When** they attempt to create an account, **Then** it's
rejected as forbidden (User Story 2's own guarantee, exercised here specifically).
3. **Given** an email already in use by an existing account, **When** account creation is
attempted, **Then** it's rejected — never a second account silently sharing one email.
---
### User Story 5 - A user logs out (Priority: P3)
A logged-in user can end their own session explicitly, rather than only ever waiting for it to
expire.
**Why this priority**: Lowest priority — a short-lived token that simply expires already
bounds the exposure of a lost/leftover session; an explicit logout is a UX nicety layered on
top, not a security-critical gap the way User Stories 1-2 are.
**Independent Test**: Log in, log out, then attempt to use the same token again; confirm it's
now rejected.
**Acceptance Scenarios**:
1. **Given** a valid session, **When** the user logs out, **Then** that specific token is
rejected on any subsequent use, even though it hasn't yet expired.
---
### Edge Cases
- What happens to a session already issued to a user whose password is changed or whose account
is deactivated? Out of scope for this feature to build a full revocation-on-every-write
mechanism (Assumptions) — User Story 3's re-check-on-identity-call is the only server-side
re-validation this feature guarantees; a short token lifetime (Assumptions) bounds the rest.
- What happens if two login attempts for the same account happen concurrently with different
passwords (e.g. a credential-stuffing attempt racing a real login)? Each is evaluated
independently against the stored password hash — no shared mutable state between them, so no
new concurrency concern is introduced.
- What happens to the two demo accounts the seed script already creates
(`admin@supporthub.internal`, `agent@supporthub.internal`) which currently have no password?
This feature MUST give them real, seeded passwords (documented for local/dev use only) so the
existing seed script keeps producing an immediately-usable bootstrap admin — never account
IDs that exist but can never actually log in.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The system MUST let a user log in with email and password, returning a session
token and their own identity (`id`/`email`/`name`/`role`) on success.
- **FR-002**: A login attempt with an incorrect password or an unrecognized email MUST be
rejected with an indistinguishable response — the system MUST NOT reveal whether a submitted
email corresponds to an existing account.
- **FR-003**: Passwords MUST be stored only as a salted hash, never in plaintext or in any
reversible form.
- **FR-004**: `fastify.authenticate` MUST reject a request with a missing, malformed, expired,
or otherwise invalid session token — it MUST NOT pass a request through as anonymous/no-op
the way the current stub does.
- **FR-005**: The system MUST provide a way to require a specific role (at minimum, `ADMIN`)
on a route, distinct from and layered on top of `fastify.authenticate`'s own valid-session
check, returning a distinguishable forbidden (not unauthorized) response when the role
requirement fails.
- **FR-006**: Every existing route currently gated by `fastify.authenticate` (across
002-009's own admin/read surfaces) MUST continue to work for a valid session and MUST now
actually reject an invalid one — this feature does not change which routes are gated, only
makes the gate real.
- **FR-007**: The system MUST provide an endpoint that returns the current session's own
identity and role, re-validated against current account state (not solely the token's own
claims).
- **FR-008**: The system MUST let an authenticated admin create a new account (email, name,
role, initial password), rejecting a duplicate email.
- **FR-009**: The system MUST let a user invalidate their own current session token before its
natural expiry.
- **FR-010**: The two existing seeded demo accounts MUST be given real, working passwords as
part of this feature, documented as local/development credentials.
### Key Entities
- **User**: A SupportHub staff identity — email, name, role (`ADMIN`/`AGENT`), and (new in this
feature) a securely hashed password. Distinct from `Agent` (the routing/skills/team-membership
profile an `AGENT`-role `User` has) and from a SaaS-delegated customer identity, which this
feature does not touch.
- **Session**: The short-lived, server-issued proof that a `User` authenticated successfully,
carrying their `id`, `email`, and `role`; revocable before its natural expiry (User Story 5).
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 100% of requests to a `fastify.authenticate`-gated route with no valid session are
rejected, verified across every module's existing admin routes (002-009), not just this
feature's own new endpoints.
- **SC-002**: 100% of admin-only actions are rejected for a valid non-admin session, verified for
at least one action from each module that has one.
- **SC-003**: 0% of login rejections reveal whether the submitted email corresponds to an
existing account, verified by comparing the exact response for both cases.
- **SC-004**: 100% of passwords are stored only as a hash — verified by inspecting the stored
representation directly, never as a value that could be reversed to the original password.
- **SC-005**: An admin can create a working new account and have it log in successfully within
the same test run, with no manual database step.
## Assumptions
- **No password-reset/forgot-password flow is built in this feature** — an admin can create a
new account (User Story 4), but resetting an existing one's forgotten password is out of
scope; the smallest viable fix today is an admin recreating the account or a direct
operational fix, not a self-service flow.
- **Session tokens are short-lived JWTs with a fixed expiry, not a server-side session store per
token** — logout (User Story 5) is implemented via a revocation check (a denylist of
logged-out-early tokens), not full server-side session tracking; this keeps token validation
fast (no DB round trip on every request) while still making explicit logout meaningfully
different from "wait for expiry." The exact expiry duration and revocation mechanism are
research.md decisions, not fixed here.
- **No account self-registration** — every account is created either by the seed script (the
two bootstrap demo accounts) or by an existing admin (User Story 4); there is no public
sign-up endpoint, consistent with these being SupportHub's own staff accounts, never a
SaaS-delegated customer identity.
- **This feature does not add a password-strength policy, MFA, or rate-limiting specifically
for login attempts beyond what 002's existing generic rate-limit infrastructure might already
cover incidentally** — those are real hardening concerns explicitly named in
`docs/10-implementation-roadmap.md`'s own Phase 11 ("security hardening pass"), not this
feature's job to anticipate.
- **The `CUSTOMER` value already defined on `UserRole` is never assigned by this feature** — no
code path in this feature creates a `User` with `role: CUSTOMER`; per Constitution Principle
I, customer identity remains exclusively SaaS-delegated (002's inbound trust boundary), never
a local `User` row. The enum value's continued existence is a pre-existing scaffold detail
this feature doesn't need to remove to stay correct.
+285
View File
@@ -0,0 +1,285 @@
---
description: "Task list for 010-identity-auth"
---
# Tasks: Identity and Authentication
**Input**: Design documents from `specs/010-identity-auth/`
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
[data-model.md](./data-model.md),
[contracts/identity-auth-contract.md](./contracts/identity-auth-contract.md),
[quickstart.md](./quickstart.md)
**Tests**: Included as first-class tasks. Pure logic worth a unit test: the identical-failure-
response behavior (FR-002/SC-003) and the `requireRole` matching logic. Everything else is
best proven end-to-end against a real Postgres/Redis, including a specific pass re-verifying
existing 002-009 admin routes now actually reject an invalid session.
**Organization**: Tasks are grouped by user story (US1 = P1 login, US2 = P1 route/role gating,
US3 = P2 self-identity, US4 = P2 admin-created accounts, US5 = P3 logout).
## Format: `[ID] [P?] [Story] Description`
All file paths are relative to `supporthub-api/` (repo root).
---
## Phase 1: Setup
- [x] T001 [P] Add `jsonwebtoken` and `bcryptjs` (plus `@types/jsonwebtoken`,
`@types/bcryptjs`) to `package.json`
- [x] T002 [P] Add `AUTH_JWT_SECRET` (required, no default — never a committed secret) and
`AUTH_TOKEN_LIFETIME_HOURS` (`z.coerce.number().default(4)`) to `src/config/env.ts`,
exposed via a new `src/config/auth.ts` (`authConfig.jwtSecret`,
`authConfig.tokenLifetimeHours`), matching `orchestrationConfig`'s exact shape
- [x] T003 [P] Populate `src/modules/identity/auth/` with the full standard shape around its
existing files, replacing the email-only `AuthService.validateCredentials`/
`AuthRepository.findByEmail`-only stub content
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Schema for the entities every user story needs.
**⚠️ CRITICAL**: No user-story stage work can begin until this phase is complete.
- [x] T004 Add `User.passwordHash` (`String`, required) and `User.active` (`Boolean
@default(true)`) to `prisma/schema.prisma`, plus `Agent.userId` (`String? @unique`, FK to
`User.id` — research.md's additive, not-yet-consumed link) (depends on T001-T003)
- [x] T005 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`)
for T004 (depends on T004)
- [x] T006 Update `prisma/seed/roles.seed.ts` to set a real bcryptjs-hashed password on both
seeded accounts (`admin@supporthub.internal`, `agent@supporthub.internal`), documenting
the plaintext dev password in a comment directly above the hash call (local/dev use only,
per spec.md Edge Cases) (depends on T005)
**Checkpoint**: Schema migrated, demo accounts have real passwords. User stories can now be
built.
---
## Phase 3: User Story 1 - An agent or admin logs in and receives a session (Priority: P1) 🎯 MVP (part 1)
**Goal**: Real password verification and JWT issuance, with an identical failure response
regardless of which reason login failed.
**Independent Test**: Quickstart Scenario 1.
### Tests for User Story 1
- [x] T007 [P] [US1] Unit test: given a found user with a matching/non-matching password, and
given no user found at all, the login-failure path produces byte-identical response
shape/status in the non-matching and no-user cases — in
`tests/unit/identity/login-failure-parity.test.ts`
- [x] T008 [US1] Integration test covering Quickstart Scenario 1 (correct login succeeds with a
token + identity; wrong password and nonexistent email produce the same `401`) against a
real Postgres in `tests/integration/identity-auth-flow.test.ts` (depends on T006)
### Implementation for User Story 1
- [x] T009 [US1] Add `hashPassword`/`verifyPassword` (bcryptjs) and `signToken`/`verifyToken`
(jsonwebtoken, embedding `sub`/`email`/`role`/`actorType`/`jti`/`iat`/`exp` per
data-model.md) in `identity/auth/mapper/` (depends on T002)
- [x] T010 [US1] Add `AuthRepository.findActiveByEmail` (replacing `findByEmail`) in
`identity/auth/repository/` (depends on T005)
- [x] T011 [US1] Add `AuthService.login(email, password)`: looks up the user, compares against
either the found hash or a fixed dummy hash when not found (FR-002's timing/shape
parity), returns `{ token, user }` or throws a single, identical `AuthenticationError` for
every failure branch — in `identity/auth/service/` (depends on T009, T010)
- [x] T012 [US1] Replace `POST /auth/login`'s schema (`email` + `password`, replacing the
email-only schema) and controller in `identity/auth/schema/` + `controller/`, registered
from `src/api/routes.ts` (depends on T011)
- [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.
---
## Phase 4: User Story 2 - Protected routes require a valid session; admin-only routes require the admin role (Priority: P1) 🎯 MVP (part 2)
**Goal**: `fastify.authenticate` actually verifies; `requireRole` enforces role on top of it;
every existing gated route is re-verified.
**Independent Test**: Quickstart Scenario 2.
### Tests for User Story 2
- [x] T014 [P] [US2] Unit test for `requireRole`'s matching logic (allowed role passes, wrong
role throws `AuthorizationError`, no `request.user` at all throws) in
`tests/unit/identity/require-role.test.ts`
- [x] T015 [US2] Integration test covering Quickstart Scenario 2 (no header, malformed token,
wrong-role token, correct-role token) against a real Postgres/Redis in
`tests/integration/identity-auth-flow.test.ts` (depends on T008)
- [x] T016 [US2] Integration test spot-checking at least one existing admin route per module
(002's product-integration admin route, 004's knowledge admin route, 006's team-creation
route, 007's manual-assignment route, 008's SLA-policy route, 009's investigation route)
now rejects a missing/invalid session — in `tests/integration/identity-auth-flow.test.ts`
(depends on T015)
### Implementation for User Story 2
- [x] T017 [US2] Add revocation-denylist helpers (`isTokenRevoked`, `revokeToken`) in
`src/infrastructure/cache/`, alongside the existing `hasSeenJti`/`markJtiSeen` (same
Redis-key-with-TTL shape, research.md) (depends on T002)
- [x] T018 [US2] Replace `auth.plugin.ts`'s `authenticate` stub: verify the JWT signature and
expiry, check T017's revocation denylist, and on success set `request.user` (the full
`AuthUser`) and `request.reqContext.actorId`/`actorType` — throw `AuthenticationError` on
any failure, never pass through as anonymous (depends on T009, T017)
- [x] T019 [US2] Add `requireRole(...allowedRoles: string[])` preHandler factory (checks
`request.user?.role`, throws `AuthorizationError` if it doesn't match) in
`identity/auth/service/` (or a dedicated `identity/auth/guards/` file), exported from
`identity/auth`'s public `index.ts` (depends on T018)
- [x] T020 [US2] Add `requireRole('ADMIN')` to every existing write/config admin route across
002-009 that doesn't already distinguish agent-vs-admin access (product-integration
admin, knowledge admin, teams/hierarchy admin, SLA/escalation-policy admin) — read-only
routes and ticket-working routes an agent legitimately uses stay `fastify.authenticate`-
only (research.md's own scoping: this is a mechanical pass applying an existing judgment,
not a new design) (depends on T019)
- [x] T021 [US2] Run Quickstart Scenario 2 locally and confirm all 4 steps pass
**Checkpoint**: Every P1 user story is complete — a session is real, and it's actually checked
everywhere it's supposed to be. This is the feature's MVP.
---
## Phase 5: User Story 3 - An authenticated user can identify themselves (Priority: P2)
**Goal**: A self-identity endpoint that re-validates against current account state.
**Independent Test**: Quickstart Scenario 3.
### Tests for User Story 3
- [x] T022 [US3] Integration test covering Quickstart Scenario 3 (identity matches login;
deactivating the account rejects a still-unexpired token's use of this endpoint
specifically) — in `tests/integration/identity-auth-flow.test.ts` (depends on T021)
### Implementation for User Story 3
- [x] T023 [US3] Add `AuthService.getCurrentUser(userId)`: re-fetches the `User` row, throws
`AuthenticationError` if it no longer exists or `active: false` — in `identity/auth/
service/` (depends on T010)
- [x] T024 [US3] Add `GET /auth/me` route (gated by `fastify.authenticate`) in `identity/auth/
controller/` + `routes/` (depends on T023)
- [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.
---
## Phase 6: User Story 4 - An admin creates additional agent/admin accounts (Priority: P2)
**Goal**: Admin-only account creation, immediately usable to log in.
**Independent Test**: Quickstart Scenario 4.
### Tests for User Story 4
- [x] T026 [US4] Integration test covering Quickstart Scenario 4 (admin creates an account and
it logs in immediately; non-admin rejected; duplicate email rejected) — in
`tests/integration/identity-auth-flow.test.ts` (depends on T021)
### Implementation for User Story 4
- [x] T027 [US4] Add `UsersService.create(email, name, role, password)` (resolve-or-409 on
duplicate email, hashes the password via T009) in `identity/agents/service/` (research.md
— account creation lives alongside `identity/agents`'s own roster CRUD, not
`identity/auth`) (depends on T009)
- [x] T028 [US4] Add `POST /admin/users` route (gated by `fastify.authenticate` +
`requireRole('ADMIN')`) in `identity/agents/controller/` + `routes/`, registered from
`src/api/routes.ts` — response never includes the password or hash (depends on T019, T027)
- [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.
---
## Phase 7: User Story 5 - A user logs out (Priority: P3)
**Goal**: Explicit, immediate session revocation.
**Independent Test**: Quickstart Scenario 5.
### Tests for User Story 5
- [x] T030 [US5] Integration test covering Quickstart Scenario 5 (logout succeeds; the same
token is rejected immediately afterward) — in `tests/integration/identity-auth-flow.test.ts`
(depends on T021)
### Implementation for User Story 5
- [x] T031 [US5] Add `AuthService.logout(jti, remainingTtlSeconds)`: calls T017's `revokeToken`
— in `identity/auth/service/` (depends on T017)
- [x] T032 [US5] Add `POST /auth/logout` route (gated by `fastify.authenticate`) in
`identity/auth/controller/` + `routes/` (depends on T031)
- [x] T033 [US5] Run Quickstart Scenario 5 locally and confirm both steps pass
**Checkpoint**: All five user stories work independently and together — real login, real
gating, self-identity, admin-provisioned accounts, and logout form one coherent auth system.
---
## Phase 8: Polish & Cross-Cutting Concerns
- [x] T034 [P] Update `specs/010-identity-auth/checklists/requirements.md` Notes with any
implementation-time findings
- [x] T035 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
- [x] T036 Full regression: `npm run test:unit` (scoped to `tests/unit`) to confirm nothing
broke elsewhere, then the full integration suite (including 002-009's own suites, since
T020 adds `requireRole` to their existing routes) against real Docker-provisioned
Postgres/Redis
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies
- **Foundational (Phase 2)**: Depends on Setup — BLOCKS all user stories
- **User Story 1 (Phase 3)**: Depends on Foundational — no dependency on US2-US5
- **User Story 2 (Phase 4)**: Depends on US1 (a real token to verify)
- **User Story 3 (Phase 5)**: Depends on US2 (the gate US3's own route sits behind)
- **User Story 4 (Phase 6)**: Depends on US2 (`requireRole('ADMIN')`)
- **User Story 5 (Phase 7)**: Depends on US2 (the gate logout's own route sits behind) and
US1's token shape (`jti`)
- **Polish (Phase 8)**: Depends on all five user stories
### Parallel Opportunities
- T001-T003 (independent scaffolding)
- T007 (unit test) alongside T009-T011 (the implementation it tests)
- T014 (unit test) alongside T019 (the implementation it tests)
- T034 in Polish
### Sequencing Note
T020 (adding `requireRole('ADMIN')` across 002-009's existing routes) is the one task in this
feature that touches code outside `identity/*` — run each touched module's own existing test
suite immediately after, not only in T036's final regression pass, so a role-gating regression
in, say, 007's own suite is caught close to its cause rather than at the very end.
---
## Implementation Strategy
### MVP First (User Stories 1-2 Only)
1. Setup + Foundational (T001-T006)
2. User Story 1 (T007-T013) → login works, no account-existence leak
3. User Story 2 (T014-T021) → the gate is real everywhere it already existed
4. **STOP and VALIDATE**: Quickstart Scenarios 1-2 pass, including the cross-module spot-check
(T016). This is the feature's MVP — every other user story is a smaller addition on top of a
now-real auth system.
### Incremental Delivery
1. Setup + Foundational → schema migrated, demo accounts have real passwords
2. Add User Story 1 → login is real
3. Add User Story 2 → the gate is real everywhere (P1-complete, MVP)
4. Add User Story 3 → self-identity, re-validated against live account state
5. Add User Story 4 → admins can provision new accounts
6. Add User Story 5 → explicit logout
7. Polish → full regression across every feature this touches
+2
View File
@@ -23,9 +23,11 @@ import { rootCausesRoutes } from '@/modules/problem-management/root-causes';
import { solutionsRoutes } from '@/modules/problem-management/solutions';
import { verificationRoutes } from '@/modules/problem-management/verification';
import { resolutionsRoutes } from '@/modules/problem-management/resolutions';
import { authRoutes } from '@/modules/identity/auth';
export async function registerGlobalRoutes(app: FastifyInstance): Promise<void> {
await app.register(healthRoutes);
await app.register(authRoutes);
await app.register(metricsRoutes);
await app.register(productsRoutes);
await app.register(inboundRequestRoutes);
+1
View File
@@ -12,6 +12,7 @@ export interface JwtPayload {
email: string;
role: string;
actorType: ActorType;
jti: string; // revocation-denylist key — see specs/010-identity-auth/research.md
iat?: number;
exp?: number;
}
+6
View File
@@ -0,0 +1,6 @@
import { env } from './env';
export const authConfig = {
jwtSecret: env.JWT_SECRET,
tokenLifetimeHours: env.AUTH_TOKEN_LIFETIME_HOURS,
};
+5
View File
@@ -60,6 +60,11 @@ const envSchema = z.object({
// explicit customer confirmation before the auto-close sweep resolves it — see
// specs/009-problem-resolution/research.md "auto-close waiting period".
RESOLUTION_AUTO_CLOSE_WAITING_HOURS: z.coerce.number().default(72),
// Identity and Authentication (010) — token lifetime; signing itself reuses the existing,
// already-required JWT_SECRET above (defined since the original scaffold, never consumed
// until now) — see specs/010-identity-auth/research.md.
AUTH_TOKEN_LIFETIME_HOURS: z.coerce.number().default(4),
});
export type EnvConfig = z.infer<typeof envSchema>;
+1
View File
@@ -6,3 +6,4 @@ export * from './storage';
export * from './ai';
export * from './orchestration';
export * from './problem-resolution';
export * from './auth';
+17
View File
@@ -0,0 +1,17 @@
import { cacheService } from './cache.service';
const REVOKED_KEY_PREFIX = 'auth:revoked:';
/**
* Explicit-logout revocation for staff session tokens (specs/010-identity-auth/research.md
* "Redis-backed revocation denylist, reusing 002's own jti-tracking mechanism"). A jti is
* denylisted only until its own token would have expired anyway, so the set never grows
* unbounded — the same shape as replay-guard.ts's hasSeenJti/markJtiSeen.
*/
export async function isTokenRevoked(jti: string): Promise<boolean> {
return cacheService.exists(`${REVOKED_KEY_PREFIX}${jti}`);
}
export async function revokeToken(jti: string, ttlSeconds: number): Promise<void> {
await cacheService.set(`${REVOKED_KEY_PREFIX}${jti}`, '1', ttlSeconds);
}
+1
View File
@@ -2,3 +2,4 @@ export * from './redis.client';
export * from './cache.service';
export * from './replay-guard';
export * from './rate-limiter';
export * from './auth-revocation';
+6 -1
View File
@@ -19,7 +19,12 @@ export function registerSlaWorker(): void {
void queueManager.getQueue(QueueName.SLA).add(
'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 } },
);
}
@@ -1,35 +1,38 @@
import { FastifyInstance } from 'fastify';
import { requireRole } from '@/modules/identity/auth';
import { knowledgeController, errorCodesController, runbooksController } from '../controller';
/**
* Admin routes gated by fastify.authenticate (research.md — known limitation inherited from
* 002/003). /knowledge/retrieve is intentionally NOT gated — it's a read path the future
* AI-support feature will call, not an admin surface (research.md "Admin endpoint
* authentication").
* Admin write routes gated by fastify.authenticate + requireRole('ADMIN'), now real
* (010-identity-auth). Reads stay agent-usable (fastify.authenticate only).
* /knowledge/retrieve is intentionally NOT gated — it's a read path the future AI-support
* feature will call, not an admin surface (research.md "Admin endpoint authentication").
*/
export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/products/:externalProductId/knowledge',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => knowledgeController.create(req, reply),
);
fastify.patch(
'/admin/knowledge/:code/publish',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => knowledgeController.publish(req, reply),
);
fastify.patch(
'/admin/knowledge/:code/unpublish',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => knowledgeController.unpublish(req, reply),
);
fastify.patch(
'/admin/knowledge/:code/validate',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => knowledgeController.validate(req, reply),
);
fastify.put('/admin/knowledge/:code', { preHandler: fastify.authenticate }, (req, reply) =>
knowledgeController.edit(req, reply),
fastify.put(
'/admin/knowledge/:code',
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => knowledgeController.edit(req, reply),
);
fastify.get(
'/admin/knowledge/:code/versions',
@@ -39,12 +42,12 @@ export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/products/:externalProductId/error-codes',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => errorCodesController.createErrorCode(req, reply),
);
fastify.post(
'/admin/products/:externalProductId/known-issues',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => errorCodesController.createKnownIssue(req, reply),
);
fastify.get(
@@ -55,7 +58,7 @@ export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/products/:externalProductId/runbooks',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => runbooksController.create(req, reply),
);
fastify.get(
@@ -65,12 +68,12 @@ export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> {
);
fastify.put(
'/admin/products/:externalProductId/runbooks/:key',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => runbooksController.edit(req, reply),
);
fastify.patch(
'/admin/products/:externalProductId/runbooks/:key/deactivate',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => runbooksController.deactivate(req, reply),
);
@@ -1,16 +1,17 @@
import { FastifyInstance } from 'fastify';
import { requireRole } from '@/modules/identity/auth';
import { confidencePolicyController, sessionController } from '../controller';
/**
* contracts/ai-support-contract.md: admin confidence-policy routes gated by
* fastify.authenticate (known limitation inherited from 002/003/004). Session-turn routes are
* not admin routes — called by the ticket-owning caller, same as 003-ticketing's
* contracts/ai-support-contract.md: the admin confidence-policy write route is gated by
* fastify.authenticate + requireRole('ADMIN'), now real (010-identity-auth). Session-turn
* routes are not admin routes — called by the ticket-owning caller, same as 003-ticketing's
* POST/GET .../messages, and carry no additional gate of their own in this feature.
*/
export async function sessionsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.put(
'/admin/products/:externalProductId/ai-policy',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => confidencePolicyController.upsert(req, reply),
);
fastify.get(
@@ -1,34 +1,35 @@
import { FastifyInstance } from 'fastify';
import { requireRole } from '@/modules/identity/auth';
import { productIntegrationsController } from '../controller';
/**
* Admin lifecycle endpoints for ProductIntegration (register/rotate/revoke/status/audit-trail).
* Gated by the existing human/admin JWT plugin (fastify.authenticate) — see
* specs/002-saas-integration/contracts/inbound-request-contract.md "Admin: Integration Lifecycle
* Endpoints" for the known limitation that this decorator doesn't perform real verification yet.
* Gated by fastify.authenticate + requireRole('ADMIN') — real as of 010-identity-auth (see
* specs/002-saas-integration/contracts/inbound-request-contract.md for the now-resolved known
* limitation this decorator previously didn't perform real verification).
*/
export async function productIntegrationsAdminRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/products/:externalProductId/integration',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => productIntegrationsController.register(req, reply),
);
fastify.post(
'/admin/integrations/:integrationId/rotate',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => productIntegrationsController.rotate(req, reply),
);
fastify.post(
'/admin/integrations/:integrationId/revoke',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => productIntegrationsController.revoke(req, reply),
);
fastify.patch(
'/admin/integrations/:integrationId/status',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => productIntegrationsController.updateStatus(req, reply),
);
@@ -6,6 +6,8 @@ import {
AgentSkillsService,
agentAvailabilityService,
AgentAvailabilityService,
usersService,
UsersService,
} from '../service';
import {
createAgentSchema,
@@ -13,6 +15,7 @@ import {
listAgentsQuerySchema,
upsertAgentSkillSchema,
upsertAgentAvailabilitySchema,
createUserSchema,
} from '../schema';
export class AgentsController {
@@ -20,6 +23,7 @@ export class AgentsController {
private readonly service: AgentsService = agentsService,
private readonly skills: AgentSkillsService = agentSkillsService,
private readonly availability: AgentAvailabilityService = agentAvailabilityService,
private readonly users: UsersService = usersService,
) {}
async create(request: FastifyRequest, reply: FastifyReply) {
@@ -73,6 +77,13 @@ export class AgentsController {
const record = await this.availability.getForAgent(agentId);
return reply.status(200).send({ success: true, data: record, meta: null });
}
/** 010-identity-auth User Story 4: admin-only account creation. */
async createUser(request: FastifyRequest, reply: FastifyReply) {
const body = createUserSchema.parse(request.body);
const user = await this.users.create(body);
return reply.status(201).send({ success: true, data: user, meta: null });
}
}
export const agentsController = new AgentsController();
@@ -1,3 +1,4 @@
export * from './agents.repository';
export * from './agent-skills.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 { requireRole } from '@/modules/identity/auth';
import { agentsController } from '../controller';
/** Admin routes gated by fastify.authenticate — known limitation inherited from 002/003/004/005
* (research.md "Admin endpoint authentication"). */
/** Admin routes gated by fastify.authenticate — real as of 010-identity-auth (previously a
* no-op stub, per that feature's own research.md). `POST /admin/users` additionally requires
* the ADMIN role (010's own User Story 4) since account creation is more sensitive than
* agent-roster management. */
export async function agentsRoutes(fastify: FastifyInstance): Promise<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) =>
agentsController.create(req, reply),
);
@@ -1,3 +1,4 @@
export * from './agents.schema';
export * from './agent-skills.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 './agent-skills.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 { AuthenticationError } from '@/common/errors';
import { authService, AuthService } from '../service';
import { AuthCredentialsInput } from '../types';
import { loginSchema } from '../schema';
function bearerToken(request: FastifyRequest): string {
const header = request.headers.authorization;
return header?.startsWith('Bearer ') ? header.slice('Bearer '.length) : '';
}
export class AuthController {
constructor(private readonly service: AuthService = authService) {}
async handleLogin(request: FastifyRequest<{ Body: AuthCredentialsInput }>, reply: FastifyReply) {
const user = await this.service.validateCredentials(request.body);
return reply.status(200).send({
success: true,
data: user,
meta: null,
});
async handleLogin(request: FastifyRequest, reply: FastifyReply) {
const body = loginSchema.parse(request.body);
const result = await this.service.login(body);
return reply.status(200).send({ success: true, data: result, meta: null });
}
async getCurrentUser(request: FastifyRequest, reply: FastifyReply) {
// Unreachable in practice: this route is only ever registered behind fastify.authenticate,
// which always sets request.user on success.
if (!request.user) throw new AuthenticationError('Session is no longer valid.');
const user = await this.service.getCurrentUser(request.user.id);
return reply.status(200).send({ success: true, data: user, meta: null });
}
async handleLogout(request: FastifyRequest, reply: FastifyReply) {
await this.service.logout(bearerToken(request));
return reply.status(200).send({ success: true, data: { loggedOut: true }, meta: null });
}
}
+5 -1
View File
@@ -1,3 +1,7 @@
export { authRoutes } from './routes';
export { AuthService, authService } from './service';
export type { AuthCredentialsInput } from './types';
export { requireRole } from './service';
export type { LoginBody } from './schema';
export type { LoginResult } from './service';
export { hashPassword, verifyPassword, signToken, verifyToken, toAuthUser } from './mapper';
export { AUTH_CONSTANTS } from './constants';
@@ -1,5 +1,52 @@
export class AuthMapper {
static toResponse(user: Record<string, unknown>): Record<string, unknown> {
return { ...user };
}
import { randomUUID } from 'crypto';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { AuthUser, JwtPayload } from '@/common/types';
import { ActorType } from '@/common/enums';
import { authConfig } from '@/config';
const SALT_ROUNDS = 10;
// research.md "byte-identical failure response": compared against when no user is found at
// all, so a login's timing/shape never reveals whether the email itself was valid.
const DUMMY_HASH = bcrypt.hashSync('not-a-real-password', SALT_ROUNDS);
export async function hashPassword(password: string): Promise<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';
export class AuthRepository {
constructor(private readonly prisma = prismaClient) {}
async findByEmail(email: string): Promise<unknown> {
return this.prisma.user.findUnique({
where: { email },
});
async findByEmail(email: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { email } });
}
/** FR-002/data-model.md: only an active account can authenticate or stay authenticated. */
async findActiveById(id: string): Promise<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 { AuthCredentialsInput } from '../types';
export async function authRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post('/auth/login', (req: FastifyRequest<{ Body: AuthCredentialsInput }>, reply) =>
authController.handleLogin(req, reply),
fastify.post('/auth/login', (req, reply) => authController.handleLogin(req, reply));
fastify.get('/auth/me', { preHandler: fastify.authenticate }, (req, reply) =>
authController.getCurrentUser(req, reply),
);
fastify.post('/auth/logout', { preHandler: fastify.authenticate }, (req, reply) =>
authController.handleLogout(req, reply),
);
}
@@ -1,5 +1,10 @@
import { z } from 'zod';
export const authCredentialsSchema = z.object({
email: z.string().email(),
});
export const loginSchema = z
.object({
email: z.string().email(),
password: z.string().min(1),
})
.strict();
export type LoginBody = z.infer<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 { AuthCredentialsInput } from '../types';
import { verifyPassword, signToken, verifyToken } from '../mapper';
import { LoginBody } from '../schema';
export interface LoginResult {
token: string;
user: { id: string; email: string; name: string; role: string };
}
function toPublicUser(user: User): LoginResult['user'] {
return { id: user.id, email: user.email, name: user.name, role: user.role };
}
export class AuthService {
constructor(private readonly repo: AuthRepository = authRepository) {}
async validateCredentials(input: AuthCredentialsInput): Promise<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 './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 {
email: string;
}
export {};
+2 -1
View File
@@ -1 +1,2 @@
export * from './auth.types';
export type { LoginBody } from '../schema';
export type { LoginResult } from '../service';
@@ -1,14 +1,19 @@
import { FastifyInstance } from 'fastify';
import { requireRole } from '@/modules/identity/auth';
import { teamsController } from '../controller';
/** Admin routes gated by fastify.authenticate — known limitation inherited from 002/003/004/005
* (research.md "Admin endpoint authentication"). */
/** Admin routes gated by fastify.authenticate, now real (010-identity-auth) — writes
* additionally require the ADMIN role; reads stay agent-usable. */
export async function teamsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post('/admin/teams', { preHandler: fastify.authenticate }, (req, reply) =>
teamsController.create(req, reply),
fastify.post(
'/admin/teams',
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => teamsController.create(req, reply),
);
fastify.patch('/admin/teams/:teamId', { preHandler: fastify.authenticate }, (req, reply) =>
teamsController.update(req, reply),
fastify.patch(
'/admin/teams/:teamId',
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => teamsController.update(req, reply),
);
fastify.get('/admin/teams/:teamId', { preHandler: fastify.authenticate }, (req, reply) =>
teamsController.getById(req, reply),
@@ -152,7 +152,13 @@ export class AssignmentEngine {
}
return {
assignment: await this.persistAndTransition(ticketId, selected.id, strategyName, actor, reason),
assignment: await this.persistAndTransition(
ticketId,
selected.id,
strategyName,
actor,
reason,
),
strategy: strategyName,
};
}
@@ -1,37 +1,35 @@
import { FastifyInstance } from 'fastify';
import { requireRole } from '@/modules/identity/auth';
import { escalationController } from '../controller';
/** contracts/sla-escalation-contract.md: every route gated by fastify.authenticate (known
* limitation inherited from 002-007). */
/** contracts/sla-escalation-contract.md: policy/rule config gated by fastify.authenticate +
* requireRole('ADMIN'), now real (010-identity-auth); manual escalation stays agent-usable
* (fastify.authenticate only) — it's a ticket-working action, not admin configuration. */
export async function escalationRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/escalation-policies',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => escalationController.createPolicy(req, reply),
);
fastify.get(
'/admin/escalation-policies',
{ preHandler: fastify.authenticate },
(req, reply) => escalationController.listPolicies(req, reply),
fastify.get('/admin/escalation-policies', { preHandler: fastify.authenticate }, (req, reply) =>
escalationController.listPolicies(req, reply),
);
fastify.post(
'/admin/escalation-policies/:id/rules',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => escalationController.createRule(req, reply),
);
fastify.patch(
'/admin/escalation-policies/:id/rules/:ruleId',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => escalationController.updateRule(req, reply),
);
fastify.delete(
'/admin/escalation-policies/:id/rules/:ruleId',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => escalationController.deleteRule(req, reply),
);
fastify.post(
'/tickets/:ticketId/escalate',
{ preHandler: fastify.authenticate },
(req, reply) => escalationController.escalateManually(req, reply),
fastify.post('/tickets/:ticketId/escalate', { preHandler: fastify.authenticate }, (req, reply) =>
escalationController.escalateManually(req, reply),
);
}
@@ -15,7 +15,11 @@ import {
escalationEventRepository,
EscalationEventRepository,
} from '../repository';
import { CreateEscalationPolicyBody, CreateEscalationRuleBody, UpdateEscalationRuleBody } from '../schema';
import {
CreateEscalationPolicyBody,
CreateEscalationRuleBody,
UpdateEscalationRuleBody,
} from '../schema';
export class EscalationService {
constructor(
@@ -32,14 +36,23 @@ export class EscalationService {
* active rule. Records nothing when no policy or no rule matches — the breach itself is
* already durably recorded by the caller (SLARun.breachedAt/firstResponseBreachedAt).
*/
async handleBreach(ticketId: string, triggerType: 'resolution_breach' | 'first_response_breach'): Promise<void> {
async handleBreach(
ticketId: string,
triggerType: 'resolution_breach' | 'first_response_breach',
): Promise<void> {
const ticket = await ticketsService.getById(ticketId);
const policy = await this.policies.findApplicable(ticket.productId);
if (!policy) return;
const matchingRules = await this.rules.findActiveRules(policy.id, triggerType);
for (const rule of matchingRules) {
await this.fire(ticketId, rule.id, rule.targetNodeId, 'system', `SLA ${triggerType} — rule ${rule.id}`);
await this.fire(
ticketId,
rule.id,
rule.targetNodeId,
'system',
`SLA ${triggerType} — rule ${rule.id}`,
);
}
}
@@ -1,29 +1,32 @@
import { FastifyInstance } from 'fastify';
import { requireRole } from '@/modules/identity/auth';
import { hierarchyController, capabilityLookupController } from '../controller';
/**
* contracts/support-org-contract.md: admin hierarchy-node routes gated by fastify.authenticate
* (known limitation inherited from 002/003/004/005). The capability-eligibility lookup is not
* contracts/support-org-contract.md: admin hierarchy-node writes gated by fastify.authenticate +
* requireRole('ADMIN'), now real (010-identity-auth). The capability-eligibility lookup is not
* gated — a read path a future orchestration caller will use (research.md "Admin endpoint
* authentication").
*/
export async function hierarchyRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post('/admin/hierarchy-nodes', { preHandler: fastify.authenticate }, (req, reply) =>
hierarchyController.create(req, reply),
fastify.post(
'/admin/hierarchy-nodes',
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => hierarchyController.create(req, reply),
);
fastify.put(
'/admin/hierarchy-nodes/:nodeId',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => hierarchyController.update(req, reply),
);
fastify.patch(
'/admin/hierarchy-nodes/:nodeId/activate',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => hierarchyController.activate(req, reply),
);
fastify.patch(
'/admin/hierarchy-nodes/:nodeId/deactivate',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => hierarchyController.deactivate(req, reply),
);
fastify.get(
@@ -1,5 +1,8 @@
import { SLAPolicy } from '@prisma/client';
import { businessCalendarsService, BusinessCalendarsService } from '@/modules/platform/business-calendars';
import {
businessCalendarsService,
BusinessCalendarsService,
} from '@/modules/platform/business-calendars';
/**
* FR-004: replaces the original naive `createdDate + targetHours` stub — every due date is
@@ -1,32 +1,30 @@
import { FastifyInstance } from 'fastify';
import { requireRole } from '@/modules/identity/auth';
import { slaController } from '../controller';
/** contracts/sla-escalation-contract.md: admin CRUD gated by fastify.authenticate; the read
* route is not (same "read path any caller can use" convention as 003/007). */
/** contracts/sla-escalation-contract.md: admin CRUD gated by fastify.authenticate +
* requireRole('ADMIN'), now real (010-identity-auth); the read route is not (same "read path
* any caller can use" convention as 003/007). */
export async function slaRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/sla-policies',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => slaController.createPolicy(req, reply),
);
fastify.get(
'/admin/sla-policies',
{ preHandler: fastify.authenticate },
(req, reply) => slaController.listPolicies(req, reply),
fastify.get('/admin/sla-policies', { preHandler: fastify.authenticate }, (req, reply) =>
slaController.listPolicies(req, reply),
);
fastify.get(
'/admin/sla-policies/:id',
{ preHandler: fastify.authenticate },
(req, reply) => slaController.getPolicy(req, reply),
fastify.get('/admin/sla-policies/:id', { preHandler: fastify.authenticate }, (req, reply) =>
slaController.getPolicy(req, reply),
);
fastify.patch(
'/admin/sla-policies/:id',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => slaController.updatePolicy(req, reply),
);
fastify.delete(
'/admin/sla-policies/:id',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => slaController.deactivatePolicy(req, reply),
);
fastify.get('/tickets/:ticketId/sla-run', (req, reply) => slaController.getRun(req, reply));
@@ -99,7 +99,12 @@ export function isWithinWorkingHours(
const [startHour, startMinute] = window.start.split(':').map(Number);
const [endHour, endMinute] = window.end.split(':').map(Number);
const windowStart = zoned.set({ hour: startHour, minute: startMinute, second: 0, millisecond: 0 });
const windowStart = zoned.set({
hour: startHour,
minute: startMinute,
second: 0,
millisecond: 0,
});
const windowEnd = zoned.set({ hour: endHour, minute: endMinute, second: 0, millisecond: 0 });
return zoned >= windowStart && zoned < windowEnd;
@@ -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 { requireRole } from '@/modules/identity/auth';
import { businessCalendarsController } from '../controller';
/** contracts/sla-escalation-contract.md: every admin route gated by fastify.authenticate (known
* limitation inherited from 002-007). */
/** contracts/sla-escalation-contract.md: every admin write route gated by fastify.authenticate +
* requireRole('ADMIN'), now real (010-identity-auth). */
export async function businessCalendarsRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post(
'/admin/business-calendars',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => businessCalendarsController.create(req, reply),
);
fastify.get(
'/admin/business-calendars',
{ preHandler: fastify.authenticate },
(req, reply) => businessCalendarsController.list(req, reply),
fastify.get('/admin/business-calendars', { preHandler: fastify.authenticate }, (req, reply) =>
businessCalendarsController.list(req, reply),
);
fastify.get(
'/admin/business-calendars/:id',
{ preHandler: fastify.authenticate },
(req, reply) => businessCalendarsController.getById(req, reply),
fastify.get('/admin/business-calendars/:id', { preHandler: fastify.authenticate }, (req, reply) =>
businessCalendarsController.getById(req, reply),
);
fastify.patch(
'/admin/business-calendars/:id',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => businessCalendarsController.update(req, reply),
);
fastify.post(
'/admin/business-calendars/:id/holidays',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => businessCalendarsController.addHoliday(req, reply),
);
fastify.delete(
'/admin/business-calendars/:id/holidays/:holidayId',
{ preHandler: fastify.authenticate },
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => businessCalendarsController.removeHoliday(req, reply),
);
}
@@ -7,8 +7,16 @@ import {
holidayRepository,
HolidayRepository,
} from '../repository';
import { addBusinessMinutes, isWithinWorkingHours, WorkingHours } from '../calculators/business-hours.calculator';
import { CreateBusinessCalendarBody, UpdateBusinessCalendarBody, CreateHolidayBody } from '../schema';
import {
addBusinessMinutes,
isWithinWorkingHours,
WorkingHours,
} from '../calculators/business-hours.calculator';
import {
CreateBusinessCalendarBody,
UpdateBusinessCalendarBody,
CreateHolidayBody,
} from '../schema';
export class BusinessCalendarsService {
constructor(
@@ -33,7 +33,9 @@ export class ResolutionsController {
}
await this.service.confirmByCustomer(ticketId, 'customer');
return reply.status(200).send({ success: true, data: { ticketId, status: 'RESOLVED' }, meta: null });
return reply
.status(200)
.send({ success: true, data: { ticketId, status: 'RESOLVED' }, meta: null });
}
}
@@ -13,8 +13,10 @@ export async function ticketsRoutes(fastify: FastifyInstance): Promise<void> {
// 009-problem-resolution FR-017: agent-facing reopen (fastify.authenticate) and customer-
// facing reopen (002's inbound trust boundary, research.md) both funnel through the same
// TicketsService.reopen.
fastify.post('/admin/tickets/:ticketId/reopen', { preHandler: fastify.authenticate }, (req, reply) =>
ticketsController.reopen(req, reply),
fastify.post(
'/admin/tickets/:ticketId/reopen',
{ preHandler: fastify.authenticate },
(req, reply) => ticketsController.reopen(req, reply),
);
fastify.post(
'/v1/support/tickets/:ticketId/reopen',
+34 -4
View File
@@ -1,6 +1,10 @@
import { FastifyPluginAsync, FastifyRequest, FastifyReply } from 'fastify';
import fp from 'fastify-plugin';
import jwt from 'jsonwebtoken';
import { AuthUser } from '@/common/types';
import { AuthenticationError } from '@/common/errors';
import { isTokenRevoked } from '@/infrastructure/cache';
import { verifyToken, toAuthUser } from '@/modules/identity/auth';
declare module 'fastify' {
interface FastifyRequest {
@@ -11,20 +15,46 @@ declare module 'fastify' {
}
}
/**
* specs/010-identity-auth: replaces the original no-op stub. Verifies the JWT's signature and
* expiry, checks the Redis revocation denylist (research.md), and on success populates both
* request.user and the same request.reqContext.actorId/actorType fields
* authenticateProductIntegration already populates for customer-originated requests — every
* `actorFrom(request)` call site since 007 becomes accurate for real agent/admin actions with
* no changes on its own end.
*/
const authPluginCallback: FastifyPluginAsync = async (fastify) => {
fastify.decorate(
'authenticate',
async (request: FastifyRequest, _reply: FastifyReply): Promise<void> => {
const authHeader = request.headers.authorization;
if (!authHeader) {
// Foundation auth: default context or optional pass
return;
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : null;
if (!token) {
throw new AuthenticationError('Missing or malformed Authorization header.');
}
// Stub for JWT verification foundation
let payload;
try {
payload = verifyToken(token);
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new AuthenticationError('Session has expired.');
}
throw new AuthenticationError('Invalid session token.');
}
if (await isTokenRevoked(payload.jti)) {
throw new AuthenticationError('Session has been revoked.');
}
request.user = toAuthUser(payload);
request.reqContext.actorId = payload.sub;
request.reqContext.actorType = payload.actorType;
},
);
};
export const authPlugin = fp(authPluginCallback, {
name: 'auth-plugin',
dependencies: ['request-context-plugin'],
});
+1
View File
@@ -18,6 +18,7 @@ describe('ROUND_ROBIN concurrency safety', () => {
teamId: 't',
name: id,
active: true,
userId: null,
createdAt: new Date(),
updatedAt: new Date(),
skills: [],
+40
View File
@@ -0,0 +1,40 @@
import { FastifyInstance } from 'fastify';
import bcrypt from 'bcryptjs';
import { prismaClient } from '@/infrastructure/database';
const TEST_PASSWORD = 'Test-Password-123!';
/**
* 010-identity-auth made fastify.authenticate real — every test file calling a route already
* gated by it (across 002-009's own suites) needs a real session now. Rather than depend on
* prisma/seed/roles.seed.ts having already been run against whatever database the suite
* connects to, this upserts its own throwaway admin/agent account directly (idempotent — safe
* to call from many test files' own beforeAll against the same database) and logs in as it.
*/
export async function loginAs(
app: FastifyInstance,
role: 'ADMIN' | 'AGENT' = 'ADMIN',
): Promise<string> {
const email = `test-${role.toLowerCase()}@supporthub.test`;
await prismaClient.user.upsert({
where: { email },
update: {},
create: {
email,
name: `Test ${role}`,
role,
passwordHash: await bcrypt.hash(TEST_PASSWORD, 10),
},
});
const response = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email, password: TEST_PASSWORD },
});
return response.json().data.token as string;
}
export function authHeader(token: string): { authorization: string } {
return { authorization: `Bearer ${token}` };
}
@@ -2,16 +2,19 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
/** Covers specs/005-ai-support/contracts/ai-support-contract.md's confidence-policy admin
* surface (FR-005) — no LLM call involved, so this runs unconditionally against a real
* Postgres, unlike the AI-diagnosis/reasoning tests in this same directory. */
describe('AI confidence policy — admin config (FR-005)', () => {
let app: FastifyInstance;
let token: string;
const externalProductId = `TEST_AI_POLICY_PROD_${Date.now()}`;
beforeAll(async () => {
app = await buildApp();
token = await loginAs(app, 'ADMIN');
await prismaClient.product.create({
data: { externalProductId, name: 'AI Policy Test Product', status: 'active' },
});
@@ -30,6 +33,7 @@ describe('AI confidence policy — admin config (FR-005)', () => {
const response = await app.inject({
method: 'PUT',
url: `/admin/products/${externalProductId}/ai-policy`,
headers: authHeader(token),
payload: { highThreshold: 0.4, lowThreshold: 0.4, maxClarifyingQuestions: 2 },
});
expect(response.statusCode).toBe(400);
@@ -39,6 +43,7 @@ describe('AI confidence policy — admin config (FR-005)', () => {
const putResponse = await app.inject({
method: 'PUT',
url: `/admin/products/${externalProductId}/ai-policy`,
headers: authHeader(token),
payload: { highThreshold: 0.8, lowThreshold: 0.3, maxClarifyingQuestions: 3 },
});
expect(putResponse.statusCode).toBe(200);
@@ -47,6 +52,7 @@ describe('AI confidence policy — admin config (FR-005)', () => {
const getResponse = await app.inject({
method: 'GET',
url: `/admin/products/${externalProductId}/ai-policy`,
headers: authHeader(token),
});
expect(getResponse.statusCode).toBe(200);
const body = getResponse.json().data;
@@ -61,11 +67,13 @@ describe('AI confidence policy — admin config (FR-005)', () => {
await app.inject({
method: 'PUT',
url: `/admin/products/${externalProductId}/ai-policy`,
headers: authHeader(token),
payload: { highThreshold: 0.9, lowThreshold: 0.2, maxClarifyingQuestions: 1 },
});
const getResponse = await app.inject({
method: 'GET',
url: `/admin/products/${externalProductId}/ai-policy`,
headers: authHeader(token),
});
const configured = getResponse.json().data.configured;
expect(configured).toHaveLength(1);
@@ -76,6 +84,7 @@ describe('AI confidence policy — admin config (FR-005)', () => {
const response = await app.inject({
method: 'PUT',
url: `/admin/products/TEST_NEVER_REGISTERED_${Date.now()}/ai-policy`,
headers: authHeader(token),
payload: { highThreshold: 0.8, lowThreshold: 0.3, maxClarifyingQuestions: 2 },
});
expect(response.statusCode).toBe(404);
@@ -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 { FastifyInstance } from 'fastify';
import { issueIntegrationToken } from '@/modules/catalog/products';
import { loginAs, authHeader } from '../helpers/auth';
/**
* Covers specs/002-saas-integration/quickstart.md Scenario 8 (integration-level and per-user
@@ -10,6 +11,7 @@ import { issueIntegrationToken } from '@/modules/catalog/products';
*/
describe('Inbound rate limiting', () => {
let app: FastifyInstance;
let adminToken: string;
const externalProductId = `TEST_RATELIMIT_PROD_${Date.now()}`;
afterAll(async () => {
@@ -29,10 +31,12 @@ describe('Inbound rate limiting', () => {
it('throttles an integration once it exceeds its per-minute limit, and independently throttles a single user within it', async () => {
app = await buildApp();
adminToken = await loginAs(app, 'ADMIN');
const registerResponse = await app.inject({
method: 'POST',
url: `/admin/products/${externalProductId}/integration`,
headers: authHeader(adminToken),
payload: {
name: 'Rate Limit Test Product',
allowedScope: { tenantIds: ['tenant-1'] },
@@ -80,6 +84,7 @@ describe('Inbound rate limiting', () => {
const registerResponse = await app.inject({
method: 'POST',
url: `/admin/products/${externalProductId}-int/integration`,
headers: authHeader(adminToken),
payload: {
name: 'Rate Limit Test Product (integration-level)',
allowedScope: { tenantIds: ['tenant-1'] },
+16 -1
View File
@@ -2,15 +2,18 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
/** Covers specs/004-product-knowledge/quickstart.md Scenarios 1, 2, 3 against a real Postgres. */
describe('Knowledge entry authoring, publishing, and versioning', () => {
let app: FastifyInstance;
let token: string;
const externalProductId = `TEST_KNOWLEDGE_PROD_${Date.now()}`;
const code = `KB-TEST-${Date.now()}`;
beforeAll(async () => {
app = await buildApp();
token = await loginAs(app, 'ADMIN');
await prismaClient.product.create({
data: { externalProductId, name: 'Knowledge Test Product', status: 'active' },
});
@@ -26,6 +29,7 @@ describe('Knowledge entry authoring, publishing, and versioning', () => {
const createResponse = await app.inject({
method: 'POST',
url: `/admin/products/${externalProductId}/knowledge`,
headers: authHeader(token),
payload: { code, type: 'known_issue', problem: 'PDF conversion fails' },
});
expect(createResponse.statusCode).toBe(201);
@@ -42,6 +46,7 @@ describe('Knowledge entry authoring, publishing, and versioning', () => {
const publishResponse = await app.inject({
method: 'PATCH',
url: `/admin/knowledge/${code}/publish`,
headers: authHeader(token),
});
expect(publishResponse.statusCode).toBe(200);
expect(publishResponse.json().data.status).toBe('published');
@@ -57,6 +62,7 @@ describe('Knowledge entry authoring, publishing, and versioning', () => {
const editResponse = await app.inject({
method: 'PUT',
url: `/admin/knowledge/${code}`,
headers: authHeader(token),
payload: {
type: 'known_issue',
problem: 'PDF conversion fails — updated',
@@ -69,6 +75,7 @@ describe('Knowledge entry authoring, publishing, and versioning', () => {
const versionsResponse = await app.inject({
method: 'GET',
url: `/admin/knowledge/${code}/versions`,
headers: authHeader(token),
});
const versions = versionsResponse.json().data;
expect(versions).toHaveLength(2);
@@ -91,11 +98,13 @@ describe('Knowledge entry authoring, publishing, and versioning', () => {
const first = await app.inject({
method: 'PUT',
url: `/admin/knowledge/${code}`,
headers: authHeader(token),
payload: { type: 'known_issue', problem: 'edit A', expectedVersion: 2 },
});
const second = await app.inject({
method: 'PUT',
url: `/admin/knowledge/${code}`,
headers: authHeader(token),
payload: { type: 'known_issue', problem: 'edit B', expectedVersion: 2 },
});
@@ -107,6 +116,7 @@ describe('Knowledge entry authoring, publishing, and versioning', () => {
const response = await app.inject({
method: 'PATCH',
url: `/admin/knowledge/${code}/validate`,
headers: authHeader(token),
payload: { validationStatus: 'validated' },
});
expect(response.statusCode).toBe(200);
@@ -114,7 +124,11 @@ describe('Knowledge entry authoring, publishing, and versioning', () => {
});
it('unpublishing removes the entry from retrieval without deleting it', async () => {
await app.inject({ method: 'PATCH', url: `/admin/knowledge/${code}/unpublish` });
await app.inject({
method: 'PATCH',
url: `/admin/knowledge/${code}/unpublish`,
headers: authHeader(token),
});
const retrieveResponse = await app.inject({
method: 'GET',
@@ -127,6 +141,7 @@ describe('Knowledge entry authoring, publishing, and versioning', () => {
const versionsResponse = await app.inject({
method: 'GET',
url: `/admin/knowledge/${code}/versions`,
headers: authHeader(token),
});
expect(versionsResponse.statusCode).toBe(200);
});
+10 -1
View File
@@ -2,16 +2,19 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
/** Covers specs/004-product-knowledge/quickstart.md Scenario 6 against a real Postgres. */
describe('Knowledge retrieval — scoping and ranking', () => {
let app: FastifyInstance;
let token: string;
const productAId = `TEST_RETRIEVE_A_${Date.now()}`;
const productBId = `TEST_RETRIEVE_B_${Date.now()}`;
const codes: string[] = [];
beforeAll(async () => {
app = await buildApp();
token = await loginAs(app, 'ADMIN');
await prismaClient.product.create({
data: { externalProductId: productAId, name: 'Product A', status: 'active' },
});
@@ -33,9 +36,14 @@ describe('Knowledge retrieval — scoping and ranking', () => {
await app.inject({
method: 'POST',
url: `/admin/products/${externalProductId}/knowledge`,
headers: authHeader(token),
payload: { code, type: 'faq', problem: `problem for ${code}` },
});
await app.inject({ method: 'PATCH', url: `/admin/knowledge/${code}/publish` });
await app.inject({
method: 'PATCH',
url: `/admin/knowledge/${code}/publish`,
headers: authHeader(token),
});
}
it("never returns another product's entries", async () => {
@@ -62,6 +70,7 @@ describe('Knowledge retrieval — scoping and ranking', () => {
await app.inject({
method: 'PATCH',
url: `/admin/knowledge/${validatedCode}/validate`,
headers: authHeader(token),
payload: { validationStatus: 'validated' },
});
+7
View File
@@ -2,15 +2,18 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
/** Covers specs/004-product-knowledge/quickstart.md Scenario 4 against a real Postgres. */
describe('Error codes and known issues', () => {
let app: FastifyInstance;
let token: string;
const externalProductId = `TEST_KNOWNISSUE_PROD_${Date.now()}`;
const errorCode = 'LAYOUT_PARSE_042';
beforeAll(async () => {
app = await buildApp();
token = await loginAs(app, 'ADMIN');
await prismaClient.product.create({
data: { externalProductId, name: 'Known Issue Test Product', status: 'active' },
});
@@ -27,6 +30,7 @@ describe('Error codes and known issues', () => {
const errorCodeResponse = await app.inject({
method: 'POST',
url: `/admin/products/${externalProductId}/error-codes`,
headers: authHeader(token),
payload: { code: errorCode, description: 'Layout parser failure' },
});
expect(errorCodeResponse.statusCode).toBe(201);
@@ -35,6 +39,7 @@ describe('Error codes and known issues', () => {
const knownIssueResponse = await app.inject({
method: 'POST',
url: `/admin/products/${externalProductId}/known-issues`,
headers: authHeader(token),
payload: { errorCodeId, description: 'Conversion fails for complex layouts' },
});
expect(knownIssueResponse.statusCode).toBe(201);
@@ -42,6 +47,7 @@ describe('Error codes and known issues', () => {
const lookupResponse = await app.inject({
method: 'GET',
url: `/admin/products/${externalProductId}/known-issues/by-error-code/${errorCode}`,
headers: authHeader(token),
});
expect(lookupResponse.statusCode).toBe(200);
const knownIssues = lookupResponse.json().data;
@@ -53,6 +59,7 @@ describe('Error codes and known issues', () => {
const response = await app.inject({
method: 'GET',
url: `/admin/products/${externalProductId}/known-issues/by-error-code/NEVER_REGISTERED`,
headers: authHeader(token),
});
expect(response.statusCode).toBe(404);
});
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
import {
encryptCredential,
generateCredentialSecret,
@@ -15,6 +16,7 @@ import {
*/
describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)', () => {
let app: FastifyInstance;
let adminToken: string;
const externalProductId = `TEST_ORCH_PROD_${Date.now()}`;
const skillTag = `orch_skill_${Date.now()}`;
let productId: string;
@@ -26,6 +28,7 @@ describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)',
beforeAll(async () => {
app = await buildApp();
adminToken = await loginAs(app, 'ADMIN');
const product = await prismaClient.product.create({
data: { externalProductId, name: 'Orchestration Test Product', status: 'active' },
@@ -47,6 +50,7 @@ describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)',
const team = await app.inject({
method: 'POST',
url: '/admin/teams',
headers: authHeader(adminToken),
payload: { name: `Orch Team ${Date.now()}` },
});
teamId = team.json().data.id;
@@ -54,30 +58,35 @@ describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)',
const agentA = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
headers: authHeader(adminToken),
payload: { name: 'Orch Agent A' },
});
agentAId = agentA.json().data.id;
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentAId}/skills/${skillTag}`,
headers: authHeader(adminToken),
payload: { level: 3 },
});
const agentB = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
headers: authHeader(adminToken),
payload: { name: 'Orch Agent B' },
});
agentBId = agentB.json().data.id;
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentBId}/skills/${skillTag}`,
headers: authHeader(adminToken),
payload: { level: 3 },
});
await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
headers: authHeader(adminToken),
payload: {
name: 'Orch Node',
order: 0,
@@ -115,6 +124,10 @@ describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)',
await prismaClient.agent.deleteMany({ where: { teamId } });
await prismaClient.team.deleteMany({ where: { id: teamId } });
await prismaClient.ticketMessage.deleteMany({ where: { ticketId } });
// A wildcard (non-product-scoped) SLA policy from another concurrently-running suite (e.g.
// sla-escalation-flow.test.ts) can match this ticket too, leaving a real sla_run row that
// would otherwise RESTRICT this delete.
await prismaClient.sLARun.deleteMany({ where: { ticketId } });
await prismaClient.ticket.deleteMany({ where: { id: ticketId } });
await prismaClient.problem.deleteMany({ where: { productId } });
await prismaClient.productIntegration.deleteMany({ where: { productId } });
@@ -127,6 +140,7 @@ describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)',
const escalate = await app.inject({
method: 'PATCH',
url: `/tickets/${ticketId}/status`,
headers: authHeader(adminToken),
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
});
expect(escalate.statusCode).toBe(200);
@@ -152,6 +166,7 @@ describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)',
const manual = await app.inject({
method: 'POST',
url: `/admin/tickets/${ticketId}/assignment`,
headers: authHeader(adminToken),
payload: { agentId: otherAgentId, reason: 'Manual override for test' },
});
expect(manual.statusCode).toBe(200);
@@ -164,6 +179,7 @@ describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)',
const notFound = await app.inject({
method: 'POST',
url: `/admin/tickets/${ticketId}/assignment`,
headers: authHeader(adminToken),
payload: { agentId: 'nonexistent-agent-id' },
});
expect(notFound.statusCode).toBe(404);
@@ -190,6 +206,7 @@ describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)',
await app.inject({
method: 'PATCH',
url: `/tickets/${ticketId}/status`,
headers: authHeader(adminToken),
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
});
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
import {
encryptCredential,
generateCredentialSecret,
@@ -12,14 +13,17 @@ import {
* SKILL_BASED, and the empty-eligible-set outcome) against a real Postgres/Redis. */
describe('Orchestration and assignment — strategies (User Story 2)', () => {
let app: FastifyInstance;
let authToken: string;
let secret: string;
let teamId: string;
beforeAll(async () => {
app = await buildApp();
authToken = await loginAs(app, 'ADMIN');
const team = await app.inject({
method: 'POST',
url: '/admin/teams',
headers: authHeader(authToken),
payload: { name: `Strategy Team ${Date.now()}` },
});
teamId = team.json().data.id;
@@ -71,6 +75,7 @@ describe('Orchestration and assignment — strategies (User Story 2)', () => {
return app.inject({
method: 'PATCH',
url: `/tickets/${ticketId}/status`,
headers: authHeader(authToken),
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
});
}
@@ -80,28 +85,33 @@ describe('Orchestration and assignment — strategies (User Story 2)', () => {
const agentLow = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
headers: authHeader(authToken),
payload: { name: 'Low Load Agent' },
});
const agentHigh = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
headers: authHeader(authToken),
payload: { name: 'High Load Agent' },
});
for (const id of [agentLow.json().data.id, agentHigh.json().data.id]) {
await app.inject({
method: 'PUT',
url: `/admin/agents/${id}/skills/${skillTag}`,
headers: authHeader(authToken),
payload: { level: 1 },
});
}
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentLow.json().data.id}/availability`,
headers: authHeader(authToken),
payload: { status: 'available', workingHours: {}, currentLoad: 1 },
});
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentHigh.json().data.id}/availability`,
headers: authHeader(authToken),
payload: { status: 'available', workingHours: {}, currentLoad: 9 },
});
@@ -109,6 +119,7 @@ describe('Orchestration and assignment — strategies (User Story 2)', () => {
await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
headers: authHeader(authToken),
payload: {
name: `LL Node ${Date.now()}`,
order: 0,
@@ -128,21 +139,25 @@ describe('Orchestration and assignment — strategies (User Story 2)', () => {
const agentExpert = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
headers: authHeader(authToken),
payload: { name: 'Expert Agent' },
});
const agentNovice = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
headers: authHeader(authToken),
payload: { name: 'Novice Agent' },
});
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentExpert.json().data.id}/skills/${skillTag}`,
headers: authHeader(authToken),
payload: { level: 9 },
});
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentNovice.json().data.id}/skills/${skillTag}`,
headers: authHeader(authToken),
payload: { level: 1 },
});
@@ -150,6 +165,7 @@ describe('Orchestration and assignment — strategies (User Story 2)', () => {
await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
headers: authHeader(authToken),
payload: {
name: `SB Node ${Date.now()}`,
order: 0,
@@ -170,6 +186,7 @@ describe('Orchestration and assignment — strategies (User Story 2)', () => {
await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
headers: authHeader(authToken),
payload: {
name: `Empty Node ${Date.now()}`,
order: 0,
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
import { resolutionsService } from '@/modules/problem-management/resolutions';
import { ticketsService } from '@/modules/ticketing/tickets';
import {
@@ -18,6 +19,7 @@ import {
*/
describe('Problem resolution — full flow (User Stories 1-6)', () => {
let app: FastifyInstance;
let authToken: string;
const externalProductId = `TEST_PR_PROD_${Date.now()}`;
const skillTag = `pr_skill_${Date.now()}`;
let productId: string;
@@ -67,6 +69,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
await app.inject({
method: 'PATCH',
url: `/tickets/${ticketId}/status`,
headers: authHeader(authToken),
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
});
}
@@ -76,42 +79,51 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
headers: authHeader(authToken),
payload: { investigator: 'agent-1', findings: { note: 'checked logs' } },
});
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
headers: authHeader(authToken),
payload: { type: 'technical', description: 'a bug' },
});
const solutionRes = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/solutions`,
headers: authHeader(authToken),
payload: { proposed: 'apply fix' },
});
const solutionId = solutionRes.json().data.id;
await app.inject({
method: 'PATCH',
url: `/admin/solutions/${solutionId}/approve`,
headers: authHeader(authToken),
});
await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`,
headers: authHeader(authToken),
payload: { implementedBy: 'agent-1' },
});
await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/verification`,
headers: authHeader(authToken),
payload: { method: 'agent_confirmation', result: 'success' },
});
await app.inject({
method: 'POST',
url: `/admin/tickets/${ticketId}/resolution`,
headers: authHeader(authToken),
payload: { outcome: 'fixed', resolvedBy: 'agent-1' },
});
}
beforeAll(async () => {
app = await buildApp();
authToken = await loginAs(app, 'ADMIN');
const product = await prismaClient.product.create({
data: { externalProductId, name: 'Problem Resolution Test Product', status: 'active' },
@@ -133,6 +145,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const team = await app.inject({
method: 'POST',
url: '/admin/teams',
headers: authHeader(authToken),
payload: { name: `PR Team ${Date.now()}` },
});
teamId = team.json().data.id;
@@ -140,18 +153,21 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const agent = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
headers: authHeader(authToken),
payload: { name: 'PR Agent' },
});
agentId = agent.json().data.id;
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentId}/skills/${skillTag}`,
headers: authHeader(authToken),
payload: { level: 3 },
});
await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
headers: authHeader(authToken),
payload: {
name: 'PR Node',
order: 0,
@@ -204,6 +220,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const record = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
headers: authHeader(authToken),
payload: {
investigator: 'agent-1',
findings: { checked: 'logs' },
@@ -216,6 +233,8 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const agentRead = await app.inject({
method: 'GET',
url: `/admin/problems/${problemId}/investigations`,
headers: authHeader(authToken),
});
expect(agentRead.json().data[0].internalNotes).toBe('suspect race condition');
@@ -228,6 +247,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const second = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
headers: authHeader(authToken),
payload: { investigator: 'agent-1', findings: { checked: 'more logs' } },
});
expect(second.statusCode).toBe(201);
@@ -235,6 +255,8 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const both = await app.inject({
method: 'GET',
url: `/admin/problems/${problemId}/investigations`,
headers: authHeader(authToken),
});
expect(both.json().data.length).toBe(2);
});
@@ -245,6 +267,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const beforeInvestigation = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
headers: authHeader(authToken),
payload: { type: 'technical', description: 'x' },
});
expect(beforeInvestigation.statusCode).toBe(409);
@@ -252,12 +275,14 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
headers: authHeader(authToken),
payload: { investigator: 'agent-1', findings: {} },
});
const afterInvestigation = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
headers: authHeader(authToken),
payload: { type: 'technical', description: 'a real cause' },
});
expect(afterInvestigation.statusCode).toBe(201);
@@ -265,6 +290,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const invalidType = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
headers: authHeader(authToken),
payload: { type: 'not_a_real_type', description: 'x' },
});
expect(invalidType.statusCode).toBe(400);
@@ -276,6 +302,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const beforeRootCause = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/solutions`,
headers: authHeader(authToken),
payload: { proposed: 'x' },
});
expect(beforeRootCause.statusCode).toBe(409);
@@ -283,17 +310,20 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
headers: authHeader(authToken),
payload: { investigator: 'agent-1', findings: {} },
});
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
headers: authHeader(authToken),
payload: { type: 'technical', description: 'x' },
});
const proposed = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/solutions`,
headers: authHeader(authToken),
payload: { proposed: 'apply the fix' },
});
expect(proposed.statusCode).toBe(201);
@@ -303,15 +333,21 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const implBeforeApproval = await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`,
headers: authHeader(authToken),
payload: { implementedBy: 'agent-1' },
});
expect(implBeforeApproval.statusCode).toBe(409);
await app.inject({ method: 'PATCH', url: `/admin/solutions/${solutionId}/approve` });
await app.inject({
method: 'PATCH',
url: `/admin/solutions/${solutionId}/approve`,
headers: authHeader(authToken),
});
const impl = await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`,
headers: authHeader(authToken),
payload: { implementedBy: 'agent-1' },
});
expect(impl.statusCode).toBe(201);
@@ -319,6 +355,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const secondImpl = await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`,
headers: authHeader(authToken),
payload: { implementedBy: 'agent-1' },
});
expect(secondImpl.statusCode).toBe(409);
@@ -329,29 +366,38 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
headers: authHeader(authToken),
payload: { investigator: 'agent-1', findings: {} },
});
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
headers: authHeader(authToken),
payload: { type: 'technical', description: 'x' },
});
const proposed = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/solutions`,
headers: authHeader(authToken),
payload: { proposed: 'fix' },
});
const solutionId = proposed.json().data.id;
await app.inject({ method: 'PATCH', url: `/admin/solutions/${solutionId}/approve` });
await app.inject({
method: 'PATCH',
url: `/admin/solutions/${solutionId}/approve`,
headers: authHeader(authToken),
});
await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`,
headers: authHeader(authToken),
payload: { implementedBy: 'agent-1' },
});
const success = await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/verification`,
headers: authHeader(authToken),
payload: { method: 'agent_confirmation', result: 'success' },
});
expect(success.statusCode).toBe(201);
@@ -361,28 +407,37 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
await app.inject({
method: 'POST',
url: `/admin/problems/${problem2}/investigations`,
headers: authHeader(authToken),
payload: { investigator: 'agent-1', findings: {} },
});
await app.inject({
method: 'POST',
url: `/admin/problems/${problem2}/root-causes`,
headers: authHeader(authToken),
payload: { type: 'technical', description: 'x' },
});
const proposed2 = await app.inject({
method: 'POST',
url: `/admin/problems/${problem2}/solutions`,
headers: authHeader(authToken),
payload: { proposed: 'a wrong fix' },
});
const solution2Id = proposed2.json().data.id;
await app.inject({ method: 'PATCH', url: `/admin/solutions/${solution2Id}/approve` });
await app.inject({
method: 'PATCH',
url: `/admin/solutions/${solution2Id}/approve`,
headers: authHeader(authToken),
});
await app.inject({
method: 'POST',
url: `/admin/solutions/${solution2Id}/implementation`,
headers: authHeader(authToken),
payload: { implementedBy: 'agent-1' },
});
const failed = await app.inject({
method: 'POST',
url: `/admin/solutions/${solution2Id}/verification`,
headers: authHeader(authToken),
payload: { method: 'agent_confirmation', result: 'failed' },
});
expect(failed.statusCode).toBe(201);
@@ -391,6 +446,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const rejectedResolution = await app.inject({
method: 'POST',
url: `/admin/tickets/${ticket2}/resolution`,
headers: authHeader(authToken),
payload: { outcome: 'x', resolvedBy: 'agent-1' },
});
expect(rejectedResolution.statusCode).toBe(409);
@@ -399,12 +455,15 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const reInvestigate = await app.inject({
method: 'POST',
url: `/admin/problems/${problem2}/investigations`,
headers: authHeader(authToken),
payload: { investigator: 'agent-2', findings: { retried: true } },
});
expect(reInvestigate.statusCode).toBe(201);
const allInvestigations = await app.inject({
method: 'GET',
url: `/admin/problems/${problem2}/investigations`,
headers: authHeader(authToken),
});
expect(allInvestigations.json().data.length).toBe(2);
@@ -413,6 +472,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const escalate = await app.inject({
method: 'PATCH',
url: `/tickets/${ticket2}/status`,
headers: authHeader(authToken),
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticketBeforeEscalate.version },
});
expect(escalate.statusCode).toBe(200);
@@ -432,6 +492,7 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const rejected = await app.inject({
method: 'POST',
url: `/admin/tickets/${ticketId}/resolution`,
headers: authHeader(authToken),
payload: { outcome: 'x', resolvedBy: 'agent-1' },
});
expect(rejected.statusCode).toBe(409);
@@ -439,34 +500,44 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/investigations`,
headers: authHeader(authToken),
payload: { investigator: 'agent-1', findings: {} },
});
await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/root-causes`,
headers: authHeader(authToken),
payload: { type: 'technical', description: 'x' },
});
const proposed = await app.inject({
method: 'POST',
url: `/admin/problems/${problemId}/solutions`,
headers: authHeader(authToken),
payload: { proposed: 'fix' },
});
const solutionId = proposed.json().data.id;
await app.inject({ method: 'PATCH', url: `/admin/solutions/${solutionId}/approve` });
await app.inject({
method: 'PATCH',
url: `/admin/solutions/${solutionId}/approve`,
headers: authHeader(authToken),
});
await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/implementation`,
headers: authHeader(authToken),
payload: { implementedBy: 'agent-1' },
});
await app.inject({
method: 'POST',
url: `/admin/solutions/${solutionId}/verification`,
headers: authHeader(authToken),
payload: { method: 'agent_confirmation', result: 'success' },
});
const resolved = await app.inject({
method: 'POST',
url: `/admin/tickets/${ticketId}/resolution`,
headers: authHeader(authToken),
payload: { outcome: 'fixed', resolvedBy: 'agent-1' },
});
expect(resolved.statusCode).toBe(201);
@@ -543,6 +614,8 @@ describe('Problem resolution — full flow (User Stories 1-6)', () => {
const agentReopen = await app.inject({
method: 'POST',
url: `/admin/tickets/${ticket2}/reopen`,
headers: authHeader(authToken),
});
expect(agentReopen.statusCode).toBe(200);
expect(agentReopen.json().data.status).toBe('IN_PROGRESS');
@@ -2,6 +2,7 @@ import { describe, it, expect, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
import { issueIntegrationToken } from '@/modules/catalog/products';
/**
@@ -12,6 +13,7 @@ import { issueIntegrationToken } from '@/modules/catalog/products';
*/
describe('Product Integration Admin Lifecycle', () => {
let app: FastifyInstance;
let token: string;
const externalProductId = `TEST_ADMIN_PROD_${Date.now()}`;
afterAll(async () => {
@@ -32,9 +34,12 @@ describe('Product Integration Admin Lifecycle', () => {
it('Scenario 5+7: register, then rotate — both old and new credential work during the transition window, and the audit trail records every step', async () => {
app = await buildApp();
token = await loginAs(app, 'ADMIN');
const registerResponse = await app.inject({
method: 'POST',
url: `/admin/products/${externalProductId}/integration`,
headers: authHeader(token),
payload: {
name: 'Admin Test Product',
allowedScope: { tenantIds: ['tenant-1'] },
@@ -48,6 +53,7 @@ describe('Product Integration Admin Lifecycle', () => {
const rotateResponse = await app.inject({
method: 'POST',
url: `/admin/integrations/${integrationId}/rotate`,
headers: authHeader(token),
});
expect(rotateResponse.statusCode).toBe(200);
const rotated = rotateResponse.json().data;
@@ -89,6 +95,7 @@ describe('Product Integration Admin Lifecycle', () => {
const auditResponse = await app.inject({
method: 'GET',
url: `/admin/integrations/${integrationId}/audit-trail`,
headers: authHeader(token),
});
expect(auditResponse.statusCode).toBe(200);
const actions = auditResponse.json().data.map((e: { action: string }) => e.action);
@@ -101,6 +108,7 @@ describe('Product Integration Admin Lifecycle', () => {
const registerResponse = await app.inject({
method: 'POST',
url: `/admin/products/${externalProductId}-revoke/integration`,
headers: authHeader(token),
payload: {
name: 'Admin Test Product Revoke',
allowedScope: { tenantIds: ['tenant-1'] },
@@ -111,6 +119,7 @@ describe('Product Integration Admin Lifecycle', () => {
const revokeResponse = await app.inject({
method: 'POST',
url: `/admin/integrations/${integrationId}/revoke`,
headers: authHeader(token),
});
expect(revokeResponse.statusCode).toBe(200);
+9
View File
@@ -2,15 +2,18 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
/** Covers specs/004-product-knowledge/quickstart.md Scenario 5 against a real Postgres. */
describe('Runbooks', () => {
let app: FastifyInstance;
let token: string;
const externalProductId = `TEST_RUNBOOK_PROD_${Date.now()}`;
const key = 'PDF_HTML_CONVERSION_FAILURE';
beforeAll(async () => {
app = await buildApp();
token = await loginAs(app, 'ADMIN');
await prismaClient.product.create({
data: { externalProductId, name: 'Runbook Test Product', status: 'active' },
});
@@ -32,6 +35,7 @@ describe('Runbooks', () => {
const createResponse = await app.inject({
method: 'POST',
url: `/admin/products/${externalProductId}/runbooks`,
headers: authHeader(token),
payload: { key, steps },
});
expect(createResponse.statusCode).toBe(201);
@@ -39,6 +43,7 @@ describe('Runbooks', () => {
const lookupResponse = await app.inject({
method: 'GET',
url: `/admin/products/${externalProductId}/runbooks/${key}`,
headers: authHeader(token),
});
expect(lookupResponse.statusCode).toBe(200);
expect(lookupResponse.json().data.steps).toEqual(steps);
@@ -46,12 +51,14 @@ describe('Runbooks', () => {
const deactivateResponse = await app.inject({
method: 'PATCH',
url: `/admin/products/${externalProductId}/runbooks/${key}/deactivate`,
headers: authHeader(token),
});
expect(deactivateResponse.statusCode).toBe(200);
const lookupAfterDeactivate = await app.inject({
method: 'GET',
url: `/admin/products/${externalProductId}/runbooks/${key}`,
headers: authHeader(token),
});
expect(lookupAfterDeactivate.statusCode).toBe(404);
});
@@ -61,12 +68,14 @@ describe('Runbooks', () => {
await app.inject({
method: 'POST',
url: `/admin/products/${externalProductId}/runbooks`,
headers: authHeader(token),
payload: { key: key2, steps: [{ step: 1, description: 'Original step' }] },
});
const editResponse = await app.inject({
method: 'PUT',
url: `/admin/products/${externalProductId}/runbooks/${key2}`,
headers: authHeader(token),
payload: {
steps: [{ step: 1, description: 'Updated step' }],
expectedVersion: 1,
+54 -8
View File
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
import { slaService } from '@/modules/orchestration/sla';
import {
encryptCredential,
@@ -17,6 +18,7 @@ import {
*/
describe('SLA and escalation — full flow (User Stories 1-6)', () => {
let app: FastifyInstance;
let authToken: string;
const externalProductId = `TEST_SLA_PROD_${Date.now()}`;
const skillTag = `sla_skill_${Date.now()}`;
let productId: string;
@@ -60,12 +62,14 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
await app.inject({
method: 'PATCH',
url: `/tickets/${ticketId}/status`,
headers: authHeader(authToken),
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
});
}
beforeAll(async () => {
app = await buildApp();
authToken = await loginAs(app, 'ADMIN');
const product = await prismaClient.product.create({
data: { externalProductId, name: 'SLA Test Product', status: 'active' },
@@ -87,6 +91,7 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
const team = await app.inject({
method: 'POST',
url: '/admin/teams',
headers: authHeader(authToken),
payload: { name: `SLA Team ${Date.now()}` },
});
teamId = team.json().data.id;
@@ -94,30 +99,35 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
const agentA = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
headers: authHeader(authToken),
payload: { name: 'SLA Agent A' },
});
agentAId = agentA.json().data.id;
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentAId}/skills/${skillTag}`,
headers: authHeader(authToken),
payload: { level: 3 },
});
const agentB = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
headers: authHeader(authToken),
payload: { name: 'SLA Agent B' },
});
agentBId = agentB.json().data.id;
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentBId}/skills/${skillTag}`,
headers: authHeader(authToken),
payload: { level: 3 },
});
const nodeA = await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
headers: authHeader(authToken),
payload: {
name: 'SLA Node A',
order: 0,
@@ -131,6 +141,7 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
const nodeB = await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
headers: authHeader(authToken),
payload: {
// Scoped to this test's own product, not a wildcard ([] matches every product per
// HierarchyNode's own scope-matching rule) — a wildcard node here would leak into any
@@ -149,6 +160,7 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
const globalPolicy = await app.inject({
method: 'POST',
url: '/admin/sla-policies',
headers: authHeader(authToken),
payload: { name: 'Global policy', firstResponseMinutes: 60, resolutionMinutes: 480 },
});
globalPolicyId = globalPolicy.json().data.id;
@@ -157,6 +169,7 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
const productPolicy = await app.inject({
method: 'POST',
url: '/admin/sla-policies',
headers: authHeader(authToken),
payload: {
name: 'Product policy',
productId,
@@ -170,10 +183,20 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
afterAll(async () => {
const ticketFilter = { ticketId: { in: createdTicketIds } };
await prismaClient.escalationEvent.deleteMany({ where: ticketFilter });
await prismaClient.escalationRule.deleteMany({ where: { targetNodeId: { in: [nodeAId, nodeBId] } } });
await prismaClient.escalationRule.deleteMany({
where: { targetNodeId: { in: [nodeAId, nodeBId] } },
});
await prismaClient.escalationPolicy.deleteMany({ where: { productId } });
await prismaClient.sLARun.deleteMany({ where: ticketFilter });
await prismaClient.sLAPolicy.deleteMany({ where: { id: { in: [globalPolicyId, productPolicyId] } } });
// "Global policy" is wildcard-scoped (no productId), so it can also match tickets created by
// another concurrently-running suite — delete any sla_run left referencing it by policyId,
// not just the ones tied to this file's own tickets, or the policy delete below gets RESTRICTed.
await prismaClient.sLARun.deleteMany({
where: { policyId: { in: [globalPolicyId, productPolicyId] } },
});
await prismaClient.sLAPolicy.deleteMany({
where: { id: { in: [globalPolicyId, productPolicyId] } },
});
await prismaClient.assignmentHistory.deleteMany({ where: ticketFilter });
await prismaClient.assignment.deleteMany({ where: ticketFilter });
await prismaClient.hierarchyNode.deleteMany({ where: { id: { in: [nodeAId, nodeBId] } } });
@@ -213,12 +236,21 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
const outsidePolicy = await app.inject({
method: 'GET',
url: `/admin/sla-policies/${productPolicyId}`,
headers: authHeader(authToken),
});
expect(outsidePolicy.statusCode).toBe(200);
// Deactivate both policies temporarily to prove the no-match path.
await app.inject({ method: 'DELETE', url: `/admin/sla-policies/${productPolicyId}` });
await app.inject({ method: 'DELETE', url: `/admin/sla-policies/${globalPolicyId}` });
await app.inject({
method: 'DELETE',
url: `/admin/sla-policies/${productPolicyId}`,
headers: authHeader(authToken),
});
await app.inject({
method: 'DELETE',
url: `/admin/sla-policies/${globalPolicyId}`,
headers: authHeader(authToken),
});
const ticketId = await createTicket();
await escalateAndAssign(ticketId);
@@ -247,6 +279,7 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
await app.inject({
method: 'PATCH',
url: `/tickets/${ticketId}/status`,
headers: authHeader(authToken),
payload: { status: 'WAITING_FOR_CUSTOMER', expectedVersion: ticket.version },
});
@@ -265,6 +298,7 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
await app.inject({
method: 'PATCH',
url: `/tickets/${ticketId}/status`,
headers: authHeader(authToken),
payload: { status: 'IN_PROGRESS', expectedVersion: ticketAfterRestart.version },
});
@@ -294,12 +328,18 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
await escalateAndAssign(pausedTicketId);
await prismaClient.sLARun.update({
where: { ticketId: pausedTicketId },
data: { resolutionDueAt: new Date(Date.now() - 60_000), status: 'paused', pausedAt: new Date() },
data: {
resolutionDueAt: new Date(Date.now() - 60_000),
status: 'paused',
pausedAt: new Date(),
},
});
await slaService.runBreachDetectionSweep();
const overdue = await prismaClient.sLARun.findUniqueOrThrow({ where: { ticketId: overdueTicketId } });
const overdue = await prismaClient.sLARun.findUniqueOrThrow({
where: { ticketId: overdueTicketId },
});
expect(overdue.status).toBe('breached');
expect(overdue.breachedAt).not.toBeNull();
@@ -308,14 +348,17 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
});
expect(completed.status).toBe('completed');
const paused = await prismaClient.sLARun.findUniqueOrThrow({ where: { ticketId: pausedTicketId } });
const paused = await prismaClient.sLARun.findUniqueOrThrow({
where: { ticketId: pausedTicketId },
});
expect(paused.status).toBe('paused');
});
it('Scenario 5: a breach with a matching rule fires exactly one EscalationEvent and reassigns to the rule\'s node', async () => {
it("Scenario 5: a breach with a matching rule fires exactly one EscalationEvent and reassigns to the rule's node", async () => {
const policy = await app.inject({
method: 'POST',
url: '/admin/escalation-policies',
headers: authHeader(authToken),
payload: { name: 'Product escalation policy', productId },
});
const escalationPolicyId = policy.json().data.id;
@@ -323,6 +366,7 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
await app.inject({
method: 'POST',
url: `/admin/escalation-policies/${escalationPolicyId}/rules`,
headers: authHeader(authToken),
payload: {
triggerType: 'resolution_breach',
condition: {},
@@ -377,6 +421,7 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
const notFound = await app.inject({
method: 'POST',
url: `/tickets/${ticketId}/escalate`,
headers: authHeader(authToken),
payload: { targetNodeId: 'nonexistent-node-id', reason: 'test' },
});
expect(notFound.statusCode).toBe(404);
@@ -385,6 +430,7 @@ describe('SLA and escalation — full flow (User Stories 1-6)', () => {
const manual = await app.inject({
method: 'POST',
url: `/tickets/${ticketId}/escalate`,
headers: authHeader(authToken),
payload: { targetNodeId: nodeBId, reason: 'Customer requested a specialist' },
});
expect(manual.statusCode).toBe(201);
@@ -2,10 +2,12 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
/** Covers specs/006-support-organization/quickstart.md Scenario 4 against a real Postgres. */
describe('Support organization — capability eligibility lookup (User Story 4)', () => {
let app: FastifyInstance;
let token: string;
const teamName = `Test Capability Team ${Date.now()}`;
const skillX = `skill_x_${Date.now()}`;
const skillY = `skill_y_${Date.now()}`;
@@ -17,9 +19,11 @@ describe('Support organization — capability eligibility lookup (User Story 4)'
beforeAll(async () => {
app = await buildApp();
token = await loginAs(app, 'ADMIN');
const team = await app.inject({
method: 'POST',
url: '/admin/teams',
headers: authHeader(token),
payload: { name: teamName },
});
teamId = team.json().data.id;
@@ -27,24 +31,28 @@ describe('Support organization — capability eligibility lookup (User Story 4)'
const agentA = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
headers: authHeader(token),
payload: { name: 'Agent A' },
});
agentAId = agentA.json().data.id;
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentAId}/skills/${skillX}`,
headers: authHeader(token),
payload: { level: 3 },
});
const agentB = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
headers: authHeader(token),
payload: { name: 'Agent B' },
});
agentBId = agentB.json().data.id;
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentBId}/skills/${skillY}`,
headers: authHeader(token),
payload: { level: 3 },
});
});
@@ -75,6 +83,7 @@ describe('Support organization — capability eligibility lookup (User Story 4)'
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentAId}/availability`,
headers: authHeader(token),
payload: { status: 'offline', workingHours: {} },
});
const stillOffline = await app.inject({
@@ -86,6 +95,7 @@ describe('Support organization — capability eligibility lookup (User Story 4)'
await app.inject({
method: 'PATCH',
url: `/admin/agents/${agentAId}`,
headers: authHeader(token),
payload: { active: false },
});
const afterDeactivation = await app.inject({
@@ -106,17 +116,20 @@ describe('Support organization — capability eligibility lookup (User Story 4)'
await app.inject({
method: 'PATCH',
url: `/admin/agents/${agentAId}`,
headers: authHeader(token),
payload: { active: true },
});
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentAId}/skills/${skillZ}`,
headers: authHeader(token),
payload: { level: 2 },
});
const productId = `TEST_CAP_PRODUCT_${Date.now()}`;
const node = await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
headers: authHeader(token),
payload: {
name: 'Capability Scope Node',
order: 0,
@@ -2,16 +2,19 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
/** Covers specs/006-support-organization/quickstart.md Scenario 3 against a real Postgres. */
describe('Support organization — dynamic hierarchy (User Story 3)', () => {
let app: FastifyInstance;
let token: string;
const rootName = `Test Root ${Date.now()}`;
let rootId: string;
let childId: string;
beforeAll(async () => {
app = await buildApp();
token = await loginAs(app, 'ADMIN');
});
afterAll(async () => {
@@ -28,6 +31,7 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => {
const createRoot = await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
headers: authHeader(token),
payload: { name: rootName, order: 0, assignmentStrategy: 'ROUND_ROBIN' },
});
expect(createRoot.statusCode).toBe(201);
@@ -36,6 +40,7 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => {
const createChild = await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
headers: authHeader(token),
payload: {
name: `${rootName} Child`,
parentId: rootId,
@@ -49,12 +54,14 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => {
const children = await app.inject({
method: 'GET',
url: `/admin/hierarchy-nodes/${rootId}/children`,
headers: authHeader(token),
});
expect(children.json().data.map((n: { id: string }) => n.id)).toContain(childId);
const badParent = await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
headers: authHeader(token),
payload: {
name: 'Orphan',
parentId: 'nonexistent-node-id',
@@ -67,6 +74,7 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => {
const selfCycle = await app.inject({
method: 'PUT',
url: `/admin/hierarchy-nodes/${childId}`,
headers: authHeader(token),
payload: {
name: `${rootName} Child`,
parentId: childId,
@@ -80,15 +88,21 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => {
const transitiveCycle = await app.inject({
method: 'PUT',
url: `/admin/hierarchy-nodes/${rootId}`,
headers: authHeader(token),
payload: { name: rootName, parentId: childId, order: 0, assignmentStrategy: 'ROUND_ROBIN' },
});
expect(transitiveCycle.statusCode).toBe(400);
expect(transitiveCycle.json().error.code).toBe('CYCLE_DETECTED');
await app.inject({ method: 'PATCH', url: `/admin/hierarchy-nodes/${rootId}/deactivate` });
await app.inject({
method: 'PATCH',
url: `/admin/hierarchy-nodes/${rootId}/deactivate`,
headers: authHeader(token),
});
const activeTree = await app.inject({
method: 'GET',
url: '/admin/hierarchy-nodes?active=true',
headers: authHeader(token),
});
const activeIds = activeTree.json().data.map((n: { id: string }) => n.id);
expect(activeIds).not.toContain(rootId);
@@ -96,6 +110,7 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => {
const childAfterParentDeactivation = await app.inject({
method: 'GET',
url: `/admin/hierarchy-nodes/${childId}`,
headers: authHeader(token),
});
expect(childAfterParentDeactivation.json().data.active).toBe(true);
});
@@ -104,6 +119,7 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => {
const parent = await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
headers: authHeader(token),
payload: { name: `${rootName} Order Parent`, order: 0, assignmentStrategy: 'ROUND_ROBIN' },
});
const parentId = parent.json().data.id;
@@ -111,17 +127,20 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => {
const second = await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
headers: authHeader(token),
payload: { name: 'Second', parentId, order: 2, assignmentStrategy: 'ROUND_ROBIN' },
});
const first = await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
headers: authHeader(token),
payload: { name: 'First', parentId, order: 1, assignmentStrategy: 'ROUND_ROBIN' },
});
const children = await app.inject({
method: 'GET',
url: `/admin/hierarchy-nodes/${parentId}/children`,
headers: authHeader(token),
});
const orderedIds = children.json().data.map((n: { id: string }) => n.id);
expect(orderedIds).toEqual([first.json().data.id, second.json().data.id]);
@@ -141,12 +160,21 @@ describe('Support organization — dynamic hierarchy (User Story 3)', () => {
const created = await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
headers: authHeader(token),
payload: { name: `${rootName} Audited`, order: 0, assignmentStrategy: 'ROUND_ROBIN' },
});
const nodeId = created.json().data.id;
await app.inject({ method: 'PATCH', url: `/admin/hierarchy-nodes/${nodeId}/deactivate` });
await app.inject({ method: 'PATCH', url: `/admin/hierarchy-nodes/${nodeId}/activate` });
await app.inject({
method: 'PATCH',
url: `/admin/hierarchy-nodes/${nodeId}/deactivate`,
headers: authHeader(token),
});
await app.inject({
method: 'PATCH',
url: `/admin/hierarchy-nodes/${nodeId}/activate`,
headers: authHeader(token),
});
const auditRows = await prismaClient.auditLog.findMany({
where: { entityType: 'HierarchyNode', entityId: nodeId },
@@ -2,25 +2,30 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
/** Covers specs/006-support-organization/quickstart.md Scenario 2 against a real Postgres. */
describe('Support organization — agent skills and availability (User Story 2)', () => {
let app: FastifyInstance;
let token: string;
const teamName = `Test Skills Team ${Date.now()}`;
let teamId: string;
let agentId: string;
beforeAll(async () => {
app = await buildApp();
token = await loginAs(app, 'ADMIN');
const team = await app.inject({
method: 'POST',
url: '/admin/teams',
headers: authHeader(token),
payload: { name: teamName },
});
teamId = team.json().data.id;
const agent = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
headers: authHeader(token),
payload: { name: 'Skilled Agent' },
});
agentId = agent.json().data.id;
@@ -38,6 +43,7 @@ describe('Support organization — agent skills and availability (User Story 2)'
const addSkill = await app.inject({
method: 'PUT',
url: `/admin/agents/${agentId}/skills/pdf_conversion`,
headers: authHeader(token),
payload: { level: 3 },
});
expect(addSkill.statusCode).toBe(200);
@@ -45,11 +51,16 @@ describe('Support organization — agent skills and availability (User Story 2)'
const updateSkill = await app.inject({
method: 'PUT',
url: `/admin/agents/${agentId}/skills/pdf_conversion`,
headers: authHeader(token),
payload: { level: 5 },
});
expect(updateSkill.statusCode).toBe(200);
const skills = await app.inject({ method: 'GET', url: `/admin/agents/${agentId}/skills` });
const skills = await app.inject({
method: 'GET',
url: `/admin/agents/${agentId}/skills`,
headers: authHeader(token),
});
const pdfSkills = skills
.json()
.data.filter((s: { skillTag: string }) => s.skillTag === 'pdf_conversion');
@@ -59,6 +70,7 @@ describe('Support organization — agent skills and availability (User Story 2)'
const setAvailability = await app.inject({
method: 'PUT',
url: `/admin/agents/${agentId}/availability`,
headers: authHeader(token),
payload: { status: 'busy', workingHours: { mon: '9-17' } },
});
expect(setAvailability.statusCode).toBe(200);
@@ -67,6 +79,7 @@ describe('Support organization — agent skills and availability (User Story 2)'
const updateAvailability = await app.inject({
method: 'PUT',
url: `/admin/agents/${agentId}/availability`,
headers: authHeader(token),
payload: { status: 'available', workingHours: { mon: '9-17' } },
});
expect(updateAvailability.statusCode).toBe(200);
@@ -74,6 +87,7 @@ describe('Support organization — agent skills and availability (User Story 2)'
const current = await app.inject({
method: 'GET',
url: `/admin/agents/${agentId}/availability`,
headers: authHeader(token),
});
expect(current.json().data.status).toBe('available');
@@ -85,6 +99,7 @@ describe('Support organization — agent skills and availability (User Story 2)'
const response = await app.inject({
method: 'PUT',
url: `/admin/agents/${agentId}/availability`,
headers: authHeader(token),
payload: { status: 'not_a_real_status', workingHours: {} },
});
expect(response.statusCode).toBe(400);
@@ -2,15 +2,18 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
/** Covers specs/006-support-organization/quickstart.md Scenario 1 against a real Postgres. */
describe('Support organization — teams and agents (User Story 1)', () => {
let app: FastifyInstance;
let token: string;
const teamName = `Test Team ${Date.now()}`;
let teamId: string;
beforeAll(async () => {
app = await buildApp();
token = await loginAs(app, 'ADMIN');
});
afterAll(async () => {
@@ -27,6 +30,7 @@ describe('Support organization — teams and agents (User Story 1)', () => {
const createTeam = await app.inject({
method: 'POST',
url: '/admin/teams',
headers: authHeader(token),
payload: { name: teamName },
});
expect(createTeam.statusCode).toBe(201);
@@ -36,48 +40,63 @@ describe('Support organization — teams and agents (User Story 1)', () => {
const createAgent = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
headers: authHeader(token),
payload: { name: 'Agent A' },
});
expect(createAgent.statusCode).toBe(201);
const agentId = createAgent.json().data.id;
const roster = await app.inject({ method: 'GET', url: `/admin/teams/${teamId}` });
const roster = await app.inject({
method: 'GET',
url: `/admin/teams/${teamId}`,
headers: authHeader(token),
});
expect(roster.json().data.agents.map((a: { id: string }) => a.id)).toContain(agentId);
await app.inject({
method: 'PATCH',
url: `/admin/agents/${agentId}`,
headers: authHeader(token),
payload: { active: false },
});
const activeListing = await app.inject({
method: 'GET',
url: `/admin/agents?active=true&teamId=${teamId}`,
headers: authHeader(token),
});
expect(activeListing.json().data.map((a: { id: string }) => a.id)).not.toContain(agentId);
const directFetch = await app.inject({ method: 'GET', url: `/admin/agents/${agentId}` });
const directFetch = await app.inject({
method: 'GET',
url: `/admin/agents/${agentId}`,
headers: authHeader(token),
});
expect(directFetch.statusCode).toBe(200);
expect(directFetch.json().data.active).toBe(false);
await app.inject({
method: 'PATCH',
url: `/admin/agents/${agentId}`,
headers: authHeader(token),
payload: { active: true },
});
const reactivatedListing = await app.inject({
method: 'GET',
url: `/admin/agents?active=true&teamId=${teamId}`,
headers: authHeader(token),
});
expect(reactivatedListing.json().data.map((a: { id: string }) => a.id)).toContain(agentId);
await app.inject({
method: 'PATCH',
url: `/admin/teams/${teamId}`,
headers: authHeader(token),
payload: { active: false },
});
const agentAfterTeamDeactivation = await app.inject({
method: 'GET',
url: `/admin/agents/${agentId}`,
headers: authHeader(token),
});
expect(agentAfterTeamDeactivation.json().data.active).toBe(true);
});
@@ -86,6 +105,7 @@ describe('Support organization — teams and agents (User Story 1)', () => {
const response = await app.inject({
method: 'POST',
url: '/admin/teams/nonexistent-team-id/agents',
headers: authHeader(token),
payload: { name: 'Ghost Agent' },
});
expect(response.statusCode).toBe(404);
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import { loginAs, authHeader } from '../helpers/auth';
import {
encryptCredential,
generateCredentialSecret,
@@ -12,12 +13,15 @@ import { malwareScanner } from '@/modules/ticketing/attachments';
/** Covers specs/003-ticketing/quickstart.md Scenario 5 against a real Postgres/Redis/MinIO. */
describe('Ticket attachments — upload, confirm, scan-gated download', () => {
let app: FastifyInstance;
let agentToken: string;
let ticketId: string;
const externalProductId = `TEST_ATT_PROD_${Date.now()}`;
beforeAll(async () => {
app = await buildApp();
agentToken = await loginAs(app, 'AGENT');
const product = await prismaClient.product.create({
data: { externalProductId, name: 'Attachments Test Product', status: 'active' },
});
@@ -70,6 +74,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => {
const response = await app.inject({
method: 'POST',
url: `/tickets/${ticketId}/attachments/upload-url`,
headers: authHeader(agentToken),
payload: { fileName: 'huge.pdf', mimeType: 'application/pdf', sizeBytes: 999_999_999 },
});
expect(response.statusCode).toBe(400);
@@ -79,6 +84,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => {
const response = await app.inject({
method: 'POST',
url: `/tickets/${ticketId}/attachments/upload-url`,
headers: authHeader(agentToken),
payload: { fileName: 'evil.exe', mimeType: 'application/x-msdownload', sizeBytes: 100 },
});
expect(response.statusCode).toBe(400);
@@ -88,6 +94,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => {
const uploadUrlResponse = await app.inject({
method: 'POST',
url: `/tickets/${ticketId}/attachments/upload-url`,
headers: authHeader(agentToken),
payload: { fileName: 'screenshot.png', mimeType: 'image/png', sizeBytes: 1024 },
});
expect(uploadUrlResponse.statusCode).toBe(200);
@@ -103,6 +110,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => {
const confirmResponse = await app.inject({
method: 'POST',
url: `/tickets/${ticketId}/attachments/confirm`,
headers: authHeader(agentToken),
payload: { storageKey, fileName: 'screenshot.png', mimeType: 'image/png', sizeBytes: 1024 },
});
expect(confirmResponse.statusCode).toBe(201);
@@ -112,6 +120,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => {
const downloadWhilePending = await app.inject({
method: 'GET',
url: `/tickets/${ticketId}/attachments/${attachmentId}/download-url`,
headers: authHeader(agentToken),
});
expect(downloadWhilePending.statusCode).toBe(409);
@@ -126,6 +135,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => {
const downloadAfterScan = await app.inject({
method: 'GET',
url: `/tickets/${ticketId}/attachments/${attachmentId}/download-url`,
headers: authHeader(agentToken),
});
// The placeholder scanner fails closed (always 'infected'), so this remains refused —
// proving the pipeline actually gates on a real scan result rather than defaulting open.
@@ -141,6 +151,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => {
const uploadUrlResponse = await app.inject({
method: 'POST',
url: `/tickets/${ticketId}/attachments/upload-url`,
headers: authHeader(agentToken),
payload: { fileName: 'clean.pdf', mimeType: 'application/pdf', sizeBytes: 2048 },
});
const { uploadUrl, storageKey } = uploadUrlResponse.json().data;
@@ -153,6 +164,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => {
const confirmResponse = await app.inject({
method: 'POST',
url: `/tickets/${ticketId}/attachments/confirm`,
headers: authHeader(agentToken),
payload: { storageKey, fileName: 'clean.pdf', mimeType: 'application/pdf', sizeBytes: 2048 },
});
const attachmentId = confirmResponse.json().data.id;
@@ -169,6 +181,7 @@ describe('Ticket attachments — upload, confirm, scan-gated download', () => {
const downloadResponse = await app.inject({
method: 'GET',
url: `/tickets/${ticketId}/attachments/${attachmentId}/download-url`,
headers: authHeader(agentToken),
});
expect(downloadResponse.statusCode).toBe(200);
const { downloadUrl } = downloadResponse.json().data;
@@ -7,6 +7,7 @@ import {
generateCredentialSecret,
issueIntegrationToken,
} from '@/modules/catalog/products';
import { loginAs, authHeader } from '../helpers/auth';
/**
* Covers specs/003-ticketing/quickstart.md Scenarios 1, 2, 3, 6 end-to-end against a real
@@ -15,10 +16,12 @@ import {
describe('Ticket creation via the inbound trust boundary', () => {
let app: FastifyInstance;
let secret: string;
let agentToken: string;
const externalProductId = `TEST_TICKET_PROD_${Date.now()}`;
beforeAll(async () => {
app = await buildApp();
agentToken = await loginAs(app, 'AGENT');
const product = await prismaClient.product.create({
data: { externalProductId, name: 'Ticket Test Product', status: 'active' },
@@ -122,11 +125,13 @@ describe('Ticket creation via the inbound trust boundary', () => {
const first = await app.inject({
method: 'PATCH',
url: `/tickets/${ticketId}/status`,
headers: authHeader(agentToken),
payload: { status: 'AI_ANALYZING', expectedVersion: ticket.version },
});
const second = await app.inject({
method: 'PATCH',
url: `/tickets/${ticketId}/status`,
headers: authHeader(agentToken),
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
});
@@ -145,6 +150,7 @@ describe('Ticket creation via the inbound trust boundary', () => {
const response = await app.inject({
method: 'PATCH',
url: `/tickets/${ticketId}/status`,
headers: authHeader(agentToken),
payload: { status: 'RESOLVED', expectedVersion: ticket.version },
});
+11 -1
View File
@@ -8,15 +8,18 @@ import {
issueIntegrationToken,
} from '@/modules/catalog/products';
import { MESSAGE_TYPES } from '@/modules/ticketing/messages/mapper/message-visibility';
import { loginAs, authHeader } from '../helpers/auth';
/** Covers specs/003-ticketing/quickstart.md Scenario 4 against a real Postgres. */
describe('Ticket messages — type-scoped visibility', () => {
let app: FastifyInstance;
let ticketId: string;
let authToken: string;
const externalProductId = `TEST_MSG_PROD_${Date.now()}`;
beforeAll(async () => {
app = await buildApp();
authToken = await loginAs(app, 'AGENT');
const product = await prismaClient.product.create({
data: { externalProductId, name: 'Messages Test Product', status: 'active' },
@@ -57,6 +60,7 @@ describe('Ticket messages — type-scoped visibility', () => {
const response = await app.inject({
method: 'POST',
url: `/tickets/${ticketId}/messages`,
headers: authHeader(authToken),
payload: { type, body: `Message of type ${type}` },
});
expect(response.statusCode).toBe(201);
@@ -75,7 +79,11 @@ describe('Ticket messages — type-scoped visibility', () => {
});
it('a customer-scoped read excludes internal-only types entirely', async () => {
const response = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/messages` });
const response = await app.inject({
method: 'GET',
url: `/tickets/${ticketId}/messages`,
headers: authHeader(authToken),
});
expect(response.statusCode).toBe(200);
const types = response.json().data.map((m: { type: string }) => m.type);
@@ -92,6 +100,7 @@ describe('Ticket messages — type-scoped visibility', () => {
const response = await app.inject({
method: 'GET',
url: `/agent/tickets/${ticketId}/messages`,
headers: authHeader(authToken),
});
expect(response.statusCode).toBe(200);
const types = response.json().data.map((m: { type: string }) => m.type);
@@ -107,6 +116,7 @@ describe('Ticket messages — type-scoped visibility', () => {
const response = await app.inject({
method: 'POST',
url: `/tickets/${ticketId}/messages`,
headers: authHeader(authToken),
payload: { type: 'NOT_A_REAL_TYPE', body: 'x' },
});
expect(response.statusCode).toBe(400);
@@ -0,0 +1,62 @@
import { describe, it, expect, vi } from 'vitest';
import bcrypt from 'bcryptjs';
import { AuthService } from '@/modules/identity/auth/service/auth.service';
const REAL_PASSWORD_HASH = bcrypt.hashSync('the-real-password', 10);
function fakeUser(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: 'u1',
email: 'agent@example.com',
name: 'Agent',
role: 'AGENT',
passwordHash: REAL_PASSWORD_HASH,
active: true,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
describe('AuthService.login failure parity', () => {
it('throws the identical error for a nonexistent email and a wrong password', async () => {
const repoFoundUser = { findByEmail: vi.fn().mockResolvedValue(fakeUser()) } as never;
const repoNoUser = { findByEmail: vi.fn().mockResolvedValue(null) } as never;
const serviceWithUser = new AuthService(repoFoundUser);
const serviceWithoutUser = new AuthService(repoNoUser);
let errorWithUser: Error | undefined;
let errorWithoutUser: Error | undefined;
try {
await serviceWithUser.login({ email: 'agent@example.com', password: 'definitely-wrong' });
} catch (e) {
errorWithUser = e as Error;
}
try {
await serviceWithoutUser.login({ email: 'nobody@example.com', password: 'anything' });
} catch (e) {
errorWithoutUser = e as Error;
}
expect(errorWithUser).toBeDefined();
expect(errorWithoutUser).toBeDefined();
expect(errorWithUser?.message).toBe(errorWithoutUser?.message);
expect((errorWithUser as { statusCode?: number })?.statusCode).toBe(
(errorWithoutUser as { statusCode?: number })?.statusCode,
);
});
it('rejects a deactivated account with the same error, never a distinguishable one', async () => {
const repo = {
findByEmail: vi.fn().mockResolvedValue(fakeUser({ active: false })),
} as never;
const service = new AuthService(repo);
await expect(
service.login({ email: 'agent@example.com', password: 'anything' }),
).rejects.toMatchObject({ statusCode: 401 });
});
});
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import { FastifyReply, FastifyRequest } from 'fastify';
import { requireRole } from '@/modules/identity/auth/service/require-role';
function fakeRequest(user?: { role: string }): FastifyRequest {
return { user } as unknown as FastifyRequest;
}
describe('requireRole', () => {
it('passes when the session role is in the allowed list', async () => {
const guard = requireRole('ADMIN');
await expect(
guard(fakeRequest({ role: 'ADMIN' }), {} as FastifyReply),
).resolves.toBeUndefined();
});
it('throws AuthorizationError when the session role is not in the allowed list', async () => {
const guard = requireRole('ADMIN');
await expect(guard(fakeRequest({ role: 'AGENT' }), {} as FastifyReply)).rejects.toMatchObject({
statusCode: 403,
});
});
it('throws AuthorizationError when there is no session at all', async () => {
const guard = requireRole('ADMIN');
await expect(guard(fakeRequest(undefined), {} as FastifyReply)).rejects.toMatchObject({
statusCode: 403,
});
});
it('accepts any role in a multi-role allow list', async () => {
const guard = requireRole('ADMIN', 'AGENT');
await expect(
guard(fakeRequest({ role: 'AGENT' }), {} as FastifyReply),
).resolves.toBeUndefined();
});
});
@@ -20,15 +20,16 @@ describe('EscalationService.handleBreach', () => {
} as never;
const rules = { findActiveRules: vi.fn().mockResolvedValue([rule]) } as never;
const events = { create: vi.fn().mockResolvedValue({ id: 'event-1' }) } as never;
const assignmentEngine = { assignToSpecificNode: vi.fn().mockResolvedValue(undefined) } as never;
const assignmentEngine = {
assignToSpecificNode: vi.fn().mockResolvedValue(undefined),
} as never;
const service = new EscalationService(policies, rules, events, assignmentEngine);
await service.handleBreach('t1', 'resolution_breach');
expect((rules as { findActiveRules: ReturnType<typeof vi.fn> }).findActiveRules).toHaveBeenCalledWith(
'policy-1',
'resolution_breach',
);
expect(
(rules as { findActiveRules: ReturnType<typeof vi.fn> }).findActiveRules,
).toHaveBeenCalledWith('policy-1', 'resolution_breach');
expect((events as { create: ReturnType<typeof vi.fn> }).create).toHaveBeenCalledWith(
expect.objectContaining({ ticketId: 't1', ruleId: 'rule-1', toNodeId: 'node-1' }),
);
@@ -46,7 +47,9 @@ describe('EscalationService.handleBreach', () => {
const service = new EscalationService(policies, rules, events, assignmentEngine);
await service.handleBreach('t1', 'resolution_breach');
expect((rules as { findActiveRules: ReturnType<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();
});
@@ -34,10 +34,7 @@ describe('SlaService.runBreachDetectionSweep', () => {
const service = new SlaService(undefined, runsRepo, undefined, undefined, escalation);
await service.runBreachDetectionSweep();
expect(update).toHaveBeenCalledWith(
'r1',
expect.objectContaining({ status: 'breached' }),
);
expect(update).toHaveBeenCalledWith('r1', expect.objectContaining({ status: 'breached' }));
expect(handleBreach).toHaveBeenCalledWith('t1', 'resolution_breach');
});
@@ -53,8 +53,10 @@ describe('SlaService pause/resume', () => {
expect(patch.pausedAt).toBeNull();
const shiftedResolution = (patch.resolutionDueAt as Date).getTime();
const expectedShiftMin = new Date('2026-01-05T17:00:00.000Z').getTime() + (before - pausedAt.getTime());
const expectedShiftMax = new Date('2026-01-05T17:00:00.000Z').getTime() + (after - pausedAt.getTime());
const expectedShiftMin =
new Date('2026-01-05T17:00:00.000Z').getTime() + (before - pausedAt.getTime());
const expectedShiftMax =
new Date('2026-01-05T17:00:00.000Z').getTime() + (after - pausedAt.getTime());
expect(shiftedResolution).toBeGreaterThanOrEqual(expectedShiftMin);
expect(shiftedResolution).toBeLessThanOrEqual(expectedShiftMax);
});
@@ -67,7 +69,11 @@ describe('SlaService pause/resume', () => {
});
const secondPausedAt = new Date(Date.now() - 10 * 60 * 1000);
const pausedAgain = { ...afterFirstResume, status: 'paused', pausedAt: secondPausedAt } as SLARun;
const pausedAgain = {
...afterFirstResume,
status: 'paused',
pausedAt: secondPausedAt,
} as SLARun;
const update = vi.fn().mockResolvedValue(pausedAgain);
const runsRepo = { findByTicketId: vi.fn().mockResolvedValue(pausedAgain), update } as never;
const service = new SlaService(undefined, runsRepo);
@@ -16,6 +16,7 @@ function fakeAgent(id: string) {
teamId: 't',
name: id,
active: true,
userId: null,
createdAt: new Date(),
updatedAt: new Date(),
skills: [],
@@ -1,5 +1,8 @@
import { describe, it, expect } from 'vitest';
import { addBusinessMinutes, isWithinWorkingHours } from '@/modules/platform/business-calendars/calculators/business-hours.calculator';
import {
addBusinessMinutes,
isWithinWorkingHours,
} from '@/modules/platform/business-calendars/calculators/business-hours.calculator';
const MON_FRI_9_TO_5 = {
timezone: 'America/New_York',
@@ -66,7 +69,9 @@ describe('addBusinessMinutes', () => {
it('returns the start time unchanged when minutes is zero or negative', () => {
const start = new Date(Date.UTC(2026, 0, 5, 15, 0));
expect(addBusinessMinutes(start, 0, MON_FRI_9_TO_5, []).toISOString()).toBe(start.toISOString());
expect(addBusinessMinutes(start, 0, MON_FRI_9_TO_5, []).toISOString()).toBe(
start.toISOString(),
);
});
it('is correct across a DST transition (US spring-forward, March 2026)', () => {
@@ -1,5 +1,8 @@
import { describe, it, expect } from 'vitest';
import { createRootCauseSchema, ROOT_CAUSE_TYPES } from '@/modules/problem-management/root-causes/schema/root-cause.schema';
import {
createRootCauseSchema,
ROOT_CAUSE_TYPES,
} from '@/modules/problem-management/root-causes/schema/root-cause.schema';
describe('createRootCauseSchema', () => {
it('accepts every documented root cause type', () => {