@starci/hfs 4.11.13 → 4.11.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/db/migrate.mjs +1 -1
- package/db/postgres.mjs +38 -3
- package/package.json +1 -1
- package/release/index.mjs +4 -3
- package/release/lint-baseline.mjs +123 -0
- package/release/urgent.mjs +19 -4
- package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
- package/runtime/knowledge/hfs/rules.yaml +4 -4
- package/runtime/knowledge/patterns/be/webhooks.yaml +3 -2
- package/runtime/scripts/hfs/architecture/identity.mjs +1 -1
- package/runtime/scripts/hfs/architecture/jwt-ast.mjs +14 -9
- package/runtime/scripts/hfs/rules/canon-copy-drift.mjs +54 -7
- package/scaffold/add-compose.mjs +3 -0
- package/scaffold/add-inbox-lite.mjs +45 -23
- package/scaffold/lite-exports.mjs +13 -0
- package/src/main.mjs +2 -2
- package/stack/cli.mjs +5 -3
- package/stack/deploy.mjs +6 -2
- package/stack/urgent-floor.mjs +101 -7
- package/templates/be/inbox/entities.index.ts.tpl +4 -0
- package/templates/be/inbox/entity.ts.tpl +29 -0
- package/templates/be/inbox/index.ts.tpl +1 -0
- package/templates/be/inbox/platform/inbox/inbox.decorators.ts.tpl +5 -2
- package/templates/be/inbox/platform/inbox/inbox.port.ts.tpl +29 -1
- package/templates/be/inbox/platform/inbox/inbox.service.spec.ts.tpl +65 -1
- package/templates/be/inbox/platform/inbox/inbox.service.ts.tpl +48 -10
- package/templates/be/inbox/platform/inbox/index.ts.tpl +2 -2
- package/templates/be/inbox/service.spec.ts.tpl +39 -15
- package/templates/be/inbox/service.ts.tpl +13 -13
- package/templates/be/inbox/webhook-door.spec.ts.tpl +4 -1
- package/templates/be/platform/errors-graphql/graphql-error.mapper.spec.ts +18 -8
- package/templates/be/platform/graphql/graphql-depth.policy.spec.ts +12 -3
- package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +39 -17
- package/templates/be/skeleton/src/modules/domain/note/note.service.ts +3 -4
- package/templates/be/skeleton/src/modules/domain/note/persistence/note.rows.ts +4 -0
- package/templates/be/skeleton/src/modules/platform/database/errors/database.error.ts +3 -0
- package/templates/be/skeleton/src/modules/platform/database/index.ts +1 -0
- package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.spec.ts +84 -0
- package/templates/be/skeleton/src/modules/platform/database/returned-rows.policy.ts +61 -0
- package/templates/be/skeleton/src/modules/platform/http-security/http-security.decorators.ts +8 -1
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.spec.ts +121 -9
- package/templates/be/skeleton/src/modules/platform/http-security/origin.guard.ts +12 -6
- package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.rows.ts +5 -0
- package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +3 -0
- package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.spec.ts +41 -33
- package/templates/be/skeleton-lite/src/modules/platform/http-security/rate-limit.guard.ts +15 -9
- package/templates/be/inbox/database-client.spec.ts.tpl +0 -45
- package/templates/be/inbox/database-client.ts.tpl +0 -45
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { Injectable } from "@nestjs/common"
|
|
2
|
-
import type { EntityManager } from "typeorm"
|
|
2
|
+
import type { EntityManager, ObjectLiteral } from "typeorm"
|
|
3
3
|
import { InjectClock } from "@modules/platform/clock"
|
|
4
4
|
import type { Clock } from "@modules/platform/clock"
|
|
5
|
-
import { BATCH_ROWS, DatabaseError, DatabaseErrorCode, callDbFunction } from "@modules/platform/database"
|
|
6
|
-
import type {
|
|
5
|
+
import { BATCH_ROWS, DatabaseError, DatabaseErrorCode, callDbFunction, returnedRows, rowShape } from "@modules/platform/database"
|
|
6
|
+
import type {
|
|
7
|
+
ClientOperation,
|
|
8
|
+
ClientOperationEffect,
|
|
9
|
+
EntityInbox,
|
|
10
|
+
Inbox,
|
|
11
|
+
InboxEntityReceipt,
|
|
12
|
+
InboxEventId,
|
|
13
|
+
InboxReceiptId,
|
|
14
|
+
} from "./inbox.port"
|
|
7
15
|
import { IdempotencyError, IdempotencyErrorCode } from "@modules/platform/idempotency"
|
|
8
16
|
import { InboxError, InboxErrorCode } from "./errors/inbox.error"
|
|
9
17
|
import { clientSource } from "./client-operation.policy"
|
|
@@ -43,19 +51,43 @@ interface InboxSweepRow {
|
|
|
43
51
|
readonly deleted_count: number
|
|
44
52
|
}
|
|
45
53
|
|
|
54
|
+
/** The row a receipt insert returns to the transaction that won it. */
|
|
55
|
+
const isReceiptRow = rowShape({ id: "string" })
|
|
56
|
+
|
|
57
|
+
/** The provider's delivery id, trimmed; a missing or blank one is refused before anything is claimed or written. */
|
|
58
|
+
const deliveryIdOf = (eventId: InboxEventId): string => {
|
|
59
|
+
const deliveryId = eventId?.trim()
|
|
60
|
+
if (!deliveryId) throw new DatabaseError({ code: DatabaseErrorCode.WebhookDeliveryIdRequired })
|
|
61
|
+
return deliveryId
|
|
62
|
+
}
|
|
63
|
+
|
|
46
64
|
@Injectable()
|
|
47
65
|
/** Claims a delivery with the receipt transaction, so a failure can be retried without losing the delivery. */
|
|
48
|
-
export class PostgresInbox implements Inbox {
|
|
66
|
+
export class PostgresInbox implements Inbox, EntityInbox {
|
|
49
67
|
constructor(@InjectClock() private readonly clock: Clock) {}
|
|
50
68
|
|
|
51
69
|
/** The first claim wins; the same source and delivery id becomes a no-op on redelivery. */
|
|
52
70
|
async claim(source: string, eventId: InboxEventId, tx: EntityManager): Promise<boolean> {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
71
|
+
return this.claimDelivery(source, deliveryIdOf(eventId), tx)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The claim first; only its winner inserts the receipt row, and a row that already exists is left as it is. */
|
|
75
|
+
async claimRow<Row extends ObjectLiteral>(
|
|
76
|
+
source: string,
|
|
77
|
+
eventId: InboxEventId,
|
|
78
|
+
tx: EntityManager,
|
|
79
|
+
receipt: InboxEntityReceipt<Row>,
|
|
80
|
+
): Promise<InboxReceiptId> {
|
|
81
|
+
const deliveryId = deliveryIdOf(eventId)
|
|
82
|
+
if (!(await this.claimDelivery(source, deliveryId, tx))) return undefined
|
|
83
|
+
const inserted = await tx
|
|
84
|
+
.createQueryBuilder(receipt.entity, "receipt")
|
|
85
|
+
.insert()
|
|
86
|
+
.values(receipt.row(deliveryId))
|
|
87
|
+
.orIgnore()
|
|
88
|
+
.returning("id")
|
|
89
|
+
.execute()
|
|
90
|
+
return returnedRows(inserted.raw, isReceiptRow)[0]?.id
|
|
59
91
|
}
|
|
60
92
|
|
|
61
93
|
/** Runs once in the domain transaction, returning the persisted JSON outcome on both the first call and every retry. */
|
|
@@ -99,4 +131,10 @@ export class PostgresInbox implements Inbox {
|
|
|
99
131
|
const rows = await callDbFunction<InboxSweepRow>(tx, "public.inbox_sweep_clients", [cutoff, BATCH_ROWS])
|
|
100
132
|
return rows[0]?.deleted_count ?? 0
|
|
101
133
|
}
|
|
134
|
+
|
|
135
|
+
/** Claims a normalized delivery id; an answered row means this transaction holds the first claim. */
|
|
136
|
+
private async claimDelivery(source: string, deliveryId: string, tx: EntityManager): Promise<boolean> {
|
|
137
|
+
const rows = await callDbFunction<InboxEventRow>(tx, "public.inbox_claim_event", [source, deliveryId])
|
|
138
|
+
return rows.length > 0
|
|
139
|
+
}
|
|
102
140
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { INBOX, InjectInbox } from "./inbox.decorators"
|
|
1
|
+
export { INBOX, InjectEntityInbox, InjectInbox } from "./inbox.decorators"
|
|
2
2
|
export { InboxModule } from "./inbox.module"
|
|
3
|
-
export type { Inbox, InboxEventId } from "./inbox.port"
|
|
3
|
+
export type { EntityInbox, Inbox, InboxEntityReceipt, InboxEventId } from "./inbox.port"
|
|
4
4
|
export { INBOX_ERROR_KINDS } from "./errors/inbox.error"
|
|
5
5
|
export { INBOX_MESSAGES } from "./messages/inbox.messages"
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import { fakeInbox, fakeTransaction, mockEntityManager, recordingEventBus } from "@starci/jest-preset"
|
|
2
2
|
import { Test } from "@nestjs/testing"
|
|
3
|
-
import {
|
|
3
|
+
import { DatabaseErrorCode, PRIMARY_ENTITY_MANAGER } from "@modules/platform/database"
|
|
4
4
|
import { @@Provider@@DeliveryRecordedEvent } from "@modules/events/@@provider@@"
|
|
5
5
|
import { EVENT_BUS } from "@modules/platform/event-bus"
|
|
6
6
|
import { INBOX } from "@modules/platform/inbox"
|
|
7
|
+
import { @@Provider@@InboxEntity } from "./persistence/entities/@@provider@@-inbox.entity"
|
|
7
8
|
import { @@Provider@@InboxService } from "./@@provider@@-inbox.service"
|
|
8
9
|
|
|
9
10
|
const DELIVERY = { id: "delivery-1" }
|
|
10
|
-
const
|
|
11
|
+
const SOURCE = "webhook.@@provider@@"
|
|
11
12
|
|
|
12
|
-
const build = async (
|
|
13
|
-
const entityManager = mockEntityManager(
|
|
13
|
+
const build = async () => {
|
|
14
|
+
const entityManager = mockEntityManager()
|
|
14
15
|
const transaction = fakeTransaction(entityManager)
|
|
15
16
|
const claims = fakeInbox()
|
|
16
|
-
if (!fresh) claims.seen("webhook.@@provider@@", "delivery-1")
|
|
17
17
|
const events = recordingEventBus<@@Provider@@DeliveryRecordedEvent>()
|
|
18
18
|
const moduleRef = await Test.createTestingModule({
|
|
19
19
|
providers: [
|
|
@@ -28,41 +28,65 @@ const build = async (fresh: boolean, recorded: ReadonlyArray<{ readonly id: stri
|
|
|
28
28
|
|
|
29
29
|
describe("@@Provider@@InboxService", () => {
|
|
30
30
|
describe("record@@Provider@@Delivery", () => {
|
|
31
|
-
it("claims, records and publishes a typed event in the same transaction", async () => {
|
|
32
|
-
const { inbox, claims, events, entityManager, transaction } = await build(
|
|
31
|
+
it("claims, records the receipt row and publishes a typed event in the same transaction", async () => {
|
|
32
|
+
const { inbox, claims, events, entityManager, transaction } = await build()
|
|
33
33
|
|
|
34
34
|
await inbox.record@@Provider@@Delivery({ eventId: "delivery-1", delivery: DELIVERY, correlationId: "request-1" })
|
|
35
35
|
|
|
36
|
-
expect(
|
|
37
|
-
expect(
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
expect(claims.claims).toEqual([{ source: SOURCE, eventId: "delivery-1" }])
|
|
37
|
+
expect(claims.receipts).toEqual([
|
|
38
|
+
{
|
|
39
|
+
id: "receipt-1",
|
|
40
|
+
source: SOURCE,
|
|
41
|
+
eventId: "delivery-1",
|
|
42
|
+
entity: @@Provider@@InboxEntity,
|
|
43
|
+
row: { provider: "@@provider@@", deliveryId: "delivery-1", payload: DELIVERY },
|
|
44
|
+
tx: events.entries[0]?.tx,
|
|
45
|
+
inTransaction: true,
|
|
46
|
+
},
|
|
47
|
+
])
|
|
48
|
+
expect(events.events).toEqual([@@Provider@@DeliveryRecordedEvent.create({ receiptId: "receipt-1" })])
|
|
40
49
|
expect(events.allInTransaction).toBe(true)
|
|
41
50
|
expect(events.entries[0]?.tx).not.toBe(entityManager)
|
|
42
51
|
expect(transaction.commits).toBe(1)
|
|
43
52
|
})
|
|
44
53
|
|
|
45
54
|
it("acknowledges a duplicate @@provider@@ delivery without further work", async () => {
|
|
46
|
-
const { inbox,
|
|
55
|
+
const { inbox, claims, events, transaction } = await build()
|
|
56
|
+
claims.seen(SOURCE, "delivery-1")
|
|
47
57
|
|
|
48
58
|
await inbox.record@@Provider@@Delivery({ eventId: "delivery-1", delivery: DELIVERY, correlationId: "request-1" })
|
|
49
59
|
|
|
50
|
-
expect(
|
|
60
|
+
expect(claims.receipts).toEqual([])
|
|
51
61
|
expect(events.events).toEqual([])
|
|
52
62
|
expect(transaction.commits).toBe(1)
|
|
53
63
|
})
|
|
54
64
|
|
|
55
65
|
it("does not publish for an already recorded receipt", async () => {
|
|
56
|
-
const { inbox, events, transaction } = await build(
|
|
66
|
+
const { inbox, claims, events, transaction } = await build()
|
|
67
|
+
claims.receiptExists(SOURCE, "delivery-1")
|
|
57
68
|
|
|
58
69
|
await inbox.record@@Provider@@Delivery({ eventId: "delivery-1", delivery: DELIVERY, correlationId: "request-1" })
|
|
59
70
|
|
|
71
|
+
expect(claims.receipts).toEqual([])
|
|
60
72
|
expect(events.events).toEqual([])
|
|
61
73
|
expect(transaction.commits).toBe(1)
|
|
62
74
|
})
|
|
63
75
|
|
|
76
|
+
it("refuses a delivery without an id before recording anything", async () => {
|
|
77
|
+
const { inbox, claims, events, transaction } = await build()
|
|
78
|
+
|
|
79
|
+
await expect(inbox.record@@Provider@@Delivery({ eventId: undefined, delivery: DELIVERY, correlationId: "request-1" })).rejects.toMatchObject({
|
|
80
|
+
code: DatabaseErrorCode.WebhookDeliveryIdRequired,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
expect(claims.receipts).toEqual([])
|
|
84
|
+
expect(events.events).toEqual([])
|
|
85
|
+
expect(transaction.rollbacks).toBe(1)
|
|
86
|
+
})
|
|
87
|
+
|
|
64
88
|
it("rolls back the receipt when publishing fails", async () => {
|
|
65
|
-
const { inbox, events, transaction } = await build(
|
|
89
|
+
const { inbox, events, transaction } = await build()
|
|
66
90
|
const failure = new Error("event bus unavailable")
|
|
67
91
|
events.failNext("publish", failure)
|
|
68
92
|
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { Injectable } from "@nestjs/common"
|
|
2
2
|
import type { EntityManager } from "typeorm"
|
|
3
|
-
import { InjectPrimaryEntityManager
|
|
3
|
+
import { InjectPrimaryEntityManager } from "@modules/platform/database"
|
|
4
4
|
import { @@Provider@@DeliveryRecordedEvent } from "@modules/events/@@provider@@"
|
|
5
5
|
import { InjectEventBus } from "@modules/platform/event-bus"
|
|
6
6
|
import type { EventBus } from "@modules/platform/event-bus"
|
|
7
|
-
import {
|
|
8
|
-
import type {
|
|
7
|
+
import { InjectEntityInbox } from "@modules/platform/inbox"
|
|
8
|
+
import type { EntityInbox, InboxEntityReceipt } from "@modules/platform/inbox"
|
|
9
|
+
import { @@Provider@@InboxEntity } from "./persistence/entities/@@provider@@-inbox.entity"
|
|
9
10
|
|
|
10
11
|
interface @@Provider@@Delivery {
|
|
11
12
|
readonly id: string
|
|
@@ -23,21 +24,20 @@ interface @@Provider@@DeliveryRequest {
|
|
|
23
24
|
export class @@Provider@@InboxService {
|
|
24
25
|
constructor(
|
|
25
26
|
@InjectPrimaryEntityManager() private readonly entityManager: EntityManager,
|
|
26
|
-
@
|
|
27
|
+
@InjectEntityInbox() private readonly inbox: EntityInbox,
|
|
27
28
|
@InjectEventBus() private readonly eventBus: EventBus,
|
|
28
29
|
) {}
|
|
29
30
|
|
|
30
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* The claim and the pending receipt row commit together: the winning claim inserts the row keyed by the delivery id the
|
|
33
|
+
* claim normalized; a duplicate is acknowledged without another pending row, and this intake never dispatches a command.
|
|
34
|
+
*/
|
|
31
35
|
record@@Provider@@Delivery(request: @@Provider@@DeliveryRequest): Promise<void> {
|
|
32
36
|
return this.entityManager.transaction(async (manager) => {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
provider: "@@provider@@",
|
|
38
|
-
deliveryId: request.eventId,
|
|
39
|
-
payload: request.delivery,
|
|
40
|
-
})
|
|
37
|
+
const receiptId = await this.inbox.claimRow("webhook.@@provider@@", request.eventId, manager, {
|
|
38
|
+
entity: @@Provider@@InboxEntity,
|
|
39
|
+
row: (deliveryId) => ({ provider: "@@provider@@", deliveryId, payload: request.delivery }),
|
|
40
|
+
} satisfies InboxEntityReceipt<@@Provider@@InboxEntity>)
|
|
41
41
|
if (receiptId === undefined) return
|
|
42
42
|
await this.eventBus.publish(@@Provider@@DeliveryRecordedEvent.create({ receiptId }), manager, request.correlationId)
|
|
43
43
|
})
|
|
@@ -5,7 +5,7 @@ import { Test } from "@nestjs/testing"
|
|
|
5
5
|
import type { Request } from "express"
|
|
6
6
|
import { @@Provider@@InboxService } from "@modules/domain/@@provider@@-inbox"
|
|
7
7
|
import { Secret } from "@modules/platform/config"
|
|
8
|
-
import { HttpSecurityModule, WebhookSignatureService } from "@modules/platform/http-security"
|
|
8
|
+
import { HttpSecurityModule, ReadinessService, WebhookSignatureService } from "@modules/platform/http-security"
|
|
9
9
|
import type { HttpSecurityOptions } from "@modules/platform/http-security"
|
|
10
10
|
import type { CorrelationRequest } from "@modules/platform/logging"
|
|
11
11
|
import { @@Event@@Request } from "./dto/@@event@@.request"
|
|
@@ -46,6 +46,9 @@ const build = async (deliveries: @@Provider@@InboxService) => {
|
|
|
46
46
|
})
|
|
47
47
|
.overrideProvider(WebhookSignatureService)
|
|
48
48
|
.useValue(new WebhookSignatureService(OPTIONS, new FakeClock(AT)))
|
|
49
|
+
// The module also provides the readiness probe, which needs the database and retry timer this door never reaches.
|
|
50
|
+
.overrideProvider(ReadinessService)
|
|
51
|
+
.useValue(mock<ReadinessService>())
|
|
49
52
|
.compile()
|
|
50
53
|
return moduleRef.get(@@Provider@@Webhook)
|
|
51
54
|
}
|
|
@@ -1,13 +1,23 @@
|
|
|
1
|
+
import { Test } from "@nestjs/testing"
|
|
1
2
|
import { mock } from "@starci/jest-preset"
|
|
2
3
|
import { GraphQLError } from "graphql"
|
|
4
|
+
import { ERRORS_SERVICE } from "./errors.decorators"
|
|
3
5
|
import type { ErrorsService } from "./errors.service"
|
|
4
6
|
import { formatGraphqlError } from "./graphql-error.mapper"
|
|
5
7
|
|
|
6
8
|
const formatted = { message: "raw", path: ["item"], locations: [{ line: 1, column: 3 }] }
|
|
7
9
|
|
|
10
|
+
const build = async () => {
|
|
11
|
+
const double = mock<ErrorsService>()
|
|
12
|
+
const moduleRef = await Test.createTestingModule({
|
|
13
|
+
providers: [{ provide: ERRORS_SERVICE, useValue: double }],
|
|
14
|
+
}).compile()
|
|
15
|
+
return moduleRef.get<typeof double>(ERRORS_SERVICE)
|
|
16
|
+
}
|
|
17
|
+
|
|
8
18
|
describe("formatGraphqlError", () => {
|
|
9
|
-
it("keeps the message of a malformed operation and marks it invalid without describing it", () => {
|
|
10
|
-
const errors =
|
|
19
|
+
it("keeps the message of a malformed operation and marks it invalid without describing it", async () => {
|
|
20
|
+
const errors = await build()
|
|
11
21
|
|
|
12
22
|
expect(formatGraphqlError(errors, formatted, new GraphQLError("GRAPHQL_VALIDATION_FAILED"))).toEqual({
|
|
13
23
|
message: "raw",
|
|
@@ -18,8 +28,8 @@ describe("formatGraphqlError", () => {
|
|
|
18
28
|
expect(errors.describe).not.toHaveBeenCalled()
|
|
19
29
|
})
|
|
20
30
|
|
|
21
|
-
it("describes the original failure of a resolver and travels its code, kind and params", () => {
|
|
22
|
-
const errors =
|
|
31
|
+
it("describes the original failure of a resolver and travels its code, kind and params", async () => {
|
|
32
|
+
const errors = await build()
|
|
23
33
|
const cause = new Error("not found")
|
|
24
34
|
errors.describe.mockReturnValue({
|
|
25
35
|
code: "ITEM_NOT_FOUND",
|
|
@@ -37,8 +47,8 @@ describe("formatGraphqlError", () => {
|
|
|
37
47
|
expect(errors.describe).toHaveBeenCalledWith(cause)
|
|
38
48
|
})
|
|
39
49
|
|
|
40
|
-
it("describes a failure that is not a GraphQL error as it is", () => {
|
|
41
|
-
const errors =
|
|
50
|
+
it("describes a failure that is not a GraphQL error as it is", async () => {
|
|
51
|
+
const errors = await build()
|
|
42
52
|
const cause = new Error("boom")
|
|
43
53
|
errors.describe.mockReturnValue({ code: "ERRORS_INTERNAL", kind: "internal", status: 500, params: {} })
|
|
44
54
|
|
|
@@ -50,9 +60,9 @@ describe("formatGraphqlError", () => {
|
|
|
50
60
|
expect(errors.describe).toHaveBeenCalledWith(cause)
|
|
51
61
|
})
|
|
52
62
|
|
|
53
|
-
it("leaves out the locations and path a formatted error does not carry", () => {
|
|
63
|
+
it("leaves out the locations and path a formatted error does not carry", async () => {
|
|
54
64
|
const subject = formatGraphqlError
|
|
55
|
-
const errors =
|
|
65
|
+
const errors = await build()
|
|
56
66
|
errors.describe.mockReturnValue({ code: "ERRORS_INTERNAL", kind: "internal", status: 500, params: {} })
|
|
57
67
|
|
|
58
68
|
expect(subject(errors, { message: "raw" }, new GraphQLError("GRAPHQL_PARSE_FAILED"))).toEqual({
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Test } from "@nestjs/testing"
|
|
1
2
|
import { buildSchema, parse, validate } from "graphql"
|
|
2
3
|
import { depthLimitRule } from "./graphql-depth.policy"
|
|
3
4
|
|
|
@@ -12,10 +13,17 @@ const schema = buildSchema(`
|
|
|
12
13
|
}
|
|
13
14
|
`)
|
|
14
15
|
|
|
15
|
-
const
|
|
16
|
+
const DEPTH_LIMIT = Symbol("spec.depth-limit")
|
|
17
|
+
|
|
18
|
+
const build = async () => {
|
|
19
|
+
const moduleRef = await Test.createTestingModule({ providers: [{ provide: DEPTH_LIMIT, useValue: 2 }] }).compile()
|
|
20
|
+
const maxDepth = moduleRef.get<number>(DEPTH_LIMIT)
|
|
21
|
+
return (source: string) => validate(schema, parse(source), [depthLimitRule(maxDepth)])
|
|
22
|
+
}
|
|
16
23
|
|
|
17
24
|
describe("depthLimitRule", () => {
|
|
18
|
-
it("accepts an operation nested exactly to the depth limit", () => {
|
|
25
|
+
it("accepts an operation nested exactly to the depth limit", async () => {
|
|
26
|
+
const validateDepth = await build()
|
|
19
27
|
const errors = validateDepth(`
|
|
20
28
|
query {
|
|
21
29
|
item {
|
|
@@ -27,7 +35,8 @@ describe("depthLimitRule", () => {
|
|
|
27
35
|
expect(errors).toEqual([])
|
|
28
36
|
})
|
|
29
37
|
|
|
30
|
-
it("rejects the first selection set deeper than the limit", () => {
|
|
38
|
+
it("rejects the first selection set deeper than the limit", async () => {
|
|
39
|
+
const validateDepth = await build()
|
|
31
40
|
const errors = validateDepth(`
|
|
32
41
|
query {
|
|
33
42
|
item {
|
|
@@ -1,12 +1,36 @@
|
|
|
1
1
|
import type { GraphQLRequestContext, GraphQLRequestContextWillSendResponse, GraphQLResponse } from "@apollo/server"
|
|
2
|
+
import { Test } from "@nestjs/testing"
|
|
2
3
|
import { mock } from "@starci/jest-preset"
|
|
3
4
|
import type { Request } from "express"
|
|
4
5
|
import type { GraphQLFormattedError } from "graphql"
|
|
6
|
+
import { ERRORS_SERVICE } from "@modules/platform/errors"
|
|
5
7
|
import type { ErrorsService } from "@modules/platform/errors"
|
|
8
|
+
import { REQUEST_LOCALE } from "@modules/platform/i18n"
|
|
6
9
|
import type { RequestLocale } from "@modules/platform/i18n"
|
|
7
10
|
import type { GraphqlContext } from "./graphql.contracts"
|
|
8
11
|
import { localizeError, localizeErrorsPlugin } from "./localize-errors.mapper"
|
|
9
12
|
|
|
13
|
+
const build = async () => {
|
|
14
|
+
const errorsDouble = mock<ErrorsService>()
|
|
15
|
+
const localeDouble = mock<RequestLocale>()
|
|
16
|
+
const moduleRef = await Test.createTestingModule({
|
|
17
|
+
providers: [
|
|
18
|
+
{ provide: ERRORS_SERVICE, useValue: errorsDouble },
|
|
19
|
+
{ provide: REQUEST_LOCALE, useValue: localeDouble },
|
|
20
|
+
],
|
|
21
|
+
}).compile()
|
|
22
|
+
return {
|
|
23
|
+
errors: moduleRef.get<typeof errorsDouble>(ERRORS_SERVICE),
|
|
24
|
+
requestLocale: moduleRef.get<typeof localeDouble>(REQUEST_LOCALE),
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const errorWith = (extensions?: GraphQLFormattedError["extensions"]): GraphQLFormattedError =>
|
|
29
|
+
extensions === undefined ? { message: "syntax error" } : { message: "syntax error", extensions }
|
|
30
|
+
|
|
31
|
+
const acceptLanguage = (language?: string): Request["headers"] =>
|
|
32
|
+
language === undefined ? {} : { "accept-language": language }
|
|
33
|
+
|
|
10
34
|
const singleResponse = (errors?: ReadonlyArray<GraphQLFormattedError>): GraphQLResponse =>
|
|
11
35
|
mock<GraphQLResponse>({ body: { kind: "single", singleResult: errors === undefined ? {} : { errors } } })
|
|
12
36
|
|
|
@@ -33,17 +57,17 @@ const sendResponse = async (
|
|
|
33
57
|
describe("localizeError", () => {
|
|
34
58
|
it.each([undefined, {}, { code: 400 }, { code: null }, { code: false }])(
|
|
35
59
|
"returns an error without a string code unchanged (%p)",
|
|
36
|
-
(extensions) => {
|
|
37
|
-
const errors =
|
|
38
|
-
const error
|
|
60
|
+
async (extensions) => {
|
|
61
|
+
const { errors } = await build()
|
|
62
|
+
const error = errorWith(extensions)
|
|
39
63
|
|
|
40
64
|
expect(localizeError(errors, "en", error)).toBe(error)
|
|
41
65
|
expect(errors.text).not.toHaveBeenCalled()
|
|
42
66
|
},
|
|
43
67
|
)
|
|
44
68
|
|
|
45
|
-
it("replaces a coded error's message with the catalog text and keeps only string and number params", () => {
|
|
46
|
-
const errors =
|
|
69
|
+
it("replaces a coded error's message with the catalog text and keeps only string and number params", async () => {
|
|
70
|
+
const { errors } = await build()
|
|
47
71
|
errors.text.mockReturnValue("The item was not found")
|
|
48
72
|
const error: GraphQLFormattedError = {
|
|
49
73
|
message: "ITEM_NOT_FOUND",
|
|
@@ -67,8 +91,8 @@ describe("localizeError", () => {
|
|
|
67
91
|
|
|
68
92
|
it.each([undefined, null, ["i-1"], "i-1", 42, false])(
|
|
69
93
|
"interpolates no params when extensions.params is %p",
|
|
70
|
-
(params) => {
|
|
71
|
-
const errors =
|
|
94
|
+
async (params) => {
|
|
95
|
+
const { errors } = await build()
|
|
72
96
|
errors.text.mockReturnValue("The item was not found")
|
|
73
97
|
const error: GraphQLFormattedError = {
|
|
74
98
|
message: "ITEM_NOT_FOUND",
|
|
@@ -83,8 +107,7 @@ describe("localizeError", () => {
|
|
|
83
107
|
|
|
84
108
|
describe("localizeErrorsPlugin", () => {
|
|
85
109
|
it("forwards the Accept-Language preferences and localizes every coded error in the resolved locale", async () => {
|
|
86
|
-
const errors =
|
|
87
|
-
const requestLocale = mock<RequestLocale>()
|
|
110
|
+
const { errors, requestLocale } = await build()
|
|
88
111
|
requestLocale.of.mockReturnValue("vi")
|
|
89
112
|
errors.text.mockReturnValueOnce("localized: not found").mockReturnValueOnce("localized: denied")
|
|
90
113
|
const coded: GraphQLFormattedError = { message: "ITEM_NOT_FOUND", extensions: { code: "ITEM_NOT_FOUND" } }
|
|
@@ -115,15 +138,14 @@ describe("localizeErrorsPlugin", () => {
|
|
|
115
138
|
it.each([undefined, ""])(
|
|
116
139
|
"delegates an absent or empty Accept-Language header to the locale default (%p)",
|
|
117
140
|
async (language) => {
|
|
118
|
-
const errors =
|
|
119
|
-
const requestLocale = mock<RequestLocale>()
|
|
141
|
+
const { errors, requestLocale } = await build()
|
|
120
142
|
requestLocale.of.mockReturnValue("vi")
|
|
121
143
|
errors.text.mockReturnValue("localized: not found")
|
|
122
144
|
const coded: GraphQLFormattedError = { message: "ITEM_NOT_FOUND", extensions: { code: "ITEM_NOT_FOUND" } }
|
|
123
145
|
|
|
124
146
|
const response = singleResponse([coded])
|
|
125
147
|
|
|
126
|
-
await sendResponse(errors, requestLocale, requestContext(response,
|
|
148
|
+
await sendResponse(errors, requestLocale, requestContext(response, acceptLanguage(language)))
|
|
127
149
|
|
|
128
150
|
expect(requestLocale.of).toHaveBeenCalledWith(language)
|
|
129
151
|
expect(errors.text).toHaveBeenCalledWith("ITEM_NOT_FOUND", {}, "vi")
|
|
@@ -132,10 +154,11 @@ describe("localizeErrorsPlugin", () => {
|
|
|
132
154
|
])
|
|
133
155
|
},
|
|
134
156
|
)
|
|
157
|
+
})
|
|
135
158
|
|
|
159
|
+
describe("localizeErrorsPlugin on a response with nothing to localize", () => {
|
|
136
160
|
it("leaves a response without errors alone", async () => {
|
|
137
|
-
const errors =
|
|
138
|
-
const requestLocale = mock<RequestLocale>()
|
|
161
|
+
const { errors, requestLocale } = await build()
|
|
139
162
|
|
|
140
163
|
const response = singleResponse()
|
|
141
164
|
const body = response.body
|
|
@@ -149,14 +172,13 @@ describe("localizeErrorsPlugin", () => {
|
|
|
149
172
|
})
|
|
150
173
|
|
|
151
174
|
it("leaves an incremental response alone", async () => {
|
|
152
|
-
const errors =
|
|
153
|
-
const requestLocale = mock<RequestLocale>()
|
|
175
|
+
const { errors, requestLocale } = await build()
|
|
154
176
|
const coded: GraphQLFormattedError = { message: "ITEM_NOT_FOUND", extensions: { code: "ITEM_NOT_FOUND" } }
|
|
155
177
|
const response = mock<GraphQLResponse>({
|
|
156
178
|
body: {
|
|
157
179
|
kind: "incremental",
|
|
158
180
|
initialResult: { errors: [coded], hasNext: true },
|
|
159
|
-
subsequentResults: mock<
|
|
181
|
+
subsequentResults: mock<AsyncIterable<never>>(),
|
|
160
182
|
},
|
|
161
183
|
})
|
|
162
184
|
const body = response.body
|
|
@@ -2,12 +2,11 @@ import { Injectable } from "@nestjs/common"
|
|
|
2
2
|
import type { EntityManager } from "typeorm"
|
|
3
3
|
import { InjectClock } from "@modules/platform/clock"
|
|
4
4
|
import type { Clock } from "@modules/platform/clock"
|
|
5
|
-
import { InjectPrimaryEntityManager, LIST_ROWS_MAX } from "@modules/platform/database"
|
|
5
|
+
import { InjectPrimaryEntityManager, LIST_ROWS_MAX, returnedRows } from "@modules/platform/database"
|
|
6
6
|
import type { CreateNoteParams, Note } from "./note.contracts"
|
|
7
7
|
import { NoteError, NoteErrorCode } from "./errors/note.error"
|
|
8
8
|
import { NoteEntity } from "./persistence/entities/note.entity"
|
|
9
|
-
import { toNote } from "./persistence/note.rows"
|
|
10
|
-
import type { NoteRow } from "./persistence/note.rows"
|
|
9
|
+
import { isNoteRow, toNote } from "./persistence/note.rows"
|
|
11
10
|
import { INSERT_NOTE } from "./persistence/note.sql"
|
|
12
11
|
|
|
13
12
|
@Injectable()
|
|
@@ -29,7 +28,7 @@ export class NoteService {
|
|
|
29
28
|
|
|
30
29
|
/** Writes one note in the caller transaction, stamped by the clock, and answers it as stored. */
|
|
31
30
|
async create(params: CreateNoteParams): Promise<Note> {
|
|
32
|
-
const rows
|
|
31
|
+
const rows = returnedRows(await params.manager.query(INSERT_NOTE, [params.body, this.clock.now()]), isNoteRow)
|
|
33
32
|
const note = toNote(rows)
|
|
34
33
|
if (note === null) throw new NoteError({ code: NoteErrorCode.NoteMissing })
|
|
35
34
|
return note
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { rowShape } from "@modules/platform/database"
|
|
1
2
|
import type { Note } from "../note.contracts"
|
|
2
3
|
|
|
3
4
|
/** The row INSERT_NOTE answers. */
|
|
@@ -10,6 +11,9 @@ export interface NoteRow {
|
|
|
10
11
|
created_at: Date
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
/** Proves one row INSERT_NOTE answered is a NoteRow. */
|
|
15
|
+
export const isNoteRow: (value: unknown) => value is NoteRow = rowShape({ id: "string", body: "string", created_at: "date" })
|
|
16
|
+
|
|
13
17
|
/** The note of the row an insert answers, or null when it answered none. */
|
|
14
18
|
export const toNote = (rows: ReadonlyArray<NoteRow>): Note | null => {
|
|
15
19
|
const row = rows[0]
|
|
@@ -5,11 +5,14 @@ import type { ErrorKind } from "@modules/platform/errors"
|
|
|
5
5
|
export enum DatabaseErrorCode {
|
|
6
6
|
/** A dynamic SQL identifier was not on the list of names its caller allows. */
|
|
7
7
|
IdentifierRejected = "DATABASE_IDENTIFIER_REJECTED",
|
|
8
|
+
/** A RETURNING statement answered something other than rows of the shape its caller expects. */
|
|
9
|
+
ReturningRowRejected = "DATABASE_RETURNING_ROW_REJECTED",
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
/** How each database code travels. */
|
|
11
13
|
export const DATABASE_ERROR_KINDS: Record<DatabaseErrorCode, ErrorKind> = {
|
|
12
14
|
[DatabaseErrorCode.IdentifierRejected]: "internal",
|
|
15
|
+
[DatabaseErrorCode.ReturningRowRejected]: "internal",
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
/** The one error class of the database capability. */
|
|
@@ -7,5 +7,6 @@ export { SeedRunnerService } from "./seed-runner.service"
|
|
|
7
7
|
export type { DatabaseConnectionConfig, DatabaseConnectionOptions } from "./database.options"
|
|
8
8
|
export { LIST_ROWS_MAX, sql } from "./database.sql"
|
|
9
9
|
export { DATABASE_ERROR_KINDS } from "./errors/database.error"
|
|
10
|
+
export { returnedRows, rowShape } from "./returned-rows.policy"
|
|
10
11
|
export { parsePrimaryDatabaseConfig } from "./primary.config"
|
|
11
12
|
export { InjectPrimaryEntityManager, PRIMARY_ENTITY_MANAGER } from "./primary.decorators"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Test } from "@nestjs/testing"
|
|
2
|
+
import { DatabaseErrorCode } from "./errors/database.error"
|
|
3
|
+
import { returnedRows, rowShape } from "./returned-rows.policy"
|
|
4
|
+
|
|
5
|
+
const RETURNED_ROWS_POLICY: unique symbol = Symbol("database.returned-rows")
|
|
6
|
+
|
|
7
|
+
/** The policy under test, resolved through a testing module as the unit spec standard asks. */
|
|
8
|
+
interface ReturnedRowsPolicy {
|
|
9
|
+
readonly returnedRows: typeof returnedRows
|
|
10
|
+
readonly rowShape: typeof rowShape
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const AT = new Date("2026-02-03T04:05:06.000Z")
|
|
14
|
+
|
|
15
|
+
describe("returned rows", () => {
|
|
16
|
+
let subject: ReturnedRowsPolicy
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
const moduleRef = await Test.createTestingModule({
|
|
20
|
+
providers: [{ provide: RETURNED_ROWS_POLICY, useFactory: (): ReturnedRowsPolicy => ({ returnedRows, rowShape }) }],
|
|
21
|
+
}).compile()
|
|
22
|
+
subject = moduleRef.get<ReturnedRowsPolicy>(RETURNED_ROWS_POLICY)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe("rowShape", () => {
|
|
26
|
+
it("admits a row holding every named column with a value of its kind", () => {
|
|
27
|
+
const isRow = subject.rowShape({ id: "string", hits: "number", open: "boolean", created_at: "date", payload: "json" })
|
|
28
|
+
|
|
29
|
+
expect(isRow({ id: "row-1", hits: 2, open: true, created_at: AT, payload: { a: 1 }, extra: "kept" })).toBe(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("admits null only for a kind that ends in ?", () => {
|
|
33
|
+
const isRow = subject.rowShape({ note: "string?", hits: "number?", open: "boolean?", seen_at: "date?" })
|
|
34
|
+
|
|
35
|
+
expect(isRow({ note: null, hits: null, open: null, seen_at: null })).toBe(true)
|
|
36
|
+
expect(isRow({ note: "n", hits: 1, open: false, seen_at: AT })).toBe(true)
|
|
37
|
+
expect(subject.rowShape({ note: "string" })({ note: null })).toBe(false)
|
|
38
|
+
expect(subject.rowShape({ payload: "json" })({ payload: null })).toBe(true)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it.each([
|
|
42
|
+
["a column the statement did not return", { hits: 1 }],
|
|
43
|
+
["a value of another kind", { id: 7, hits: 1 }],
|
|
44
|
+
["a number returned as text", { id: "row-1", hits: "1" }],
|
|
45
|
+
["a value that is not a row", "row-1"],
|
|
46
|
+
["null", null],
|
|
47
|
+
])("refuses %s", (_case, value) => {
|
|
48
|
+
expect(subject.rowShape({ id: "string", hits: "number" })(value)).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("refuses a json column the statement did not return", () => {
|
|
52
|
+
expect(subject.rowShape({ payload: "json" })({})).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe("returnedRows", () => {
|
|
57
|
+
it("answers every row, typed by its guard, in the order the statement returned them", () => {
|
|
58
|
+
const rows = subject.returnedRows([{ id: "a" }, { id: "b" }], subject.rowShape({ id: "string" }))
|
|
59
|
+
|
|
60
|
+
expect(rows.map((row) => row.id)).toEqual(["a", "b"])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("answers no rows for a statement that returned none", () => {
|
|
64
|
+
expect(subject.returnedRows([], subject.rowShape({ id: "string" }))).toEqual([])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("refuses a row of another shape instead of reading it", () => {
|
|
68
|
+
expect(() => subject.returnedRows([{ id: "a" }, { id: 2 }], subject.rowShape({ id: "string" }))).toThrow(
|
|
69
|
+
expect.objectContaining({ code: DatabaseErrorCode.ReturningRowRejected }),
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("refuses a result that is not a list of rows, such as the [rows, count] of a bare UPDATE", () => {
|
|
74
|
+
const isRow = subject.rowShape({ id: "string" })
|
|
75
|
+
|
|
76
|
+
expect(() => subject.returnedRows({ id: "a" }, isRow)).toThrow(
|
|
77
|
+
expect.objectContaining({ code: DatabaseErrorCode.ReturningRowRejected }),
|
|
78
|
+
)
|
|
79
|
+
expect(() => subject.returnedRows([[{ id: "a" }], 1], isRow)).toThrow(
|
|
80
|
+
expect.objectContaining({ code: DatabaseErrorCode.ReturningRowRejected }),
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
})
|