@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 +1 -1
- package/src/adapters/mongodb.ts +4 -2
- package/src/adapters/postgres-conditions.ts +10 -0
- package/src/app.ts +1 -1
- package/src/config.ts +6 -2
- package/src/environment.ts +1 -1
- package/src/fluent-query.ts +44 -14
- package/src/matching.ts +3 -1
- package/src/query.ts +5 -2
- package/src/repository.ts +21 -8
- package/src/sql/conditions.ts +4 -2
- package/src/testing/adapter-contract.ts +3 -3
- package/src/testing/attack-suite.ts +135 -39
package/package.json
CHANGED
package/src/adapters/mongodb.ts
CHANGED
|
@@ -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 === "
|
|
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
|
-
|
|
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 `
|
|
74
|
-
|
|
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;
|
package/src/environment.ts
CHANGED
|
@@ -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.
|
|
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" } },
|
package/src/fluent-query.ts
CHANGED
|
@@ -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 `
|
|
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 `
|
|
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.
|
|
299
|
-
|
|
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
|
-
/**
|
|
307
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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 >
|
|
410
|
-
throw new Error(`Query on ${model.entityName} matched more than repositories.
|
|
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
|
-
|
|
424
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 `
|
|
72
|
-
|
|
71
|
+
/** Most records a fluent query's `toArray()` may return (default 10000). */
|
|
72
|
+
toArrayMax?: number;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
export const
|
|
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 `
|
|
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
|
|
502
|
+
const toArrayMax = repositoryOptions.toArrayMax ?? DEFAULT_TO_ARRAY_MAX;
|
|
495
503
|
const backend: QueryBackend = {
|
|
496
504
|
model,
|
|
497
|
-
|
|
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
|
-
|
|
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") {
|
package/src/sql/conditions.ts
CHANGED
|
@@ -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 === "
|
|
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.
|
|
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).
|
|
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.
|
|
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 {
|
|
4
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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 () => {
|