Part 3 · Chapter 3.25
Milestone: the isolation gauntlet
You will produce: Three deliberate reintroductions — one of which stayed green and taught the suite's range — and tests for the instruments that had never produced output · about 13 minutes including the exercise
Source: SRS — Software Requirements Specification · SAD — Software Architecture Document
Nó đã từng bắt được gì chưa?
Một suite chưa bao giờ đỏ là một bài test chưa được test. Ba lần cố ý đưa lỗi trở lại, và lần đầu tiên chính là lý do phần này đáng đọc.
Lần đưa lại 1. Bỏ environment_id khỏi helper giới hạn phạm vi của listMessages —
một lệnh SELECT repository trên đường đọc history. Chạy gauntlet. Bảy mươi mốt trên
bảy mươi mốt đều xanh.
flowchart TB
attack["GET /v1/channels/:id/messages<br/>với channel id của tenant khác"]
attack --> exists["channelExists(id)<br/>ĐÃ SCOPE — từ chối ngay đây"]
exists --> four["404, y hệt một id không tồn tại"]
exists -. không bao giờ tới .-> list["listMessages(id)<br/>scope BỊ BỎ để thí nghiệm"]
list --> leak["lẽ ra đã trả về<br/>các row của tenant khác"]
four --> green["bộ test vẫn XANH.<br/>21 trên 21."]
green --> lesson["nhạy với lớp kiểm tra NGOÀI CÙNG;<br/>mù với lớp bên trong bị lớp ngoài còn sống che đi"]
style green fill:#78350f,color:#fff,stroke:#d97706
style lesson fill:#1e3a8a,color:#fff,stroke:#3b82f6
style leak fill:#7f1d1d,color:#fff,stroke:#dc2626Đó không phải một khiếm khuyết của lần đưa lại và nó không bị nắn lại cho tới khi nó đỏ.
Nó là một sự thật về tầm với của suite: GET /v1/channels/:channelId/messages từ chối
một id lạ trước cả khi listMessages được với tới.
Và lần thử thứ hai cũng chẳng tìm ra gì, và đó mới là cái câu đáng có.
Nước đi hiển nhiên kế tiếp là bỏ giới hạn của cái điều kiện phía trên nó — channelExists,
cái mà chương 2.8 thêm vào khi hai cánh cửa bất đồng. Bỏ luôn giới hạn của nó: vẫn
bảy mươi mốt trên bảy mươi mốt. channelExists không còn nằm trên đường này nữa.
Chương channel-control đã thay sự tồn tại bằng KHẢ NĂNG HIỂN THỊ, bởi một channel
vắng mặt thì trả lời 404 còn một channel riêng tư mà một người không phải thành viên đọc thì trả lời 200 với một
trang rỗng, và giờ một điều kiện duy nhất tạo ra cả hai lời từ chối.
Nên cái che mắt là channelVisibleTo, và nó có giới hạn phạm vi. Bỏ giới hạn của CÁI ĐÓ thì năm
khẳng định nổ cùng lúc:
× GET /v1/channels/:channelId/messages — a foreign channel reads as an absent one
× GET .../messages/:messageId/edits — a foreign message's history reads as an absent one
× PATCH .../messages/:messageId — a foreign message is not edited, and says so like an absent one
× DELETE .../messages/:messageId — a foreign message is not tombstoned
× POST /auth/dev-token — a token minted for one environment is refused by another
"status 200 (foreign) vs 404 (absent)"
"body {"messages":[{"id":"c80ae99d-…","user":"victim-mtv023g0-user",
"text":"victim-mtv023g0 says something"…}]} (foreign) vs
{"code":"not_found","message":"channel not found"…} (absent)"
Năm, không phải một. channelVisibleTo nằm dưới bốn route — lệnh đọc message, lệnh
sửa, lệnh xoá và lịch sử sửa — nên một lệnh SELECT không giới hạn là bốn chỗ rò. Cái
thứ năm là đòn tấn công credential, thứ mint một token trong environment của kẻ tấn công rồi
trình nó ra với channel của nạn nhân: SẤM TRUYỀN của nó là một lệnh đọc message, nên nó
thừa hưởng chỗ rò từ chính cái route nó dùng để dò.
Cái cuối đó đáng để ý. Một suite gồm các đòn tấn công độc lập thì không độc lập ở chỗ hai đòn dùng chung một mục tiêu, và con số các khẳng định đỏ là một con số đếm các khẳng định chứ không phải đếm các lỗi — ở đây có một lỗi, được báo theo năm cách.
Lần đưa lại 2 là chỗ mà hình dạng ghi kiếm được chỗ đứng của nó. Bỏ
environment_id khỏi một lệnh UPDATE — của setEndpointEnabled — và để mọi lệnh đọc vẫn
có giới hạn:
× POST /v1/webhooks/:id/disable — refused on a foreign endpoint, and nothing moves
AssertionError: the victim's endpoints moved: expected true to be false
differences vẫn rỗng. Endpoint trả lời 404 cho cả hai nửa của
cái cặp, bởi lệnh đọc sau lệnh cập nhật thì vẫn có giới hạn — nên response
không phân biệt được còn lệnh ghi thì vẫn đi qua. Một suite chỉ so các
response thì xanh với chuyện này.
Lần đưa lại 3 dạy hình dạng của cái lỗi chứ không phải xác nhận một phỏng đoán. Task nói "đổi một 404 của một endpoint thành một 403" — và làm thế ở tầng route thì dời cả hai nửa của cái cặp sang 403, thứ mà sấm truyền không thấy được theo cấu tạo. Để chúng khác nhau, code phải học được một thứ mà phạm vi tenant đang giấu:
// services/api/src/webhooks/webhooks.service.ts — DELIBERATELY UNTITLED.
// The fence chain replays every titled fence onto the repository, and this code
// was reverted the moment it was measured. A title here would put a
// reintroduction into the canonical tree, which is the one thing FR-015 forbids.
if (!row) {
// T066's REINTRODUCTION — reverted immediately after measuring.
if (await this.repo.anyEndpointExists(id)) {
throw new ForbiddenException("that endpoint belongs to another environment");
}
throw new NotFoundException("no such webhook endpoint");
}× POST /v1/webhooks/:id/enable — refused on a foreign endpoint, and nothing moves
× POST /v1/webhooks/:id/disable — refused on a foreign endpoint, and nothing moves
"status 403 (foreign) vs 404 (absent)"
Hai, bởi cả hai route là một phương thức: setEnabled(id, enabled) phục vụ cả lệnh bật
lẫn lệnh tắt, nên một lỗi cắm vào đó thì nhìn thấy được từ cánh cửa nào cũng được.
Phá vỡ tính không-phân-biệt-được thì cần một lệnh đọc không giới hạn. Đó là một câu hữu ích hơn "suite bắt được một cái 403", và nó ra đời từ việc lần đưa lại diễn ra khác với kế hoạch.
Mọi lần đưa lại đều được hoàn nguyên bằng git checkout trên một cái cây đã commit,
đó là cái thực hành mà dự án này viết ra và là lý do lần đầu tiên tốn
năm phút chứ không phải một buổi chiều.
Các cân đo chưa bao giờ cho ra kết quả
Coverage đặt một con số lên một thứ đọc lên như một nghịch lý. Ba file mới của chương này nằm thấp hơn hẳn phần còn lại, và mọi nhánh chưa phủ đều nằm trong code chỉ chạy khi nền tảng bị hỏng.
attack.ts đo được 61% nhánh bởi mọi khẳng định trong gauntlet đều khẳng định
rằng differences là rỗng — nên đoạn code dựng một chuỗi khác biệt chỉ chạy
khi có thứ gì đó đã rò. targets.ts đo được 50% bởi một ứng dụng Nest thật
có đúng một hình dạng router, nên các phương án dự phòng cho các hình dạng khác thì không với tới được ở đó.
catalogue.ts đo được 87.5% bởi cái nhánh trả về null — bảng chưa phân loại —
không chạy được với một database chẳng có bảng chưa phân loại nào, mà đó chính là trạng thái
mà phép kiểm tồn tại để giữ.
Đó chính là lập luận của chương này, hạ xuống một tầng: một cân đo chưa bao giờ cho ra kết quả thì chưa bao giờ có kết quả nào được đem kiểm.
import { afterEach, describe, expect, it, vi } from "vitest";
import { comparePair, credentialAttack, rowsOf } from "./attack";
// The oracle's REPORTING arm, which a passing gauntlet cannot reach.
//
// Every assertion in `gauntlet.itest.ts` asserts that `differences` is empty, so
// the code that builds a difference string only ever runs when the platform is
// broken. That is the same problem Phase 7's reintroductions solve for the suite
// as a whole, one layer down: an instrument that has never produced output has
// never had its output checked.
const answer = (status: number, body: unknown) => ({ status, body });
describe("comparing a foreign answer against an absent one", () => {
it("reports nothing when both agree", () => {
expect(comparePair(answer(404, { code: "not_found" }), answer(404, { code: "not_found" }))).toEqual(
[],
);
});
it("ignores request_id, which differs on every request by design", () => {
expect(
comparePair(
answer(404, { code: "not_found", request_id: "a" }),
answer(404, { code: "not_found", request_id: "b" }),
),
).toEqual([]);
});
it("names the statuses when they differ", () => {
const [first] = comparePair(answer(403, {}), answer(404, {}));
expect(first).toBe("status 403 (foreign) vs 404 (absent)");
});
it("names both bodies when they differ", () => {
const differences = comparePair(answer(404, { code: "forbidden" }), answer(404, { code: "not_found" }));
expect(differences).toHaveLength(1);
expect(differences[0]).toContain("forbidden");
expect(differences[0]).toContain("not_found");
});
it("reports both when both differ", () => {
expect(comparePair(answer(200, { messages: [1] }), answer(404, { code: "not_found" }))).toHaveLength(
2,
);
});
it("compares a non-object body without throwing", () => {
// `withoutRequestId` returns a non-object unchanged, and an html error page or
// an empty string is a real answer a misconfigured route can give.
expect(comparePair(answer(502, "bad gateway"), answer(404, ""))).toHaveLength(2);
});
});
describe("counting the rows in a list answer", () => {
it("reads a bare array", () => {
expect(rowsOf([1, 2, 3])).toEqual([1, 2, 3]);
});
it("reads a paginated envelope", () => {
expect(rowsOf({ data: [1, 2], next_cursor: "x" })).toEqual([1, 2]);
});
it("returns nothing for a shape it does not recognise", () => {
// The arm that matters. Zero rows from an unknown shape looks exactly like
// zero rows from a correctly-scoped list, and only one of those is a pass.
expect(rowsOf({ code: "not_found" })).toEqual([]);
expect(rowsOf(null)).toEqual([]);
expect(rowsOf("an html error page")).toEqual([]);
expect(rowsOf({ data: "not an array" })).toEqual([]);
});
});
describe("a credential attack that could not even mint", () => {
// THE ARM THAT REPORTS A FAILED MINT, which is the third instrument in this file
// that a healthy lane never reaches. `credentialAttack` asks the api for a token
// with the ATTACKER's own credential — a legitimate request that succeeds every
// time the platform is up — and only then presents it against the victim.
//
// WHY IT MATTERS THAT THIS ARM IS RIGHT. If a broken mint returned the shape of a
// success, every credential attack in the gauntlet would read `minted: true` and
// compare a token that does not exist against a route that refused it for the wrong
// reason. The suite would be green and would be asserting nothing — which is the
// failure this whole chapter is about, arriving through the instrument rather than
// through the platform.
//
// Driven with a stubbed `fetch`, because the only way to fail a mint against a live
// api is to break the api.
afterEach(() => {
vi.unstubAllGlobals();
});
it("reports the mint's own status and body, and does not claim a token", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
Promise.resolve({
ok: false,
status: 401,
json: async () => Promise.resolve({ code: "unauthorized" }),
}),
),
);
const verdict = await credentialAttack("http://unused.invalid", "rk_dev_bad", "mai", {
method: "GET",
path: "/v1/channels/whatever",
});
expect(verdict.minted, "a failed mint reported as a successful one").toBe(false);
expect(verdict.crossStatus).toBe(401);
expect(verdict.crossBody).toEqual({ code: "unauthorized" });
});
});import { describe, expect, it } from "vitest";
import { deriveTargets } from "./targets";
// THE DERIVATION'S SHAPE-HANDLING, driven with fakes.
//
// `targets.itest.ts` runs `deriveTargets` against a real Nest application, which
// is the assertion that matters — and a real application has exactly one router
// shape, so the fallbacks for the others are unreachable there. Express 4 exposed
// `_router`, Express 5 exposes `router`, and a future adapter may expose neither;
// the branch that reports `none` is the one a reader of a failure most needs to be
// working, because it is what turns "no routes found" into "no router found".
const route = (path: string, methods: Record<string, boolean>) => ({ route: { path, methods } });
describe("deriving targets from whatever the adapter exposes", () => {
it("reads Express 5's `router`", () => {
const result = deriveTargets({
router: { stack: [route("/v1/x", { get: true })] },
});
expect(result.property).toBe("router");
expect(result.targets).toEqual([{ method: "GET", path: "/v1/x" }]);
});
it("falls back to Express 4's `_router`", () => {
const result = deriveTargets({
_router: { stack: [route("/v1/y", { post: true })] },
});
expect(result.property).toBe("_router");
expect(result.targets).toEqual([{ method: "POST", path: "/v1/y" }]);
});
it("says `none` rather than pretending the surface is empty", () => {
// The distinction the suite depends on: an empty target list from a found
// router is a clean surface, and an empty list from no router at all is a
// broken derivation. Only this field tells them apart.
const result = deriveTargets({});
expect(result.property).toBe("none");
expect(result.targets).toEqual([]);
});
it("counts middleware layers, which have no route", () => {
const result = deriveTargets({
router: { stack: [{}, {}, route("/v1/z", { delete: true })] },
});
expect(result.middlewareLayers).toBe(2);
expect(result.targets).toEqual([{ method: "DELETE", path: "/v1/z" }]);
});
it("takes only the verbs a layer actually answers", () => {
// Express marks every verb on the layer and flags the live ones. Treating the
// keys as the answer would invent a target per HTTP verb per route.
const result = deriveTargets({
router: { stack: [route("/v1/w", { get: true, post: false, put: false })] },
});
expect(result.targets).toEqual([{ method: "GET", path: "/v1/w" }]);
});
it("handles a layer with a route but no methods, and one with no path", () => {
const result = deriveTargets({
router: { stack: [{ route: { path: "/v1/none" } }, { route: { methods: { get: true } } }] },
});
expect(result.targets).toEqual([{ method: "GET", path: "" }]);
});
});import { describe, expect, it } from "vitest";
import { classifyRow, SPINE_TABLES } from "./catalogue";
// The classification's four arms, driven with rows made up here rather than with
// rows a database happens to hold (FR-040).
//
// The one that matters is `null`. It executes only when somebody adds a table with
// no tenant path — which is the state `tenant-scope.itest.ts` exists to prevent —
// so against a healthy database it is the one arm nothing can reach. T042 proved it
// fires by creating a scratch table by hand; this proves it without a database and
// without a hand.
const row = (over: Partial<Parameters<typeof classifyRow>[0]>) => ({
table_name: "made_up",
has_environment_id: false,
fk_targets: null,
...over,
});
describe("a table's tenant path", () => {
it("is direct when it carries environment_id", () => {
expect(classifyRow(row({ has_environment_id: true }))).toEqual({
table: "made_up",
path: "direct",
via: [],
});
});
it("is a hop when a foreign key reaches a direct table", () => {
expect(classifyRow(row({ fk_targets: ["channels", "users"] }))).toEqual({
table: "made_up",
path: "hop",
via: ["channels", "users"],
});
});
it("prefers direct over hop when a table has both", () => {
// The ordering the function's own comment argues for: a spine table that
// gains `environment_id` should report `direct` so its SPINE entry becomes
// visibly wrong rather than quietly ignored.
const both = classifyRow(row({ has_environment_id: true, fk_targets: ["channels"] }));
expect(both.path).toBe("direct");
});
it("is spine for a listed table, and carries its reason", () => {
const result = classifyRow(row({ table_name: "organisations" }));
expect(result.path).toBe("spine");
expect(result.reason).toBeTruthy();
});
it("IS NULL for a table with no path at all", () => {
expect(classifyRow(row({ table_name: "t042_scratch" }))).toEqual({
table: "t042_scratch",
path: null,
via: [],
});
});
it("gives every spine table a reason", () => {
for (const name of SPINE_TABLES) {
expect(classifyRow(row({ table_name: name })).reason, name).toBeTruthy();
}
});
});import { describe, expect, it } from "vitest";
import { withoutRequestId } from "./compare";
// The oracle's own tests. Pure — no database, no HTTP — because the thing being
// checked is one comparison rule, and a rule that needs a stack to test is a rule
// nobody re-reads.
describe("withoutRequestId", () => {
it("makes two bodies differing only in request_id equal", () => {
const foreign = {
code: "not_found",
message: "channel not found",
docs_url: "https://relay.example/docs/errors/not_found",
request_id: "req_aaa",
};
const absent = { ...foreign, request_id: "req_bbb" };
expect(withoutRequestId(foreign)).toEqual(withoutRequestId(absent));
});
it("leaves two bodies differing in message unequal", () => {
// The case that matters: `messages.service.ts` keeps a CONSTANT message for
// exactly this reason — echoing the id back would make the foreign answer
// differ from the absent one, and "different" is itself a disclosure.
const foreign = { code: "not_found", message: "channel abc not found", request_id: "req_a" };
const absent = { code: "not_found", message: "channel not found", request_id: "req_b" };
expect(withoutRequestId(foreign)).not.toEqual(withoutRequestId(absent));
});
it("leaves two bodies differing in code unequal", () => {
const forbidden = { code: "forbidden", message: "no", request_id: "req_a" };
const missing = { code: "not_found", message: "no", request_id: "req_b" };
expect(withoutRequestId(forbidden)).not.toEqual(withoutRequestId(missing));
});
it("passes a non-object body through untouched", () => {
// A 204 has no body and a proxy may hand back a string. Neither is an error
// envelope, and neither should throw on the way to a comparison.
expect(withoutRequestId(null)).toBeNull();
expect(withoutRequestId("gateway timeout")).toBe("gateway timeout");
expect(withoutRequestId(undefined)).toBeUndefined();
});
it("does not mutate its argument", () => {
const body = { code: "not_found", request_id: "req_a" };
withoutRequestId(body);
expect(body.request_id).toBe("req_a");
});
});Đóng chúng lại nghĩa là tách quyết định khỏi phần vận chuyển ở ba chỗ —
classifyRow khỏi câu query danh mục, comparePair và rowsOf khỏi lời gọi
HTTP. Cả ba đều đạt 100% nhánh sau đó, và chính sự tách rời ấy mới là phát hiện
chứ không phải con số.
Hai nhánh vẫn chưa được phủ và chúng được gọi tên chứ không bị đuổi theo: nhánh body-rỗng của send
và nhánh mint-thất-bại của credentialAttack, cả hai đều cần một bản HTTP giả trong
một file mà chủ đề là HTTP thật.
Một quy tắc, và một khối sau đó đã thay thế nó
Các cân đo ở trên là các bài test. Cái này là một linter, và nó có đúng cái kiểu thất bại mà chương này nói về: một quy tắc trông như đang được thực thi mà thực ra không.
no-restricted-imports cấm import driver, query engine và kho đếm
bên ngoài các tầng sở hữu chúng — hiến pháp I dưới dạng một lỗi build
chứ không phải một quy ước. Phần hạn chế rút-toàn-cục muốn cùng cách đối xử ấy: sáu
hàm repository giành hoặc đếm các dòng xuyên mọi environment, thứ mà một
bài test tích hợp không được với tay tới.
Cả hai đều là cùng một quy tắc. Trong flat config thì một khối sau sẽ THAY THẾ thiết lập của một khối trước
cho một quy tắc chứ không hợp nhất vào nó, và khối thứ hai phải khớp
**/*.itest.ts — mà mọi file như thế đều đã được khối thứ nhất khớp dưới dạng **/*.ts.
Nên thêm phần hạn chế rút theo cách hiển nhiên thì tắt lệnh cấm driver cho
mọi bài test tích hợp trong workspace, và chẳng gì nói ra điều đó: cấu hình parse được, lệnh
lint xanh, còn lệnh cấm thì biến mất.
Đo trước khi nó được viết, trên một file chẳng nằm trong danh sách miễn trừ nào:
services/api/src/channels/channels.itest.ts, with `import { sql } from "drizzle-orm"`
one block error 'drizzle-orm' import is restricted no-restricted-imports
two blocks error 'sql' is defined but never used @typescript-eslint/no-unused-vars
Lần chạy thứ hai chính là toàn bộ phát hiện. Lệnh import engine vẫn còn đó, vẫn bị một quy tắc mà cấu hình vẫn chứa cấm, và lời phàn nàn duy nhất còn lại là rằng cái biến không được dùng.
Bốn cái tập được gọi tên và nhấc lên để các khối ghép được chúng, và mỗi khối đặt quy tắc thành đúng cái hợp nó cần chứ không phải một nửa của nó:
@@ -1,11 +1,277 @@
import eslint from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
// One lint config for the whole workspace (ADR-01's consequence made literal).
+// ── THE RESTRICTION SETS, HOISTED SO THEY CAN BE COMPOSED ───────────────────────
+//
+// `no-restricted-imports` is ONE rule, and in flat config a later block REPLACES an
+// earlier block's setting for it rather than merging. Everything below exists because
+// of that sentence: a second block matching `**/*.itest.ts` — every one of which the
+// `**/*.ts` block already matched — switches the first block's rule OFF for every
+// integration test in the workspace, silently.
+//
+// MEASURED BEFORE IT WAS WRITTEN. `services/api/src/channels/channels.itest.ts` is on
+// no exemption list. Given `import { sql } from "drizzle-orm"` it fails lint under a
+// single block, and under a naively-added second block the only error left is
+// `'sql' is defined but never used`.
+const DRIVER_AND_ENGINE = {
+ paths: [
+ {
+ name: "pg",
+ message:
+ "Raw database access is forbidden outside services/api/src/db (constitution I).",
+ },
+ {
+ name: "drizzle-orm",
+ message:
+ "The query engine lives inside the repository layer only (constitution I, ADR-16).",
+ },
+ {
+ name: "ioredis",
+ message:
+ "The counter store lives in services/api/src/limits and services/gateway/src/limits.ts only (constitution I). Its keys are per environment; an unrestricted client is a cross-tenant read.",
+ },
+ ],
+ patterns: [
+ {
+ group: ["drizzle-orm/*"],
+ message:
+ "The query engine lives inside the repository layer only (constitution I, ADR-16).",
+ },
+ ],
+};
+
+// The paths excused from the driver and the engine. Two data-access LAYERS as
+// directories and everything else by path, each with the argument it needs.
+const DRIVER_EXEMPT = [
+ "services/api/src/db/**",
+ "services/api/src/limits/**",
+ // DRIVER_EXEMPT — every path below is exempt from all three restricted modules,
+ // the driver's name on the marker notwithstanding: `driver-exempt.test.ts` reads
+ // the module names out of the rule, so this list governs whatever the rule names.
+ //
+ // First the lane's own infrastructure. Reasons, one per path:
+ // global-setup.ts installs the guard against a database vitest names
+ // setup.ts rewrites the connection string to carry the exemption
+ // guard.itest.ts holds one exempt client and one plain one, and the
+ // difference between them is the whole test
+ "packages/test-harness/src/global-setup.ts",
+ "packages/test-harness/src/setup.ts",
+ "packages/test-harness/src/guard.itest.ts",
+ // AND THE LANE RESET'S OWN TEST, which arrives with the table it clears. It asserts
+ // what the SCRIPT DID — a planted stale delivery gone, an organisation count unmoved
+ // — and both are facts about rows the script reached through its own connection.
+ // Going through the repository layer would mean asserting the script's effect against
+ // the code the script does not use.
+ "packages/test-harness/src/reset-lane.itest.ts",
+ // AND TWO SUITES THAT WRITE A ROW THE TYPE SYSTEM FORBIDS.
+ //
+ // backfill.itest.ts asserts what `toFrame` does with a SENDERLESS message.
+ // Those rows exist — every one written through the socket
+ // before the sender was threaded looks like this — and the
+ // repository can no longer produce one, because `userId` is
+ // required. The fixture has to be raw SQL or the behaviour
+ // has no test at all.
+ // history.itest.ts reads the same row from the other end: a page whose
+ // `user` comes back null. FR-MSG-15 made `sendMessage`
+ // require a sender, so this suite lost the ability to build
+ // its own fixture in the same change that gave it the case.
+ //
+ // This is the exemption's honest case: not "the repository is inconvenient" but
+ // "the state under test is one the repository is now unable to reach". Both are
+ // listed by path rather than reached through a shared helper, because a helper in
+ // another file names none of these specifiers and this rule sees only imports —
+ // an invisible exemption is worse than a listed one.
+ "services/api/src/internal/backfill.itest.ts",
+ "services/api/src/messages/history.itest.ts",
+ // THE QUOTA CHAPTER'S PERIOD SUITE, and its case is the two above's in a third
+ // shape: the state under test is one the repository cannot reach. `periodOf`
+ // returns the month a timestamp falls in, and the property is that a row INSERTED
+ // under that value is FOUND by it — which needs a `usage_periods` row written
+ // directly, because every repository path that writes one derives the period from
+ // the clock and so cannot disagree with the function under test.
+ //
+ // A suite that used the repository here would be asserting that `periodOf` equals
+ // itself.
+ "services/api/src/quotas/period.itest.ts",
+ // AND THE QUOTA SUITE ITSELF, for a different reason from its sibling above.
+ // `period.itest.ts` writes a row the repository cannot; this one READS the two
+ // roll-up tables directly to check what a send left behind. Going through
+ // `usageFor` would mean asserting the roll-up against the function that reads
+ // it — the same circularity, one table over.
+ "services/api/src/quotas/quotas.itest.ts",
+ // AND THE CONNECTION-METERING CHAPTER'S, WHICH MAKES THE SAME CLAIM ONE
+ // DIMENSION OVER: a credited minute survives a `FLUSHALL` of the counter store,
+ // because a quota is about THIS MONTH and the rate limiter's store is allowed to
+ // lose things. Proving that needs the flush, and the flush needs a raw client.
+ //
+ // LISTED RATHER THAN DODGED. Published's version reached for
+ // `await import("ioredis")` inside the test, which this rule cannot see — an
+ // exemption that is invisible, which the note at the top of this block calls
+ // worse than a listed one. The static import puts it back under the rule and
+ // this entry is the answer.
+ "services/api/src/quotas/connections.itest.ts",
+ // ── AND EVERY OTHER REDIS CLIENT, BY PATH, WITH THE ARGUMENT IT NEEDS ──
+ //
+ // The rule arrives here and TWELVE files older than it already import `ioredis`.
+ // A missing exemption is not a silent one — this rule goes red on a chapter
+ // nobody is editing — so all of them land in the commit that adds the rule.
+ // FIVE DIFFERENT ARGUMENTS, and a blanket "the gateway's Redis files" would
+ // erase all five distinctions the rule exists to make.
+ //
+ // (1) NO KEY IS TOUCHED AT ALL — these name a pub/sub SUBJECT and never a key,
+ // which is the property, not whether they publish or subscribe. The subjects are
+ // `chan:{channel_id}`, `member:{channel_id}` and `typing:{channel_id}`: a channel
+ // UUID, and a subject is not readable at all, only listened to by whoever already
+ // subscribed. There is no key here for a cross-tenant read to reach. (The api's
+ // two publishers publish; the gateway's `membership.ts` only ever subscribes,
+ // because the api publishes that fabric — and the argument is the same either
+ // way.)
+ "services/api/src/fanout/publisher.ts",
+ "services/api/src/membership/publisher.ts",
+ //
+ // (2) THE COUNTER STORE'S OTHER HALF. `rl:{environment_id}:…` is the key shape
+ // the whole restriction is about, and this file composes it — so it is exempt as
+ // the rule's own subject, not against its reason. `limits.itest.ts` is listed
+ // beside it for something the rule cannot express at all: its subject is that
+ // the api and the gateway increment the SAME key, and the only way to check that
+ // is to read the key with NEITHER of their code.
+ "services/gateway/src/limits.ts",
+ "services/gateway/src/limits.itest.ts",
+ "services/gateway/src/fanout.ts",
+ // `member:{env}:{user}` — the principal-addressed half of that fabric — DOES
+ // carry an environment id, and that still does not make it the limiter's case:
+ // a subject is not readable, and the id is composed from the repository's own
+ // scope on the way out and from the authenticated connection's identity on the
+ // way in, never read from a payload.
+ "services/gateway/src/membership.ts",
+ // `typing.ts` both publishes and subscribes and composes no key at all — the
+ // environment travels INSIDE the payload, where the receiving gateway checks it
+ // against the connection it is about to act on.
+ "services/gateway/src/typing.ts",
+ //
+ // (2) KEYS ARE COMPOSED AND THEY ARE ENVIRONMENT-SCOPED — the limiter's own
+ // argument rather than the publishers'. `presence:{env}:{user}` is exactly the
+ // shape the restriction guards. Every key is composed from the environment id on
+ // the authenticated connection's own identity; no path takes one from a client,
+ // and there is no scan, `KEYS` or pattern read that could reach another tenant's.
+ "services/gateway/src/presence.ts",
+ //
+ // (3) THE ENVIRONMENT COMES FIRST IN THE KEY, which is the strongest case on
+ // this list rather than the weakest. `conn:{env}:{user}:{slot}` makes
+ // constitution I structural in the key itself: reaching across a tenant needs a
+ // caller to hand this module another environment's id, and the session layer
+ // takes that from the api's verified identity. The other entries argue about
+ // what they touch; this one cannot be wrong without being lied to.
+ "services/gateway/src/connections.ts",
+ //
+ // (4) THE SUBJECT IS WHAT REACHES THE FABRIC, so the oracle cannot be either
+ // service's own client. A spy on `createFanout` or on `createPresence` proves
+ // that an object was asked to publish, not that a frame arrived — and these
+ // suites' receive halves have rejection paths (a body that is not JSON, a body
+ // that is JSON and not a transition) that no module-level API can produce,
+ // because each only ever publishes payloads its own schema built.
+ "services/api/src/fanout/fanout.itest.ts",
+ "services/gateway/src/presence.itest.ts",
+ "services/gateway/src/membership.itest.ts",
+ "services/gateway/src/typing.itest.ts",
+ //
+ // (5) THE RAW CLIENT IS THE STIMULUS, NOT THE ORACLE — a fifth reason, and the
+ // rule cannot express it. This suite's subject is delivery and it asserts on
+ // sockets. It needs a client to CAUSE a membership change: `Membership` exposes
+ // `onChange`, `subscribeChannel` and `watch` and no `publish`, because the api
+ // publishes and the gateway only ever subscribes.
+ "services/gateway/src/connections.itest.ts",
+ //
+ // `services/gateway/src/connections.test.ts` IS DELIBERATELY ABSENT, and the
+ // ledger that owed these entries said to add it. It reads the module's own
+ // source off disk and imports nothing restricted, so the exemption would be one
+ // over nothing — and `driver-exempt.test.ts`'s stale-entry check is the half of
+ // this list that goes red when a listed file stops needing it.
+];
+
+// The suites that drive a global drain on purpose — derived by asking, not by
+// remembering: these are exactly the `*.itest.ts` files in this tree that import one of
+// the functions `GLOBAL_DRAINS` names, and `drain-exempt.test.ts` asserts that in both
+// directions against the tree rather than against a second list.
+const DRAIN_EXEMPT_TESTS = [
+ // `outboxDepth` — the relay's whole subject IS a global drain.
+ "services/api/src/outbox/outbox.itest.ts",
+ // `drainDueDeliveries`, `sweepDisabledEndpoints`, `pendingDeliveryDepth`.
+ "services/api/src/webhooks/deliveries.itest.ts",
+ // `drainDueDeliveries`.
+ "services/api/src/webhooks/attempts.itest.ts",
+ //
+ // THREE, AND PUBLISHED'S LIST IS SIX. `test-event.itest.ts`,
+ // `notifications.itest.ts` and `dispatcher.itest.ts` are on it there and import
+ // nothing restricted HERE: two name a drain only in prose explaining why they do not
+ // call one, and the dispatcher's suite declares `drainDueDeliveries` as a property on
+ // a stub it builds. Listing them would be three standing exemptions over nothing on
+ // the list's first day — the failure mode this file's own note calls out, arriving
+ // by inheritance rather than by drift.
+ //
+ // `drain-exempt.test.ts` found all three, which is the only reason the list is three
+ // long. It reads the names out of `DRAIN_NAMES` below and asserts both directions
+ // against the TREE.
+];
+
+/** The six functions, and the two counts that cannot be bounded. Named once so the
+ * two specifier spellings below cannot drift apart. */
+const DRAIN_NAMES = [
+ "drainOutbox",
+ "drainDueDeliveries",
+ "drainDisableNotifications",
+ "drainQuotaNotifications",
+ "sweepDisabledEndpoints",
+ "outboxDepth",
+ "pendingDeliveryDepth",
+];
+
+const DRAIN_MESSAGE =
+ "This claims or counts rows across EVERY environment. In an integration test that " +
+ "is a local assertion about a global operation, or a global operation over a " +
+ "neighbour's fixture. Scope the assertion to rows this test created, or add the " +
+ "suite to DRAIN_EXEMPT_TESTS with its reason.";
+
+// THE GLOBAL ADMIN FUNCTIONS, RESTRICTED IN INTEGRATION TESTS.
+//
+// Six recorded instances of one fault: a test asserts a local fact about a global
+// operation, or performs one and damages a neighbour's fixture. Each imported one of
+// these and called it as though the database held only its own rows.
+//
+// The two `*Depth` functions are here for a different reason from the other five. They
+// take no batch size and cannot — a count has nothing to bound — and a global count
+// compared against itself is the instance that appeared twice in one file, four
+// chapters apart.
+//
+// WHAT THIS DOES NOT CATCH, and must not be trusted to: an indirect call — a helper in
+// another file that calls the function, imported here under an innocent name — and raw
+// SQL, which names no import at all. Both are the sentinel trigger's job; it watches
+// STATEMENTS rather than imports. A rule trusted further than it goes is worse than no
+// rule.
+//
+// BOTH SPELLINGS, because `no-restricted-imports` matches the specifier as WRITTEN.
+// `../db/repository` and `./repository` are two rules, and the second is the one a
+// suite inside `services/api/src/db/` would use.
+const GLOBAL_DRAINS = {
+ paths: [
+ {
+ name: "../db/repository",
+ importNames: DRAIN_NAMES,
+ message: DRAIN_MESSAGE,
+ },
+ {
+ name: "./repository",
+ importNames: DRAIN_NAMES,
+ message: DRAIN_MESSAGE,
+ },
+ ],
+};
+
export default tseslint.config(
{ ignores: ["**/node_modules/**", "**/dist/**", "**/coverage/**"] },
eslint.configs.recommended,
...tseslint.configs.recommended,
{
// Dev scripts run on Node directly, outside any package's tsconfig —
@@ -39,168 +305,48 @@
// file that imports the driver; nothing here can catch a LISTED file that stopped
// importing it, so the list can only grow and a stale entry holds a standing
// exemption forever. `driver-exempt.test.ts` reads this array and asserts each
// path exists and still imports a module the rule below restricts — with those
// module names read out of the rule rather than restated.
files: ["**/*.ts"],
- ignores: [
- "services/api/src/db/**",
- "services/api/src/limits/**",
- // DRIVER_EXEMPT — every path below is exempt from all three restricted modules,
- // the driver's name on the marker notwithstanding: `driver-exempt.test.ts` reads
- // the module names out of the rule, so this list governs whatever the rule names.
- //
- // First the lane's own infrastructure. Reasons, one per path:
- // global-setup.ts installs the guard against a database vitest names
- // setup.ts rewrites the connection string to carry the exemption
- // guard.itest.ts holds one exempt client and one plain one, and the
- // difference between them is the whole test
- "packages/test-harness/src/global-setup.ts",
- "packages/test-harness/src/setup.ts",
- "packages/test-harness/src/guard.itest.ts",
- // AND TWO SUITES THAT WRITE A ROW THE TYPE SYSTEM FORBIDS.
- //
- // backfill.itest.ts asserts what `toFrame` does with a SENDERLESS message.
- // Those rows exist — every one written through the socket
- // before the sender was threaded looks like this — and the
- // repository can no longer produce one, because `userId` is
- // required. The fixture has to be raw SQL or the behaviour
- // has no test at all.
- // history.itest.ts reads the same row from the other end: a page whose
- // `user` comes back null. FR-MSG-15 made `sendMessage`
- // require a sender, so this suite lost the ability to build
- // its own fixture in the same change that gave it the case.
- //
- // This is the exemption's honest case: not "the repository is inconvenient" but
- // "the state under test is one the repository is now unable to reach". Both are
- // listed by path rather than reached through a shared helper, because a helper in
- // another file names none of these specifiers and this rule sees only imports —
- // an invisible exemption is worse than a listed one.
- "services/api/src/internal/backfill.itest.ts",
- "services/api/src/messages/history.itest.ts",
- // THE QUOTA CHAPTER'S PERIOD SUITE, and its case is the two above's in a third
- // shape: the state under test is one the repository cannot reach. `periodOf`
- // returns the month a timestamp falls in, and the property is that a row INSERTED
- // under that value is FOUND by it — which needs a `usage_periods` row written
- // directly, because every repository path that writes one derives the period from
- // the clock and so cannot disagree with the function under test.
- //
- // A suite that used the repository here would be asserting that `periodOf` equals
- // itself.
- "services/api/src/quotas/period.itest.ts",
- // AND THE QUOTA SUITE ITSELF, for a different reason from its sibling above.
- // `period.itest.ts` writes a row the repository cannot; this one READS the two
- // roll-up tables directly to check what a send left behind. Going through
- // `usageFor` would mean asserting the roll-up against the function that reads
- // it — the same circularity, one table over.
- "services/api/src/quotas/quotas.itest.ts",
- // ── AND EVERY OTHER REDIS CLIENT, BY PATH, WITH THE ARGUMENT IT NEEDS ──
- //
- // The rule arrives here and TWELVE files older than it already import `ioredis`.
- // A missing exemption is not a silent one — this rule goes red on a chapter
- // nobody is editing — so all of them land in the commit that adds the rule.
- // FIVE DIFFERENT ARGUMENTS, and a blanket "the gateway's Redis files" would
- // erase all five distinctions the rule exists to make.
- //
- // (1) NO KEY IS TOUCHED AT ALL — these name a pub/sub SUBJECT and never a key,
- // which is the property, not whether they publish or subscribe. The subjects are
- // `chan:{channel_id}`, `member:{channel_id}` and `typing:{channel_id}`: a channel
- // UUID, and a subject is not readable at all, only listened to by whoever already
- // subscribed. There is no key here for a cross-tenant read to reach. (The api's
- // two publishers publish; the gateway's `membership.ts` only ever subscribes,
- // because the api publishes that fabric — and the argument is the same either
- // way.)
- "services/api/src/fanout/publisher.ts",
- "services/api/src/membership/publisher.ts",
- //
- // (2) THE COUNTER STORE'S OTHER HALF. `rl:{environment_id}:…` is the key shape
- // the whole restriction is about, and this file composes it — so it is exempt as
- // the rule's own subject, not against its reason. `limits.itest.ts` is listed
- // beside it for something the rule cannot express at all: its subject is that
- // the api and the gateway increment the SAME key, and the only way to check that
- // is to read the key with NEITHER of their code.
- "services/gateway/src/limits.ts",
- "services/gateway/src/limits.itest.ts",
- "services/gateway/src/fanout.ts",
- // `member:{env}:{user}` — the principal-addressed half of that fabric — DOES
- // carry an environment id, and that still does not make it the limiter's case:
- // a subject is not readable, and the id is composed from the repository's own
- // scope on the way out and from the authenticated connection's identity on the
- // way in, never read from a payload.
- "services/gateway/src/membership.ts",
- // `typing.ts` both publishes and subscribes and composes no key at all — the
- // environment travels INSIDE the payload, where the receiving gateway checks it
- // against the connection it is about to act on.
- "services/gateway/src/typing.ts",
- //
- // (2) KEYS ARE COMPOSED AND THEY ARE ENVIRONMENT-SCOPED — the limiter's own
- // argument rather than the publishers'. `presence:{env}:{user}` is exactly the
- // shape the restriction guards. Every key is composed from the environment id on
- // the authenticated connection's own identity; no path takes one from a client,
- // and there is no scan, `KEYS` or pattern read that could reach another tenant's.
- "services/gateway/src/presence.ts",
- //
- // (3) THE ENVIRONMENT COMES FIRST IN THE KEY, which is the strongest case on
- // this list rather than the weakest. `conn:{env}:{user}:{slot}` makes
- // constitution I structural in the key itself: reaching across a tenant needs a
- // caller to hand this module another environment's id, and the session layer
- // takes that from the api's verified identity. The other entries argue about
- // what they touch; this one cannot be wrong without being lied to.
- "services/gateway/src/connections.ts",
- //
- // (4) THE SUBJECT IS WHAT REACHES THE FABRIC, so the oracle cannot be either
- // service's own client. A spy on `createFanout` or on `createPresence` proves
- // that an object was asked to publish, not that a frame arrived — and these
- // suites' receive halves have rejection paths (a body that is not JSON, a body
- // that is JSON and not a transition) that no module-level API can produce,
- // because each only ever publishes payloads its own schema built.
- "services/api/src/fanout/fanout.itest.ts",
- "services/gateway/src/presence.itest.ts",
- "services/gateway/src/membership.itest.ts",
- "services/gateway/src/typing.itest.ts",
- //
- // (5) THE RAW CLIENT IS THE STIMULUS, NOT THE ORACLE — a fifth reason, and the
- // rule cannot express it. This suite's subject is delivery and it asserts on
- // sockets. It needs a client to CAUSE a membership change: `Membership` exposes
- // `onChange`, `subscribeChannel` and `watch` and no `publish`, because the api
- // publishes and the gateway only ever subscribes.
- "services/gateway/src/connections.itest.ts",
- //
- // `services/gateway/src/connections.test.ts` IS DELIBERATELY ABSENT, and the
- // ledger that owed these entries said to add it. It reads the module's own
- // source off disk and imports nothing restricted, so the exemption would be one
- // over nothing — and `driver-exempt.test.ts`'s stale-entry check is the half of
- // this list that goes red when a listed file stops needing it.
- ],
+ ignores: DRIVER_EXEMPT,
+
+ rules: {
+ "no-restricted-imports": ["error", DRIVER_AND_ENGINE],
+ },
+ },
+ {
+ // ── AND THE UNION, WHICH IS THE WHOLE REASON THE SETS ARE NAMED ─────────────
+ //
+ // Every `*.itest.ts` the block above matched as `**/*.ts` is matched again here, so
+ // this rule must be the UNION or the driver ban is switched off for all of them.
+ // The two exemption lists are ignored here and given their own single rule below,
+ // because `ignores` on the FIRST block cannot reach a rule the SECOND one sets.
+ files: ["**/*.itest.ts"],
+ ignores: [...DRAIN_EXEMPT_TESTS, ...DRIVER_EXEMPT],
rules: {
"no-restricted-imports": [
"error",
{
- paths: [
- {
- name: "pg",
- message:
- "Raw database access is forbidden outside services/api/src/db (constitution I).",
- },
- {
- name: "drizzle-orm",
- message:
- "The query engine lives inside the repository layer only (constitution I, ADR-16).",
- },
- {
- name: "ioredis",
- message:
- "The counter store lives in services/api/src/limits and services/gateway/src/limits.ts only (constitution I). Its keys are per environment; an unrestricted client is a cross-tenant read.",
- },
- ],
- patterns: [
- {
- group: ["drizzle-orm/*"],
- message:
- "The query engine lives inside the repository layer only (constitution I, ADR-16).",
- },
- ],
+ paths: [...DRIVER_AND_ENGINE.paths, ...GLOBAL_DRAINS.paths],
+ patterns: DRIVER_AND_ENGINE.patterns,
},
],
},
},
+ {
+ // The driver-exempt paths keep their exemption and gain the drain rule. Without
+ // this block the union above would restore the ban they were excused from.
+ files: DRIVER_EXEMPT,
+ rules: {
+ "no-restricted-imports": ["error", GLOBAL_DRAINS],
+ },
+ },
+ {
+ // And the drain-exempt suites get the driver rule alone. They drive a global drain
+ // on purpose; they are excused from nothing else.
+ files: DRAIN_EXEMPT_TESTS,
+ rules: {
+ "no-restricted-imports": ["error", DRIVER_AND_ENGINE],
+ },
+ },
);Sáu phép dò, bởi một quy tắc được ghép có sáu hướng để sai và một cấu hình xanh trên chính cái cây của nó thì chẳng chứng minh được hướng nào:
| file | lệnh import | kỳ vọng |
|---|---|---|
một .itest.ts thường | drizzle-orm | bị cấm |
| một suite được miễn drain | drizzle-orm | bị cấm |
| một suite được miễn driver | drainOutbox | bị cấm |
một .itest.ts thường | outboxDepth | bị cấm |
| một suite được miễn drain | lệnh drain của chính nó | được phép |
| cái cây như nó đang là | — | sạch |
Và danh sách miễn trừ tới nơi đã cũ. Sáu suite được miễn khỏi quy tắc
rút; drain-exempt.test.ts đọc các tên bị hạn chế ra từ chính quy tắc rồi
khẳng định cả hai hướng đối chiếu với cái cây, và nó tìm ra rằng ba trong sáu suite chẳng
import thứ gì bị hạn chế ở đây — hai suite chỉ gọi tên một lệnh drain trong văn xuôi giải thích vì sao chúng gọi
thứ khác thay thế, còn suite thứ ba gọi tên nó như một thuộc tính trên một stub nó dựng. Ba
phần miễn trừ thường trực trên hư không, ngay ngày đầu tiên của danh sách.
Đó là cái hình dạng mà phần còn lại của chương này nói về, tới nơi qua cái cân đo chứ không qua nền tảng: một danh sách chép từ nơi nó đúng là một danh sách chẳng ai kiểm ở đây.
Thứ mà suite không phủ
Chính suite nói ra điều đó, ngay đầu cái file mà một người đọc gặp đầu tiên:
// TIMING. A foreign id answering in 3 ms and an absent id in 30 ms is a disclosure
// this suite cannot see. Measuring that stably in CI is a different discipline and
// is not attempted here; the chapter names it as unaddressed rather than implying
// it is covered.
//
// A LEAKED PLATFORM CREDENTIAL. FR-044 narrowed which routes each internal service
// may call, and that is all it did. There is no rotation, and `service` is
// self-reported by which variable matched — so the change shrinks the blast radius
// of a leak and does not make one survivable.
//
// MESSAGE CONTENT BEYOND EQUALITY. The pair proves the two answers match. It does
// not prove that what they say is wise: a constant message leaking a schema detail
// would pass every assertion below.Các khẳng định rải rác vẫn còn đó, và một trong số chúng sắc hơn trước — dù không phải
nhờ bất cứ thứ gì chương này làm. Khẳng định về membership của repository.itest.ts từng
đọc ra false cho ba lời từ chối khác nhau: channel không phải của bạn, user không phải của bạn,
và bạn hỏi hai lần. Trộn lẫn hai cái đầu chính là cái tính chất; trộn cái thứ ba với
chúng là một bug đang chờ một endpoint — và chương endpoints đã trả lời nó mười bảy
chương trước, và đó là lý do khẳng định ấy tới đây với ba chữ thay vì
một boolean.
Cái endpoint cần sự phân biệt đó là của chương endpoints, và nó
đã được dựng — chương ấy giờ chạy trước chương này tám movement. Nửa
về sự cô lập của nó thì thuộc về đây, bởi việc not_found phủ cả hai trường hợp lạ
chính là tính chất mà FR-TEN-05 đòi hỏi và giờ nó được viết ra thành một từ chứ không phải
một boolean mà ai đó phải diễn giải.
Một tuyến phòng thủ được tin vượt quá tầm với của nó thì tệ hơn không có, và đây là khác biệt
giữa suite này và các khẳng định rải rác mà nó gia nhập. Hai mươi lăm khẳng định trong số đó
có trước nó — đếm theo tiêu đề trên mọi suite tích hợp bên ngoài
src/isolation/, mười một cái khi chương này được viết lần đầu và hai mươi lăm cái vào lúc
lần sắp xếp lại đặt thêm mười một chương trước nó. Chúng đều vẫn còn đó
và vẫn xanh: gauntlet thêm vào bề mặt cô lập chứ không dời nó
ra khỏi đoạn code mà lượt chạy coverage đo.