@quickservicets/core 0.1.0 → 0.2.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quickservicets/core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "The QuickService.ts runtime: build typed, validated and documented REST APIs on Bun from TypeScript entity types.",
5
5
  "keywords": [
6
6
  "quickservicets",
@@ -97,8 +97,10 @@ export function toFilter(conditions: Condition[], entity?: EntityModel): Filter<
97
97
  entity?.fields.some((field) => field.name === condition.field && field.type === "refArray") ?? false;
98
98
  const one = (condition: Condition): Document => {
99
99
  if (isConditionGroup(condition)) {
100
- // An empty AND matches everything; an empty OR matches nothing.
101
- if (condition.conditions.length === 0) return condition.group === "or" ? { _id: { $in: [] } } : {};
100
+ // An empty AND matches everything, so its negation matches nothing; an empty OR matches nothing.
101
+ if (condition.conditions.length === 0) return condition.group === "and" ? {} : { _id: { $in: [] } };
102
+ // $nor also matches documents where a compared field is null or missing.
103
+ if (condition.group === "not") return { $nor: [condition.conditions.length === 1 ? one(condition.conditions[0]!) : { $and: condition.conditions.map(one) }] };
102
104
  return { [condition.group === "or" ? "$or" : "$and"]: condition.conditions.map(one) };
103
105
  }
104
106
  return isRelation(condition) ? translateArray(condition) : translate(condition);
@@ -140,6 +140,16 @@ function translateField(out: Translation, condition: FieldCondition): void {
140
140
  }
141
141
 
142
142
  function translate(out: Translation, condition: Condition): void {
143
+ if (isConditionGroup(condition) && condition.group === "not") {
144
+ out.key += "not(";
145
+ // IS NOT TRUE also matches when the inner condition is NULL, as it is for comparisons with a NULL column.
146
+ out.emit("(");
147
+ if (condition.conditions.length === 0) out.emit("(1 = 1)");
148
+ else translateAll(out, condition.conditions, " and ");
149
+ out.emit(" is not true)");
150
+ out.key += ")";
151
+ return;
152
+ }
143
153
  if (isConditionGroup(condition)) {
144
154
  const or = condition.group === "or";
145
155
  out.key += or ? "or(" : "and(";
package/src/app.ts CHANGED
@@ -202,7 +202,7 @@ export function createApp({ models, crudRoutes, endpoints: allEndpoints, hooks,
202
202
  populateMaxDepth: config.populate?.maxDepth,
203
203
  fileAccess,
204
204
  fileCleanup,
205
- findAllMax: config.repositories?.findAllMax,
205
+ toArrayMax: config.repositories?.toArrayMax,
206
206
  });
207
207
  const hooksByRoute = new Map(crudRoutes.map((entry) => [`${entry.method} ${entry.path}`, entry.hooks]));
208
208
  const uploads = { storage: config.storage, files: config.files };
package/src/config.ts CHANGED
@@ -70,8 +70,8 @@ export interface ServiceConfig extends NativeFeatures {
70
70
  };
71
71
  /** Repository settings. */
72
72
  repositories?: {
73
- /** Most records a fluent query's `all()` may return (default 10000). */
74
- findAllMax?: number;
73
+ /** Most records a fluent query's `toArray()` may return (default 10000). */
74
+ toArrayMax?: number;
75
75
  };
76
76
  /** Populate settings. */
77
77
  populate?: {
@@ -321,6 +321,9 @@ function resolveLifecycle(config: ServiceConfig): ResolvedConfig["lifecycle"] {
321
321
  return resolved;
322
322
  }
323
323
 
324
+ /** The error for the config key the fluent query's `toArray()` replaced. */
325
+ export const FIND_ALL_MAX_RENAMED = "repositories.findAllMax was renamed to repositories.toArrayMax";
326
+
324
327
  export function resolveConfig(
325
328
  config: ServiceConfig,
326
329
  env: Record<string, string | undefined> = process.env,
@@ -331,6 +334,7 @@ export function resolveConfig(
331
334
  if (!config || typeof config !== "object" || !config.database) {
332
335
  throw new ConfigError("Invalid config: database is required");
333
336
  }
337
+ if (Object.hasOwn(config.repositories ?? {}, "findAllMax")) throw new ConfigError(FIND_ALL_MAX_RENAMED);
334
338
  // Variables win over aliases, which win over the config's values; the environment is read only here.
335
339
  const environment = applyEnvironment(config as unknown as Record<string, unknown>, env, activeFeatureDefinitions());
336
340
  config = environment.config as unknown as ServiceConfig;
@@ -53,7 +53,7 @@ export const CONFIG_OVERRIDES: readonly ConfigOverride[] = [
53
53
  { path: "files.deleteOnRemove", schema: boolean() },
54
54
  { path: "files.deleteReplaced", schema: boolean() },
55
55
  { path: "files.deleteWithOwner", schema: boolean() },
56
- { path: "repositories.findAllMax", schema: integer().min(1) },
56
+ { path: "repositories.toArrayMax", schema: integer().min(1) },
57
57
  { path: "populate.maxDepth", schema: integer().min(1) },
58
58
  { path: "migrations.autoInDev", schema: boolean() },
59
59
  { path: "jobs.enabled", schema: boolean(), alias: { name: "JOBS_ENABLED" } },
@@ -19,6 +19,14 @@ export class QueryCondition {
19
19
  return new QueryCondition([{ group: "or", conditions: [this.single(), other.single()] }]);
20
20
  }
21
21
 
22
+ /**
23
+ * Every record for which this condition is not true, including records where a compared field is null or missing:
24
+ * `a.and(b).not()` is `NOT (a AND b)`.
25
+ */
26
+ not(): QueryCondition {
27
+ return new QueryCondition([{ group: "not", conditions: [this.single()] }]);
28
+ }
29
+
22
30
  /** The conditions as one: itself when there is one, an AND group otherwise. */
23
31
  private single(): Condition {
24
32
  return this.conditions.length === 1 ? this.conditions[0]! : { group: "and", conditions: [...this.conditions] };
@@ -287,24 +295,37 @@ export interface Query<T, R = Record<never, never>, M = never> {
287
295
  thenBy(lambda: SortLambda<T>): Query<T, R, M>;
288
296
  /** Adds a further sort field in descending order. */
289
297
  thenByDescending(lambda: SortLambda<T>): Query<T, R, M>;
290
- /** Leaves out the first `count` results (not applied to `count()` and `any()`). */
298
+ /** Leaves out the first `count` results (not applied to `count()`, `any()`, `all()` and `none()`). */
291
299
  skip(count: number): Query<T, R, M>;
292
- /** Returns at most `count` results (not applied to `count()` and `any()`). */
300
+ /** Returns at most `count` results (not applied to `count()`, `any()`, `all()` and `none()`). */
293
301
  take(count: number): Query<T, R, M>;
294
302
  /** Returns only these fields plus `id`. */
295
303
  select<K extends keyof T & string>(...fields: K[]): Query<Pick<T, K | ("id" & keyof T)>, R, M>;
296
304
  /** Replaces reference paths with the referenced records, e.g. `include("profiles.permissions.module")`. */
297
305
  include<const P extends readonly PopulatePath<R>[]>(...paths: P): Query<Populated<T, R, P[number]>, Record<never, never>, M>;
298
- /** Every match; throws when there are more than `repositories.findAllMax`. */
299
- all(): Promise<T[]>;
306
+ /** Every match; throws when there are more than `repositories.toArrayMax`. */
307
+ toArray(): Promise<T[]>;
300
308
  /** The first match, or 404 `<Entity> not found`. */
301
309
  first(): Promise<T>;
302
310
  /** The first match, or null. */
303
311
  firstOrDefault(): Promise<T | null>;
304
312
  /** The number of matches, without loading records. */
305
313
  count(): Promise<number>;
306
- /** Whether at least one record matches, without loading records. */
307
- any(): Promise<boolean>;
314
+ /**
315
+ * Whether at least one match exists, or with a condition, whether at least one match satisfies it, without loading
316
+ * records.
317
+ */
318
+ any(lambda?: QueryLambda<T>): Promise<boolean>;
319
+ /**
320
+ * Whether every match satisfies the condition (true when nothing matches), answered by the database in one round trip
321
+ * without loading records.
322
+ */
323
+ all(lambda: QueryLambda<T>): Promise<boolean>;
324
+ /**
325
+ * Whether no match exists, or with a condition, whether no match satisfies it (`!(await any(lambda))`), without
326
+ * loading records.
327
+ */
328
+ none(lambda?: QueryLambda<T>): Promise<boolean>;
308
329
  /** `{ data, total, limit, offset }` like the generated list routes. */
309
330
  page(options?: PageOptions): Promise<{ data: T[]; total: number; limit: number; offset: number }>;
310
331
  /**
@@ -318,7 +339,7 @@ export interface Query<T, R = Record<never, never>, M = never> {
318
339
  /** What a query needs from its repository. */
319
340
  export interface QueryBackend {
320
341
  model: EntityModel;
321
- findAllMax: number;
342
+ toArrayMax: number;
322
343
  /** A page of matches; `withTotal` false skips counting them (`total` is then the page size). */
323
344
  list(query: ListQuery, populate: string[], withTotal: boolean): Promise<{ data: Record<string, unknown>[]; total: number }>;
324
345
  count(where: Condition[]): Promise<number>;
@@ -372,7 +393,7 @@ function nonNegative(method: string, count: number): number {
372
393
 
373
394
  /** A query over the backend's records with the given state; every method returns a new query. */
374
395
  export function createQuery<T, R = Record<never, never>, M = never>(backend: QueryBackend, state: QueryState): Query<T, R, M> {
375
- const { model, findAllMax } = backend;
396
+ const { model, toArrayMax } = backend;
376
397
  const next = (changes: Partial<QueryState>) => createQuery<T, R, M>(backend, { ...state, ...changes });
377
398
  const keyOf = (lambda: SortLambda<T>) => {
378
399
  const key = lambda(sortKeys(model) as unknown as SortFields<T>);
@@ -391,6 +412,8 @@ export function createQuery<T, R = Record<never, never>, M = never>(backend: Que
391
412
  return { ...result, data: shape(result.data) };
392
413
  };
393
414
  const count = () => backend.count(state.where);
415
+ const any = async (lambda?: QueryLambda<T>) =>
416
+ (await (lambda === undefined ? count() : backend.count([...state.where, ...conditionsOf(model, lambda)]))) > 0;
394
417
  return {
395
418
  where: (lambda) => next({ where: [...state.where, ...conditionsOf(model, lambda)] }),
396
419
  orderBy: (lambda) => next({ sort: [{ field: keyOf(lambda), direction: "asc" }] }),
@@ -401,13 +424,13 @@ export function createQuery<T, R = Record<never, never>, M = never>(backend: Que
401
424
  take: (value) => next({ take: nonNegative("take", value) }),
402
425
  select: (...fields) => createQuery(backend, { ...state, select: fields }) as never,
403
426
  include: (...paths) => createQuery(backend, { ...state, include: [...new Set([...(state.include ?? []), ...paths])] }) as never,
404
- async all() {
427
+ async toArray() {
405
428
  // One record past the cap shows there are too many, without counting the matches.
406
- const limit = Math.min(state.take ?? Number.POSITIVE_INFINITY, findAllMax + 1);
429
+ const limit = Math.min(state.take ?? Number.POSITIVE_INFINITY, toArrayMax + 1);
407
430
  if (limit === 0) return [];
408
431
  const { data } = await read(limit, state.skip);
409
- if (data.length > findAllMax) {
410
- throw new Error(`Query on ${model.entityName} matched more than repositories.findAllMax (${findAllMax}) records`);
432
+ if (data.length > toArrayMax) {
433
+ throw new Error(`Query on ${model.entityName} matched more than repositories.toArrayMax (${toArrayMax}) records`);
411
434
  }
412
435
  return data;
413
436
  },
@@ -420,8 +443,15 @@ export function createQuery<T, R = Record<never, never>, M = never>(backend: Que
420
443
  return state.take === 0 ? null : ((await read(1, state.skip)).data[0] ?? null);
421
444
  },
422
445
  count,
423
- async any() {
424
- return (await count()) > 0;
446
+ any,
447
+ async none(lambda) {
448
+ return !(await any(lambda));
449
+ },
450
+ async all(lambda) {
451
+ if (typeof lambda !== "function") throw new Error("all() needs a condition; use toArray() to read the records");
452
+ // The matches and those satisfying the condition, counted together in one query.
453
+ const counts = await backend.aggregate(state.where, { matched: { fn: "count" }, satisfied: { fn: "count", where: conditionsOf(model, lambda) } });
454
+ return counts === null || counts.matched === counts.satisfied;
425
455
  },
426
456
  async page({ limit = 20, offset = 0 }: PageOptions = {}) {
427
457
  const details: FieldErrorDetail[] = [];
package/src/matching.ts CHANGED
@@ -20,7 +20,9 @@ export function compareValues(left: unknown, right: unknown): number {
20
20
  export function matches(record: StoredRecord, condition: Condition, entity: EntityModel): boolean {
21
21
  if (isConditionGroup(condition)) {
22
22
  const test = (inner: Condition) => matches(record, inner, entity);
23
- return condition.group === "or" ? condition.conditions.some(test) : condition.conditions.every(test);
23
+ if (condition.group === "or") return condition.conditions.some(test);
24
+ // Comparisons with a missing value are false, so their negation matches it.
25
+ return condition.group === "not" ? !condition.conditions.every(test) : condition.conditions.every(test);
24
26
  }
25
27
  const actual = record[condition.field];
26
28
  const { op, value } = condition;
package/src/query.ts CHANGED
@@ -41,9 +41,12 @@ export interface FieldCondition {
41
41
  literal?: true;
42
42
  }
43
43
 
44
- /** Conditions joined by AND or OR, nested to any depth. */
44
+ /**
45
+ * Conditions joined by AND or OR, nested to any depth. A `not` group matches every record for which its conditions
46
+ * (joined by AND) are not true, including records where a compared field is null or missing.
47
+ */
45
48
  export interface ConditionGroup {
46
- group: "and" | "or";
49
+ group: "and" | "or" | "not";
47
50
  conditions: Condition[];
48
51
  }
49
52
 
package/src/repository.ts CHANGED
@@ -68,11 +68,11 @@ export interface RepositoryOptions {
68
68
  fileAccess?: FileAccessSettings;
69
69
  /** When set, objects and file records that are no longer used are deleted. */
70
70
  fileCleanup?: FileCleanupSettings;
71
- /** Most records a fluent query's `all()` may return (default 10000). */
72
- findAllMax?: number;
71
+ /** Most records a fluent query's `toArray()` may return (default 10000). */
72
+ toArrayMax?: number;
73
73
  }
74
74
 
75
- export const DEFAULT_FIND_ALL_MAX = 10000;
75
+ export const DEFAULT_TO_ARRAY_MAX = 10000;
76
76
  const FIND_BY_IDS_BATCH = 100;
77
77
 
78
78
  export interface ListResult<T> {
@@ -176,6 +176,14 @@ export interface Repository<T extends EntityRecord = EntityRecord, R = Record<ne
176
176
  where(lambda: QueryLambda<T>): Query<T, R>;
177
177
  /** A fluent query over every record. */
178
178
  query(): Query<T, R>;
179
+ /** Whether any record exists, or with a condition, whether any record satisfies it; `query().any(lambda)`. */
180
+ any(lambda?: QueryLambda<T>): Promise<boolean>;
181
+ /** Whether every record satisfies the condition (true when there are none); `query().all(lambda)`. */
182
+ all(lambda: QueryLambda<T>): Promise<boolean>;
183
+ /** Whether no record exists, or with a condition, whether no record satisfies it; `query().none(lambda)`. */
184
+ none(lambda?: QueryLambda<T>): Promise<boolean>;
185
+ /** The number of records, or with a condition, of records satisfying it; `where(lambda).count()`. */
186
+ count(lambda?: QueryLambda<T>): Promise<number>;
179
187
  /**
180
188
  * A fluent query over the records the reference array `field` of record `id` lists, e.g.
181
189
  * `related(orgId, "members")`. It has no records when that record does not exist, and its `aggregate` then resolves
@@ -185,9 +193,9 @@ export interface Repository<T extends EntityRecord = EntityRecord, R = Record<ne
185
193
  }
186
194
 
187
195
  /** The read methods of a repository, the only ones offered for records the framework writes (e.g. job runs). */
188
- export const READ_METHODS = ["findById", "findOne", "list", "where", "query"] as const;
196
+ export const READ_METHODS = ["findById", "findOne", "list", "where", "query", "any", "all", "none", "count"] as const;
189
197
 
190
- /** A repository that only reads: `findById`, `findOne`, `list`, `where` and `query`. */
198
+ /** A repository that only reads: `findById`, `findOne`, `list`, `where`, `query`, `any`, `all`, `none` and `count`. */
191
199
  export type ReadRepository<T extends EntityRecord = EntityRecord, R = Record<never, never>> = Pick<Repository<T, R>, (typeof READ_METHODS)[number]>;
192
200
 
193
201
  /** The read methods of a repository, without its write methods. */
@@ -491,10 +499,10 @@ export function createRepository(
491
499
  return resolved;
492
500
  };
493
501
 
494
- const findAllMax = repositoryOptions.findAllMax ?? DEFAULT_FIND_ALL_MAX;
502
+ const toArrayMax = repositoryOptions.toArrayMax ?? DEFAULT_TO_ARRAY_MAX;
495
503
  const backend: QueryBackend = {
496
504
  model,
497
- findAllMax,
505
+ toArrayMax,
498
506
  list: (query, populate, withTotal) => listRecords(query, { populate }, withTotal),
499
507
  count: async (where) => adapter.count(model, await resolve(where)),
500
508
  aggregate: async (where, aggregates) => {
@@ -528,7 +536,7 @@ export function createRepository(
528
536
  };
529
537
  return {
530
538
  model,
531
- findAllMax,
539
+ toArrayMax,
532
540
  list: (query, populate, withTotal) => listRecords(query, { populate }, withTotal, (where) => findRelated(query, where, withTotal)),
533
541
  count: async (where) => {
534
542
  const resolved = await resolve(where);
@@ -584,6 +592,11 @@ export function createRepository(
584
592
  return createQuery(backend, { where: [], sort: [], skip: 0 });
585
593
  },
586
594
 
595
+ any: (lambda) => repository.query().any(lambda),
596
+ all: (lambda) => repository.query().all(lambda),
597
+ none: (lambda) => repository.query().none(lambda),
598
+ count: (lambda) => (lambda === undefined ? repository.query() : repository.where(lambda)).count(),
599
+
587
600
  related(id, field) {
588
601
  const definition = model.fields.find((candidate) => candidate.name === field);
589
602
  if (!definition || definition.type !== "refArray" || definition.storage !== "relation") {
@@ -58,9 +58,11 @@ function translateRelation(table: Columns, condition: FieldCondition, context: R
58
58
 
59
59
  function translate(table: Columns, condition: Condition, dialect: SqlDialect, context?: RelationContext): SQL {
60
60
  if (isConditionGroup(condition)) {
61
- // An empty AND matches everything; an empty OR matches nothing.
62
- if (condition.conditions.length === 0) return condition.group === "or" ? sql`1 = 0` : sql`1 = 1`;
61
+ // An empty AND matches everything, so its negation matches nothing; an empty OR matches nothing.
62
+ if (condition.conditions.length === 0) return condition.group === "and" ? sql`1 = 1` : sql`1 = 0`;
63
63
  const parts = condition.conditions.map((inner) => translate(table, inner, dialect, context));
64
+ // IS NOT TRUE also matches when the inner condition is NULL, as it is for comparisons with a NULL column.
65
+ if (condition.group === "not") return sql`((${and(...parts)}) is not true)`;
64
66
  return (condition.group === "or" ? or(...parts) : and(...parts))!;
65
67
  }
66
68
  const relation = context?.entity.fields.find((field) => field.name === condition.field && field.type === "refArray");
@@ -468,9 +468,9 @@ export function runAdapterContract(name: string, factory: AdapterFactory): void
468
468
  test("scope every terminal to the records of the array, in the usual default order", async () => {
469
469
  const { items, a, linked } = await setupRelated();
470
470
  const partners = items.related(linked.id, "partners");
471
- expect(names(await partners.all())).toEqual(["c", "b", "a"]);
471
+ expect(names(await partners.toArray())).toEqual(["c", "b", "a"]);
472
472
  const bigOrC = partners.where((o) => o.size.gte(15).or(o.name.eq("c")));
473
- expect(names(await bigOrC.orderBy((o) => o.name).all())).toEqual(["b", "c"]);
473
+ expect(names(await bigOrC.orderBy((o) => o.name).toArray())).toEqual(["b", "c"]);
474
474
  expect(await partners.count()).toBe(3);
475
475
  expect(await bigOrC.count()).toBe(2);
476
476
  expect(await partners.any()).toBe(true);
@@ -508,7 +508,7 @@ export function runAdapterContract(name: string, factory: AdapterFactory): void
508
508
  expect(await items.related(empty.id, "partners").aggregate(() => ({}))).toEqual({});
509
509
  for (const id of [newId(), empty.id]) {
510
510
  const partners = items.related(id, "partners");
511
- expect(await partners.all()).toEqual([]);
511
+ expect(await partners.toArray()).toEqual([]);
512
512
  expect(await partners.count()).toBe(0);
513
513
  expect(await partners.any()).toBe(false);
514
514
  expect(await partners.firstOrDefault()).toBeNull();
@@ -1,8 +1,13 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import type { DatabaseAdapter, StoredRecord } from "../adapter.ts";
3
- import { createApp } from "../app.ts";
4
- import { resolveConfig } from "../config.ts";
3
+ import type { App } from "../app.ts";
4
+ import type { ServiceConfig } from "../config.ts";
5
+ import { pathAndQueryLength } from "../features/security/limits.ts";
6
+ import { parseSize } from "../files/limits.ts";
5
7
  import { builtInFileEntity } from "../files.ts";
8
+ import type { Repositories } from "../hooks.ts";
9
+ // The package entry registers the natives, so the suite resolves them as a service does.
10
+ import { createApp, createFeatureLifecycle, resolveConfig, type FeatureLifecycle } from "../index.ts";
6
11
  import type { LogEntry } from "../logger.ts";
7
12
  import type { EntityModel } from "../model.ts";
8
13
  import { contractItem, contractOrganization, contractRecord } from "./adapter-contract.ts";
@@ -12,6 +17,11 @@ export interface AttackSuiteOptions {
12
17
  name: string;
13
18
  /** Returns a fresh, empty adapter whose `connect` creates the tables of the models it receives. */
14
19
  adapter: () => DatabaseAdapter | Promise<DatabaseAdapter>;
20
+ /**
21
+ * Merged into the config the suite resolves, like `{ security: false }` or `{ security: { limits: { url: "64KB" } } }`;
22
+ * the adapter and a quiet logger are always applied. The natives enabled by default run unless it turns them off.
23
+ */
24
+ config?: Partial<Omit<ServiceConfig, "database" | "logger">>;
15
25
  }
16
26
 
17
27
  const fileEntity = builtInFileEntity("file");
@@ -34,44 +44,117 @@ export const SQL_PAYLOADS: Payload[] = [
34
44
  { label: "a 10 KB string", value: "'; --".repeat(2048) },
35
45
  ];
36
46
 
37
- interface Harness {
47
+ /** A running app of the attack models, with the natives the config enables, called without opening a port. */
48
+ export interface AttackHarness {
38
49
  adapter: DatabaseAdapter;
39
50
  fetch(method: string, path: string, body?: string): Promise<Response>;
40
51
  /** Every stored item and file, sorted by id, to compare before and after an attack. */
41
52
  snapshot(): Promise<StoredRecord[]>;
53
+ /** Whether the security limits refuse `path` for its length: security and its `limits` item on, and the path and query over `limits.url`. */
54
+ urlTooLong(path: string): boolean;
55
+ /** Runs the shutdown sequence and disconnects the adapter. */
56
+ stop(): Promise<void>;
42
57
  }
43
58
 
44
- function setUp(options: AttackSuiteOptions): () => Harness {
45
- let harness: Harness | undefined;
46
- let adapter: DatabaseAdapter;
47
- beforeAll(async () => {
48
- adapter = await options.adapter();
59
+ const ORIGIN = "http://attack.test";
60
+
61
+ interface SecuritySettings {
62
+ limits?: { enabled?: boolean; url?: string | number };
63
+ headers?: { enabled?: boolean; contentTypeOptions?: boolean };
64
+ }
65
+
66
+ const securityOf = (features: readonly { key: string; options: unknown }[]) =>
67
+ features.find((feature) => feature.key === "security")?.options as SecuritySettings | undefined;
68
+
69
+ /** The `limits.url` of the resolved security native, in bytes; undefined when security or its `limits` item is off. */
70
+ function urlLimit(security: SecuritySettings | undefined): number | undefined {
71
+ if (!security?.limits?.enabled || security.limits.url === undefined) return undefined;
72
+ return parseSize(security.limits.url);
73
+ }
74
+
75
+ /**
76
+ * Starts the attack models on a fresh adapter as `startService` does, without listening: the enabled features' setups,
77
+ * the connection, the app with the features' routes, middleware and server settings, then the `onStart` callbacks.
78
+ */
79
+ export async function startAttackHarness(options: AttackSuiteOptions): Promise<AttackHarness> {
80
+ const adapter = await options.adapter();
81
+ const quiet = (_entry: LogEntry) => {};
82
+ const config = resolveConfig({ ...options.config, database: adapter, logger: { info: quiet, warn: quiet, error: quiet } }, {});
83
+ const lifecycle: FeatureLifecycle = createFeatureLifecycle({
84
+ features: config.features,
85
+ config,
86
+ models: attackModels,
87
+ worker: { index: 0, count: 1, runsScheduledWork: true },
88
+ endpoints: [],
89
+ crudRoutes: [],
90
+ });
91
+ let connected = false;
92
+ let app: App | undefined;
93
+ try {
94
+ await lifecycle.setup();
49
95
  await adapter.connect(attackModels);
50
- const quiet = (_entry: LogEntry) => {};
51
- const config = resolveConfig({ database: adapter, logger: { info: quiet, warn: quiet, error: quiet } }, {});
52
- const app = createApp({ models: attackModels, crudRoutes: [], endpoints: [], hooks: new Map(), config });
53
- const all = async (model: EntityModel) =>
54
- (await adapter.findMany(model, { where: [], sort: [{ field: "id", direction: "asc" }], limit: 10_000, offset: 0 })).data;
55
- harness = {
56
- adapter,
57
- fetch: (method, path, body) =>
58
- app.server.fetch(
59
- new Request(`http://attack.test${path}`, {
60
- method,
61
- ...(body === undefined ? {} : { body, headers: { "content-type": "application/json" } }),
62
- }),
63
- ),
64
- snapshot: async () => [...(await all(contractItem)), ...(await all(fileEntity))],
65
- };
96
+ connected = true;
97
+ app = createApp({
98
+ models: attackModels,
99
+ crudRoutes: [],
100
+ endpoints: [],
101
+ hooks: new Map(),
102
+ config,
103
+ runtimeRoutes: lifecycle.routes,
104
+ middleware: lifecycle.middleware,
105
+ server: lifecycle.server,
106
+ });
107
+ await lifecycle.start({ repos: app.repos as unknown as Repositories, server: app.server });
108
+ } catch (error) {
109
+ await app?.shutdown();
110
+ await lifecycle.stop();
111
+ if (connected) await adapter.disconnect();
112
+ throw error;
113
+ }
114
+ const running = app;
115
+ const security = securityOf(config.features);
116
+ const maxUrl = urlLimit(security);
117
+ // With the security headers on, every response the suite receives must carry them, refusals included.
118
+ const nosniff = security?.headers?.enabled === true && security.headers.contentTypeOptions === true;
119
+ const all = async (model: EntityModel) =>
120
+ (await adapter.findMany(model, { where: [], sort: [{ field: "id", direction: "asc" }], limit: 10_000, offset: 0 })).data;
121
+ return {
122
+ adapter,
123
+ fetch: async (method, path, body) => {
124
+ const response = await running.server.fetch(
125
+ new Request(`${ORIGIN}${path}`, {
126
+ method,
127
+ ...(body === undefined ? {} : { body, headers: { "content-type": "application/json" } }),
128
+ }),
129
+ );
130
+ if (nosniff) expect(response.headers.get("x-content-type-options"), `${method} ${path.slice(0, 80)}`).toBe("nosniff");
131
+ return response;
132
+ },
133
+ snapshot: async () => [...(await all(contractItem)), ...(await all(fileEntity))],
134
+ urlTooLong: (path) => maxUrl !== undefined && pathAndQueryLength(new URL(path, ORIGIN).href) > maxUrl,
135
+ async stop() {
136
+ await lifecycle.shutdown();
137
+ await running.shutdown();
138
+ await lifecycle.stop();
139
+ await adapter.disconnect();
140
+ },
141
+ };
142
+ }
143
+
144
+ /** Starts a harness before the first test of the enclosing group and stops it after the last. */
145
+ function setUp(options: AttackSuiteOptions): () => AttackHarness {
146
+ let harness: AttackHarness | undefined;
147
+ beforeAll(async () => {
148
+ harness = await startAttackHarness(options);
66
149
  });
67
150
  afterAll(async () => {
68
- await adapter?.disconnect();
151
+ await harness?.stop();
69
152
  });
70
153
  return () => harness!;
71
154
  }
72
155
 
73
156
  /** Creates items through the generated route, as a client would. */
74
- async function seedItems(harness: Harness, titles: string[]): Promise<StoredRecord[]> {
157
+ async function seedItems(harness: AttackHarness, titles: string[]): Promise<StoredRecord[]> {
75
158
  const stored: StoredRecord[] = [];
76
159
  for (const title of titles) {
77
160
  const response = await harness.fetch("POST", "/items", JSON.stringify({ title }));
@@ -99,7 +182,7 @@ async function expectValidationError(response: Response, message?: string): Prom
99
182
  }
100
183
 
101
184
  /** Runs a read attack and asserts that it neither failed with a 500 nor changed any stored record. */
102
- async function readAttack(harness: Harness, path: string, method = "GET"): Promise<Response> {
185
+ async function readAttack(harness: AttackHarness, path: string, method = "GET"): Promise<Response> {
103
186
  const before = await harness.snapshot();
104
187
  const response = await harness.fetch(method, path);
105
188
  const clone = response.clone() as Response;
@@ -108,6 +191,24 @@ async function readAttack(harness: Harness, path: string, method = "GET"): Promi
108
191
  return clone;
109
192
  }
110
193
 
194
+ /**
195
+ * Sends a payload in the URL: over the security `limits.url` it expects 414 `URL is too long` and an unchanged
196
+ * snapshot; otherwise it hands the response to `expectations`. Never a 500, never a changed record.
197
+ */
198
+ export async function urlAttack(harness: AttackHarness, method: string, path: string, expectations: (response: Response) => Promise<void> | void, body?: string): Promise<void> {
199
+ const before = await harness.snapshot();
200
+ const response = await harness.fetch(method, path, body);
201
+ const clone = response.clone() as Response;
202
+ const parsed = (await expectNot500(response)) as { error?: { message?: string } } | undefined;
203
+ expect(await harness.snapshot()).toEqual(before);
204
+ if (harness.urlTooLong(path)) {
205
+ expect(clone.status).toBe(414);
206
+ expect(parsed?.error?.message).toBe("URL is too long");
207
+ return;
208
+ }
209
+ await expectations(clone);
210
+ }
211
+
111
212
  async function listedIds(response: Response): Promise<string[]> {
112
213
  expect(response.status).toBe(200);
113
214
  const body = (await response.json()) as { data: { id: string }[] };
@@ -143,23 +244,18 @@ export function runAttackSuite(options: AttackSuiteOptions): void {
143
244
  ];
144
245
  for (const [op, raw, matches] of filters) {
145
246
  test(`sql: ${op} with ${label}`, async () => {
146
- const response = await readAttack(harness(), `/items${query({ [`where[title][${op}]`]: raw })}`);
147
- if (refused) await expectValidationError(response);
148
- else expect(await listedIds(response)).toEqual(idsOf(seeded, matches));
247
+ await urlAttack(harness(), "GET", `/items${query({ [`where[title][${op}]`]: raw })}`, async (response) => {
248
+ if (refused) await expectValidationError(response);
249
+ else expect(await listedIds(response)).toEqual(idsOf(seeded, matches));
250
+ });
149
251
  });
150
252
  }
151
253
 
152
254
  test(`sql: id with ${label}`, async () => {
153
255
  const path = `/items/${encodeURIComponent(value)}`;
154
- for (const method of ["GET", "DELETE"]) {
155
- const response = await readAttack(harness(), path, method);
156
- expect([400, 404]).toContain(response.status);
157
- }
158
- const before = await harness().snapshot();
159
- const patched = await harness().fetch("PATCH", path, JSON.stringify({ title: "changed" }));
160
- await expectNot500(patched);
161
- expect([400, 404]).toContain(patched.status);
162
- expect(await harness().snapshot()).toEqual(before);
256
+ const missing = (response: Response) => void expect([400, 404]).toContain(response.status);
257
+ for (const method of ["GET", "DELETE"]) await urlAttack(harness(), method, path, missing);
258
+ await urlAttack(harness(), "PATCH", path, missing, JSON.stringify({ title: "changed" }));
163
259
  });
164
260
 
165
261
  test(`sql: body with ${label}`, async () => {