386 行
15 KiB
Markdown
386 行
15 KiB
Markdown
# Backend Fastify / Prisma
|
|
|
|
## Scope
|
|
|
|
Server work lives under `server/`. Prefer existing route, service, utility and
|
|
QA script patterns over new abstractions.
|
|
|
|
## Rules
|
|
|
|
- Validate Prisma changes with `cd server && npm run prisma:validate`.
|
|
- For route/service edits, run `node --check` on the touched files and the
|
|
focused regression listed in the current Trellis task.
|
|
- Prisma migrations require rollback notes in the Trellis task design or PRD.
|
|
- Do not resolve migration drift, reset databases, or rewrite migration history
|
|
without explicit approval.
|
|
- Workflow, approval and permission behavior must be verified at process level,
|
|
not only syntax level.
|
|
|
|
## Common Checks
|
|
|
|
- `cd server && node --check src/routes/reimbursements.js`
|
|
- `cd server && node --check src/routes/workflow.js`
|
|
- `cd server && ./scripts/regression-workflow-actions.sh`
|
|
- `cd server && ./scripts/regression-all.sh`
|
|
|
|
## Scenario: Local Regression Migration Drift
|
|
|
|
### 1. Scope / Trigger
|
|
|
|
- Trigger: a local or attached E2E fails with Prisma `missing column`,
|
|
`column does not exist`, enum missing, or route 500 immediately after a
|
|
schema-backed feature was added.
|
|
- Applies to `server/prisma/schema.prisma`, `server/prisma/migrations/`,
|
|
local regression databases such as `hyb_oa_regression`, and Playwright tests
|
|
that use real API/database flows.
|
|
|
|
### 2. Signatures
|
|
|
|
- Read-only drift check: `cd server && npx prisma migrate status`.
|
|
- Apply committed migrations after explicit confirmation:
|
|
`cd server && npx prisma migrate deploy`.
|
|
- Local service smoke for attached E2E:
|
|
`HOST=127.0.0.1 PORT=3000 JOBS_ENABLED=false SERVE_CLIENT_DIST=true npm run dev`.
|
|
- Server entrypoints that call `getConfig()` before `buildApp()` must load
|
|
`dotenv/config` before reading config.
|
|
|
|
### 3. Contracts
|
|
|
|
- `migrate status` is safe and read-only; it may be run before asking the user.
|
|
- `migrate deploy` writes database schema state and requires explicit user
|
|
confirmation unless the current task already approved that exact local DB.
|
|
- Do not use `prisma migrate reset` for E2E recovery without separate backup
|
|
and destructive-action approval.
|
|
- Attached local E2E should disable background jobs with `JOBS_ENABLED=false`
|
|
unless the test explicitly validates job behavior.
|
|
|
|
### 4. Validation & Error Matrix
|
|
|
|
- `migrate status` reports pending migrations -> record the migration names,
|
|
inspect whether they are additive/destructive, then ask before applying.
|
|
- Missing `JWT_SECRET` during `npm run dev` while `.env` exists -> verify the
|
|
entrypoint loads `dotenv/config` before `getConfig()`.
|
|
- Missing column after `migrate deploy` -> inspect actual columns and migration
|
|
history; do not patch tests to hide schema drift.
|
|
- E2E selector failure after schema is fixed -> treat as page/test contract
|
|
drift and update the focused E2E assertion, not the database.
|
|
|
|
### 5. Good/Base/Bad Cases
|
|
|
|
- Good: run `migrate status`, apply only committed migrations after approval,
|
|
seed dev users, then rerun the focused Playwright flow.
|
|
- Base: if migrations are already current, skip DB writes and debug the test
|
|
failure directly.
|
|
- Bad: use `migrate reset` or manually add columns to unblock a test.
|
|
- Bad: start the server with an ad hoc `node -r dotenv/config` workaround and
|
|
leave `npm run dev` broken.
|
|
|
|
### 6. Tests Required
|
|
|
|
- `cd server && npm run prisma:validate`.
|
|
- `bash scripts/qa/check-local-migration-drift.sh` when local DB drift was the
|
|
cause.
|
|
- `cd server && node --check src/index.js` if server bootstrap changed.
|
|
- Focused Playwright flow that previously failed.
|
|
- Frontend `npm run build` when Playwright or client behavior changed.
|
|
|
|
### 7. Wrong vs Correct
|
|
|
|
#### Wrong
|
|
|
|
```bash
|
|
cd server && npx prisma migrate reset
|
|
```
|
|
|
|
#### Correct
|
|
|
|
```bash
|
|
cd server && npx prisma migrate status
|
|
# after explicit approval for the local regression database only
|
|
cd server && npx prisma migrate deploy
|
|
```
|
|
|
|
## Scenario: Portal Announcement Acknowledgement
|
|
|
|
### 1. Scope / Trigger
|
|
|
|
- Trigger: extending `announcements_v1` from read-only announcements into formal employee acknowledgement.
|
|
- Applies to `server/prisma/schema.prisma`, `server/src/routes/announcements.js`, `client/src/api/announcements.ts`, employee `/announcements`, and admin `/system/announcements`.
|
|
- Use this only for lightweight policy acknowledgement; do not treat it as electronic signature, CA signing, contract signing, or full policy CMS.
|
|
|
|
### 2. Signatures
|
|
|
|
- DB: `PortalAnnouncement` may carry `category`, `requires_acknowledgement`, `acknowledgement_statement`, `acknowledgement_due_at`.
|
|
- DB: `PortalAnnouncementAcknowledgement` stores one receipt per `(announcement_id, employee_id)` with statement/title/summary snapshots and `actor_id`.
|
|
- API: `GET /api/announcements?manage=0` returns visible announcements and the current employee's acknowledgement status.
|
|
- API: `GET /api/announcements?manage=1` requires `system.settings` and returns acknowledgement summary plus minimal pending employees.
|
|
- API: `POST /api/announcements/:id/acknowledge` accepts `{ "confirmed": true }`.
|
|
|
|
### 3. Contracts
|
|
|
|
- Required acknowledgement status values: `not_required`, `pending`, `acknowledged`, `overdue`.
|
|
- Employee response must not include other employees' acknowledgement rows or pending lists.
|
|
- Admin pending list may include only `id`, `employeeNo`, `name`, and `status`; do not include phone, email, WeCom userid, account identifiers, or sensitive profile fields.
|
|
- Acknowledgement is explicit only: opening an announcement or clicking its link must never create a receipt.
|
|
- Policy content changes after any acknowledgement require a new announcement version; do not mutate title, summary, category, required flag, or statement in place.
|
|
|
|
### 4. Validation & Error Matrix
|
|
|
|
- `announcements_v1` disabled -> `409 VALID_001`.
|
|
- policy acknowledgement without statement -> `400 VALID_001`.
|
|
- `manage=1` without `system.settings` -> `403 PERM_001`.
|
|
- acknowledge invisible/missing announcement -> `404 ANN_404`.
|
|
- acknowledge normal announcement -> `409 VALID_001`.
|
|
- acknowledge without `{ confirmed: true }` -> `400 VALID_001`.
|
|
- edit protected policy fields after acknowledgement exists -> `409 VALID_001`.
|
|
|
|
### 5. Good/Base/Bad Cases
|
|
|
|
- Good: admin creates a policy announcement, employee checks the confirmation box, server creates one receipt and one audit log.
|
|
- Base: normal announcement remains compatible and returns `not_required`.
|
|
- Bad: frontend displays another employee's receipt status on `/announcements`.
|
|
- Bad: admin updates an acknowledged policy title instead of creating a new version.
|
|
|
|
### 6. Tests Required
|
|
|
|
- Prisma validate after schema/migration changes.
|
|
- Route focused tests for required statement, permission, idempotency, normal/invisible announcement rejection, admin summary, and protected edit rejection.
|
|
- Frontend typecheck and build.
|
|
- Playwright flow for admin create -> employee acknowledge -> admin summary update when UI changes.
|
|
|
|
### 7. Wrong vs Correct
|
|
|
|
#### Wrong
|
|
|
|
```js
|
|
await prisma.portalAnnouncement.update({
|
|
where: { id },
|
|
data: { title: "制度 V2" },
|
|
});
|
|
```
|
|
|
|
#### Correct
|
|
|
|
```js
|
|
const acknowledgementCount = await prisma.portalAnnouncementAcknowledgement.count({
|
|
where: { announcement_id: id },
|
|
});
|
|
if (acknowledgementCount > 0) {
|
|
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409 });
|
|
}
|
|
```
|
|
|
|
## Scenario: Safe Development Seed
|
|
|
|
### 1. Scope / Trigger
|
|
|
|
- Trigger: changing or running `server/src/scripts/seed-dev.js`, its safety
|
|
guard, development account defaults or the `seed:dev` npm command.
|
|
|
|
### 2. Signatures
|
|
|
|
- Command: `SEED_DEV_ALLOW=1 SEED_DEV_DATABASE_CONFIRM=<db> npm run seed:dev`.
|
|
- Optional reset: `SEED_DEV_RESET_PASSWORDS=1`.
|
|
- Target: `DATABASE_URL` parsed with the same `pg` semantics used by Prisma's
|
|
PostgreSQL adapter.
|
|
|
|
### 3. Contracts
|
|
|
|
- `NODE_ENV=production` is permanently refused.
|
|
- The explicit allow switch and exact database-name confirmation are both
|
|
required.
|
|
- The effective PostgreSQL host, including query-string overrides, must be
|
|
loopback; checking only the URI authority is insufficient.
|
|
- Existing passwords are preserved by default. Newly created accounts and
|
|
explicitly rotated accounts set `must_change_password=true`.
|
|
- The guard and seed logs never print credentials, complete targets or account
|
|
passwords.
|
|
|
|
### 4. Validation & Error Matrix
|
|
|
|
- Production -> `production_forbidden`.
|
|
- Missing allow switch -> `explicit_allow_required`.
|
|
- Invalid/non-PostgreSQL URL -> `database_url_invalid` or
|
|
`database_protocol_invalid`.
|
|
- Effective non-loopback host, including `?host=...` ->
|
|
`database_host_not_local`.
|
|
- Missing/mismatched confirmation -> `database_confirmation_mismatch`.
|
|
|
|
### 5. Good/Base/Bad Cases
|
|
|
|
- Good: loopback test database, exact confirmation, new accounts require a
|
|
password change.
|
|
- Base: rerun without reset preserves existing password hashes.
|
|
- Bad: accept `postgresql://[email protected]/db?host=remote.internal` because only
|
|
`URL.hostname` was checked.
|
|
|
|
### 6. Tests Required
|
|
|
|
- Pure guard tests for production, missing allow, remote authority, host-query
|
|
override, exact confirmation and redacted errors.
|
|
- Unit tests for existing-password preservation, explicit rotation and new
|
|
account first-login change.
|
|
- Do not validate refusal paths by connecting to a real database.
|
|
|
|
### 7. Wrong vs Correct
|
|
|
|
#### Wrong
|
|
|
|
```js
|
|
if (new URL(env.DATABASE_URL).hostname === "127.0.0.1") allowSeed();
|
|
```
|
|
|
|
#### Correct
|
|
|
|
```js
|
|
const client = new Client({ connectionString: env.DATABASE_URL });
|
|
if (!LOOPBACK_HOSTS.has(client.host)) refuse("database_host_not_local");
|
|
```
|
|
|
|
## Scenario: Database-Free App Contract Tests
|
|
|
|
### 1. Scope / Trigger
|
|
|
|
- Trigger: unit tests call the production `buildApp()` factory to verify health,
|
|
readiness, static serving or other app-level contracts in CI without a DB.
|
|
|
|
### 2. Signatures
|
|
|
|
- Production: `buildApp()`.
|
|
- Test seam: `buildApp({ prismaPlugin, startJobs })`.
|
|
|
|
### 3. Contracts
|
|
|
|
- Omitted dependencies always use the production Prisma plugin and job starter.
|
|
- Unit tests inject a `fastify-plugin`-wrapped Prisma decorator and a no-op job
|
|
starter; they must not rely on ESM `vi.mock` intercepting a CJS `require`.
|
|
- App-level test doubles expose only methods used by the tested requests.
|
|
- `DATABASE_URL='' JWT_SECRET=''` must not make isolated app tests access a
|
|
developer `.env` or local PostgreSQL instance.
|
|
|
|
### 4. Validation & Error Matrix
|
|
|
|
- Production call with missing DB target -> normal startup refusal.
|
|
- Test call with injected Prisma plugin -> no production client construction.
|
|
- Test double missing a method exercised by the request -> focused unit failure.
|
|
- Injected dependencies leaking into `buildApp()` callers without options ->
|
|
contract regression.
|
|
|
|
### 5. Good/Base/Bad Cases
|
|
|
|
- Good: health/static tests inject Prisma and pass with empty DB/JWT process
|
|
variables after setting their own test JWT.
|
|
- Base: API/Worker call `buildApp()` without options and retain production wiring.
|
|
- Bad: a hoisted `vi.mock("../prismaClient")` appears green only because a local
|
|
`.env` database is reachable.
|
|
|
|
### 6. Tests Required
|
|
|
|
- Run health and static tests with `DATABASE_URL='' JWT_SECRET=''`.
|
|
- Run the full `scripts/check-minimal.sh` under the same empty environment.
|
|
- Keep the production default path covered by normal startup/smoke checks.
|
|
|
|
### 7. Wrong vs Correct
|
|
|
|
#### Wrong
|
|
|
|
```js
|
|
vi.mock("../../src/prismaClient", () => ({ createPrismaClient: fake }));
|
|
await buildApp(); // CJS plugin may still construct the real client
|
|
```
|
|
|
|
#### Correct
|
|
|
|
```js
|
|
await buildApp({ prismaPlugin: testPrismaPlugin, startJobs: noOpStartJobs });
|
|
```
|
|
|
|
## Scenario: Authentication Challenge Serialization
|
|
|
|
### 1. Scope / Trigger
|
|
|
|
- Trigger: changing password reset, email-code login, refresh rotation,
|
|
password login session creation, user password change or administrator reset.
|
|
- Applies when a credential event can consume a challenge, change
|
|
`token_version`, create/revoke a refresh session or invalidate another
|
|
challenge.
|
|
|
|
### 2. Signatures
|
|
|
|
- Row barrier: `SELECT ... FROM employees WHERE ... FOR UPDATE`.
|
|
- Challenge rows: `password_reset_codes` and `email_login_codes`.
|
|
- Session rows: `auth_sessions`.
|
|
- Security epoch: `employees.token_version`.
|
|
|
|
### 3. Contracts
|
|
|
|
- Use one lock order for credential mutations: Employee first, then challenge
|
|
and session rows. Refresh rotation must lock Employee and re-read the session
|
|
inside the same transaction before rotating it.
|
|
- Capture expiry, cooldown and lockout time after the Employee lock is acquired;
|
|
a request queued behind another credential event must not use stale time.
|
|
- Creating a new challenge invalidates the previous unused challenge while the
|
|
Employee lock is held. An active challenge with a wrong code increments the
|
|
account-level failure state; no active challenge returns the generic failure
|
|
without penalizing concurrent correct-request losers.
|
|
- Successful reset, user password change and administrator reset increment
|
|
`token_version`, revoke active refresh sessions and invalidate unused
|
|
challenges in the same transaction as the password change and audit event.
|
|
- Pepper-backed hashes use distinct purpose domains for email login and
|
|
password reset. Production rejects a missing, placeholder, weak or
|
|
JWT-reused pepper.
|
|
|
|
### 4. Validation & Error Matrix
|
|
|
|
- Unknown/inactive/locked account or missing/expired/consumed challenge ->
|
|
stable generic response; no account enumeration detail.
|
|
- Wrong code for the current active challenge -> increment account and
|
|
challenge attempts; reaching the bound locks and invalidates the challenge.
|
|
- Reset commits before login/refresh -> the queued request rechecks state and
|
|
cannot create a usable session.
|
|
- Login/refresh commits before reset -> reset observes and revokes the newly
|
|
created session before it commits.
|
|
- Concurrent consumers of one valid challenge -> exactly one successful state
|
|
transition; losers do not create audits, sessions or a recovery lock.
|
|
|
|
### 5. Good/Base/Bad Cases
|
|
|
|
- Good: every credential path uses Employee -> challenge/session ordering and
|
|
is covered by a deterministic PostgreSQL lock race.
|
|
- Base: sequential invalid-code attempts use the same account-level counters
|
|
and return the same external error contract.
|
|
- Bad: refresh locks a session before Employee while reset locks Employee
|
|
before sessions; this can leave a newly inserted session outside reset's
|
|
statement snapshot.
|
|
- Bad: treat `!challenge` after another request consumed the code as a new
|
|
failed guess and lock the legitimate account.
|
|
|
|
### 6. Tests Required
|
|
|
|
- Focused route tests for CSPRNG/HMAC domain separation, cooldown, account
|
|
lockout, missing-challenge losers, conditional consumption and session
|
|
revocation.
|
|
- Real isolated PostgreSQL checks for concurrent challenge requests, 20-way
|
|
same-code consumption, reset-first/login-first ordering and refresh/reset.
|
|
- After reset, assert the pre-reset access token and refresh cookie both fail,
|
|
the active-session count is zero and only one success audit exists.
|
|
- Prisma validate plus clean migration deployment from an empty database.
|
|
|
|
### 7. Wrong vs Correct
|
|
|
|
#### Wrong
|
|
|
|
```js
|
|
await tx.authSession.updateMany({ where: { id: sessionId, revoked_at: null } });
|
|
await tx.authSession.create({ data: replacement });
|
|
```
|
|
|
|
#### Correct
|
|
|
|
```js
|
|
await lockEmployeeForUpdate(tx, employeeId);
|
|
const currentSession = await tx.authSession.findUnique({ where: { id: sessionId } });
|
|
if (!currentSession || currentSession.revoked_at) return invalidRefresh();
|
|
await rotateSession(tx, currentSession);
|
|
```
|