@filelayer/core 0.4.1 → 0.4.2
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 +221 -140
- package/README.md +6 -8
- package/dist/filelayer.d.ts +22 -9
- package/dist/filelayer.d.ts.map +1 -1
- package/dist/filelayer.js +21 -2
- package/dist/filelayer.js.map +1 -1
- package/dist/simple.d.ts +1 -1
- package/dist/store.d.ts +98 -7
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +115 -18
- package/dist/store.js.map +1 -1
- package/package.json +1 -1
- package/src/filelayer.ts +25 -11
- package/src/store.ts +243 -28
- package/test/audit-resolution.test.ts +379 -0
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "WHO TOUCHED THIS?" -- ANSWERED IN THE CALLER'S OWN WORDS.
|
|
3
|
+
*
|
|
4
|
+
* THE DEFECT. `auditLog()` answered the marquee question in internal uuids and
|
|
5
|
+
* shipped no public way back. A developer who wrote `as: 'marco'` and
|
|
6
|
+
* `org: 'acme'` got `97cf1649-dd8...` back and had to read `schema.sql`, find
|
|
7
|
+
* the `actor` table and write SQL against it to turn the answer into the words
|
|
8
|
+
* they had used ninety seconds earlier. It cost the first developer to try it
|
|
9
|
+
* about eight minutes, on the one step the product exists to make good.
|
|
10
|
+
*
|
|
11
|
+
* WHAT THIS SUITE HOLDS DOWN. Six things, in the order they would be missed:
|
|
12
|
+
*
|
|
13
|
+
* 1. The answer is legible. The external ids the caller supplied come back,
|
|
14
|
+
* for the actor, the file and the org.
|
|
15
|
+
* 2. The internal ids are still there, unchanged. This was additive; anything
|
|
16
|
+
* already reading `actorId` keeps working.
|
|
17
|
+
* 3. A denial carries its reason AND the actor who was refused. This is the
|
|
18
|
+
* value moment -- `reason=grant_revoked` while the link still had six days
|
|
19
|
+
* of validity left -- and it is the row most likely to be read by a human
|
|
20
|
+
* under time pressure.
|
|
21
|
+
* 4. Anonymous access says `anonymous`, not `null`. A link redemption has no
|
|
22
|
+
* actor; the answer must say so rather than hand back a null and leave the
|
|
23
|
+
* caller to decide what it meant.
|
|
24
|
+
* 5. The system chain (`org_id IS NULL`) resolves rather than throwing.
|
|
25
|
+
* 6. Resolution is project-scoped (P8) and costs ONE query, not N+1.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import assert from 'node:assert/strict';
|
|
29
|
+
import { describe, it } from 'node:test';
|
|
30
|
+
|
|
31
|
+
import { createTestDb, type Queryable } from '../src/db.ts';
|
|
32
|
+
import { Filelayer } from '../src/filelayer.ts';
|
|
33
|
+
import { MemoryStorage } from '../src/storage.ts';
|
|
34
|
+
import { DEFAULT_PROJECT_ID } from '../src/store.ts';
|
|
35
|
+
import { bytes, rejects } from './helpers.ts';
|
|
36
|
+
|
|
37
|
+
const CONTRACT = bytes('a contract nobody else may read');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The scenario the first outside developer built, reduced to the parts that
|
|
41
|
+
* bear on the audit answer.
|
|
42
|
+
*
|
|
43
|
+
* alice owns `acme`; `contract.pdf` is private in it; an external share link
|
|
44
|
+
* carries seven days of validity and a cap of three; marco -- a named member --
|
|
45
|
+
* is separately given read. Both are then revoked and both are refused. That
|
|
46
|
+
* second refusal is the value moment: the reason is `grant_revoked` while the
|
|
47
|
+
* link still has six days and two opens left.
|
|
48
|
+
*/
|
|
49
|
+
async function scenario(fl: Filelayer) {
|
|
50
|
+
await fl.orgs.create('acme', { owner: 'alice' });
|
|
51
|
+
const file = await fl.files.put(CONTRACT, {
|
|
52
|
+
org: 'acme',
|
|
53
|
+
owner: 'alice',
|
|
54
|
+
name: 'contract.pdf',
|
|
55
|
+
contentType: 'application/pdf',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// The external share link, redeemed once, then revoked.
|
|
59
|
+
const link = await fl.shares.create(file.id, {
|
|
60
|
+
as: 'alice',
|
|
61
|
+
expiresIn: 7 * 86400,
|
|
62
|
+
maxDownloads: 3,
|
|
63
|
+
});
|
|
64
|
+
await fl.shares.redeem(link.secret!);
|
|
65
|
+
await fl.shares.revoke(link.grantId, { as: 'alice' });
|
|
66
|
+
await rejects(() => fl.shares.redeem(link.secret!), 404);
|
|
67
|
+
|
|
68
|
+
// The named recipient, who reads it, then loses it.
|
|
69
|
+
await fl.orgs.setRole('acme', 'marco', 'member', { as: 'alice' });
|
|
70
|
+
const direct = await fl.shares.create(file.id, { as: 'alice', withUser: 'marco' });
|
|
71
|
+
await fl.files.get(file.id, { as: 'marco' });
|
|
72
|
+
await fl.shares.revoke(direct.grantId, { as: 'alice' });
|
|
73
|
+
await rejects(() => fl.files.get(file.id, { as: 'marco' }), 404);
|
|
74
|
+
|
|
75
|
+
return { file, link, direct };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// =============================================================================
|
|
79
|
+
// 1. THE ANSWER IS LEGIBLE
|
|
80
|
+
// =============================================================================
|
|
81
|
+
|
|
82
|
+
describe('auditLog answers in the identifiers the caller supplied', () => {
|
|
83
|
+
it('names marco, contract.pdf and acme -- not three uuids', async () => {
|
|
84
|
+
const fl = await Filelayer.quickstart();
|
|
85
|
+
const { file } = await scenario(fl);
|
|
86
|
+
|
|
87
|
+
const log = await fl.orgs.audit('acme', { as: 'alice' });
|
|
88
|
+
assert.ok(log.length > 0, 'the trail is empty');
|
|
89
|
+
|
|
90
|
+
// The external ids the developer actually typed are in the answer.
|
|
91
|
+
const actors = new Set(log.map((e) => e.actor.label));
|
|
92
|
+
assert.ok(actors.has('marco'), `no "marco" in the trail, got ${[...actors].join(', ')}`);
|
|
93
|
+
assert.ok(actors.has('alice'));
|
|
94
|
+
assert.ok(
|
|
95
|
+
log.every((e) => e.org.label === 'acme'),
|
|
96
|
+
'every event in acme\'s chain should say acme',
|
|
97
|
+
);
|
|
98
|
+
assert.ok(
|
|
99
|
+
log.some((e) => e.file.label === 'contract.pdf'),
|
|
100
|
+
'the file is never named',
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// ...and they are on the typed fields, not only in the summary line.
|
|
104
|
+
const marco = log.find((e) => e.actor.externalId === 'marco')!;
|
|
105
|
+
assert.ok(marco, 'no event carries marco as an external id');
|
|
106
|
+
assert.equal(marco.actor.resolution, 'resolved');
|
|
107
|
+
assert.equal(marco.org.externalId, 'acme');
|
|
108
|
+
assert.equal(marco.org.resolution, 'resolved');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('keeps every internal id it has always returned, unchanged', async () => {
|
|
112
|
+
// This is a PRESENTATION change. Something downstream may well be keyed on
|
|
113
|
+
// the uuids, and replacing them rather than joining them would be a
|
|
114
|
+
// breaking change wearing a usability costume.
|
|
115
|
+
const fl = await Filelayer.quickstart();
|
|
116
|
+
const { file } = await scenario(fl);
|
|
117
|
+
|
|
118
|
+
const log = await fl.orgs.audit('acme', { as: 'alice' });
|
|
119
|
+
const plain = await fl.store.listAudit(
|
|
120
|
+
(await fl.store.db.query<{ id: string }>(
|
|
121
|
+
`SELECT id FROM org WHERE external_id = 'acme'`,
|
|
122
|
+
)).rows[0]!.id,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
assert.equal(log.length, plain.length);
|
|
126
|
+
for (let i = 0; i < log.length; i++) {
|
|
127
|
+
const a = log[i]!;
|
|
128
|
+
const b = plain[i]!;
|
|
129
|
+
assert.equal(a.id, b.id);
|
|
130
|
+
assert.equal(a.actorId, b.actorId, 'actorId must still be the uuid');
|
|
131
|
+
assert.equal(a.fileId, b.fileId);
|
|
132
|
+
assert.equal(a.orgId, b.orgId);
|
|
133
|
+
assert.equal(a.hash, b.hash, 'the chain digest is untouched by presentation');
|
|
134
|
+
// and the resolved view agrees with the id it was resolved from
|
|
135
|
+
assert.equal(a.actor.id, b.actorId);
|
|
136
|
+
assert.equal(a.file.id, b.fileId);
|
|
137
|
+
assert.equal(a.org.id, b.orgId);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The chain still verifies -- nothing here writes.
|
|
141
|
+
assert.equal((await fl.orgs.verifyAudit('acme', { as: 'alice' })).valid, true);
|
|
142
|
+
assert.ok(file.id);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('every row prints without a null, an undefined or a uuid in it', async () => {
|
|
146
|
+
const fl = await Filelayer.quickstart();
|
|
147
|
+
await scenario(fl);
|
|
148
|
+
const log = await fl.orgs.audit('acme', { as: 'alice' });
|
|
149
|
+
|
|
150
|
+
for (const e of log) {
|
|
151
|
+
assert.equal(typeof e.summary, 'string');
|
|
152
|
+
assert.ok(e.summary.length > 0);
|
|
153
|
+
assert.ok(!/\bundefined\b|\bnull\b/.test(e.summary), `unprintable summary: ${e.summary}`);
|
|
154
|
+
for (const ref of [e.actor, e.file, e.org]) {
|
|
155
|
+
assert.equal(typeof ref.label, 'string', 'a label may never be null');
|
|
156
|
+
assert.ok(ref.label.length > 0);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// =============================================================================
|
|
163
|
+
// 2. THE VALUE MOMENT: A DENIAL, ITS REASON, AND WHO WAS REFUSED
|
|
164
|
+
// =============================================================================
|
|
165
|
+
|
|
166
|
+
describe('a denial names the reason and the principal who was refused', () => {
|
|
167
|
+
it('the revoked link reads as deny:grant_revoked against contract.pdf in acme', async () => {
|
|
168
|
+
const fl = await Filelayer.quickstart();
|
|
169
|
+
await scenario(fl);
|
|
170
|
+
|
|
171
|
+
const denials = await fl.orgs.audit('acme', { as: 'alice', decision: 'deny' });
|
|
172
|
+
const refusal = denials.find((e) => e.reason === 'grant_revoked');
|
|
173
|
+
assert.ok(refusal, `no grant_revoked denial; reasons: ${denials.map((d) => d.reason)}`);
|
|
174
|
+
assert.equal(refusal.decision, 'deny');
|
|
175
|
+
assert.equal(refusal.file.label, 'contract.pdf');
|
|
176
|
+
assert.equal(refusal.file.name, 'contract.pdf');
|
|
177
|
+
assert.equal(refusal.org.label, 'acme');
|
|
178
|
+
// The link had no actor behind it, and the row says so out loud.
|
|
179
|
+
assert.equal(refusal.actor.label, 'anonymous');
|
|
180
|
+
|
|
181
|
+
// The one line an operator reads under time pressure.
|
|
182
|
+
assert.match(
|
|
183
|
+
refusal.summary,
|
|
184
|
+
/^\S+ anonymous file\.read deny:grant_revoked contract\.pdf @acme$/,
|
|
185
|
+
`summary reads: ${refusal.summary}`,
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('a NAMED principal who is refused is named, with the reason', async () => {
|
|
190
|
+
const fl = await Filelayer.quickstart();
|
|
191
|
+
await scenario(fl);
|
|
192
|
+
|
|
193
|
+
const denials = await fl.orgs.audit('acme', { as: 'alice', decision: 'deny' });
|
|
194
|
+
const marco = denials.find((e) => e.actor.externalId === 'marco');
|
|
195
|
+
assert.ok(marco, 'marco was refused and the log does not say it was marco');
|
|
196
|
+
assert.equal(marco.actor.label, 'marco');
|
|
197
|
+
assert.equal(marco.actor.resolution, 'resolved');
|
|
198
|
+
assert.equal(marco.actor.id, marco.actorId, 'the uuid is still there beside the name');
|
|
199
|
+
assert.ok(marco.reason, 'a denial with no reason answers nothing');
|
|
200
|
+
assert.equal(marco.file.label, 'contract.pdf');
|
|
201
|
+
assert.match(marco.summary, new RegExp(` marco file\\.read deny:${marco.reason} `));
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('filtering by decision still narrows, and every denial is legible', async () => {
|
|
205
|
+
const fl = await Filelayer.quickstart();
|
|
206
|
+
await scenario(fl);
|
|
207
|
+
const denials = await fl.orgs.audit('acme', { as: 'alice', decision: 'deny' });
|
|
208
|
+
assert.ok(denials.length >= 2);
|
|
209
|
+
assert.ok(denials.every((e) => e.decision === 'deny'));
|
|
210
|
+
assert.ok(denials.every((e) => e.summary.includes('deny:')));
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// =============================================================================
|
|
215
|
+
// 3. ANONYMOUS IS A FACT, NOT A MISSING VALUE
|
|
216
|
+
// =============================================================================
|
|
217
|
+
|
|
218
|
+
describe('access with no actor is represented honestly', () => {
|
|
219
|
+
it('a share-link redemption reads as `anonymous`, not as null', async () => {
|
|
220
|
+
const fl = await Filelayer.quickstart();
|
|
221
|
+
await fl.orgs.create('acme', { owner: 'alice' });
|
|
222
|
+
const file = await fl.files.put(CONTRACT, {
|
|
223
|
+
org: 'acme',
|
|
224
|
+
owner: 'alice',
|
|
225
|
+
name: 'contract.pdf',
|
|
226
|
+
});
|
|
227
|
+
const share = await fl.shares.create(file.id, { as: 'alice', maxDownloads: 3 });
|
|
228
|
+
await fl.shares.redeem(share.secret!);
|
|
229
|
+
|
|
230
|
+
const log = await fl.orgs.audit('acme', { as: 'alice' });
|
|
231
|
+
const anon = log.filter((e) => e.actorId === null);
|
|
232
|
+
assert.ok(anon.length > 0, 'the redemption left no actor-less event');
|
|
233
|
+
|
|
234
|
+
for (const e of anon) {
|
|
235
|
+
assert.equal(e.actor.id, null);
|
|
236
|
+
assert.equal(e.actor.externalId, null, 'nothing may be invented for a link');
|
|
237
|
+
assert.equal(e.actor.label, 'anonymous');
|
|
238
|
+
assert.equal(e.actor.resolution, 'anonymous');
|
|
239
|
+
assert.ok(e.summary.includes(' anonymous '), e.summary);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('an event that is not about a file says so, and no file label is fabricated', async () => {
|
|
244
|
+
const fl = await Filelayer.quickstart();
|
|
245
|
+
await fl.orgs.create('acme', { owner: 'alice' });
|
|
246
|
+
await fl.orgs.setRole('acme', 'marco', 'member', { as: 'alice' });
|
|
247
|
+
|
|
248
|
+
const log = await fl.orgs.audit('acme', { as: 'alice' });
|
|
249
|
+
const membership = log.find((e) => e.action === 'member.add' && e.fileId === null);
|
|
250
|
+
assert.ok(membership, 'no fileless member.add event');
|
|
251
|
+
assert.equal(membership.file.id, null);
|
|
252
|
+
assert.equal(membership.file.name, null);
|
|
253
|
+
assert.equal(membership.file.label, 'none');
|
|
254
|
+
assert.equal(membership.file.resolution, 'none');
|
|
255
|
+
// ...and the summary simply omits it rather than printing "none".
|
|
256
|
+
assert.ok(!membership.summary.includes(' none '), membership.summary);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// =============================================================================
|
|
261
|
+
// 4. THE SYSTEM CHAIN HAS NO TENANT AND MUST NOT CRASH
|
|
262
|
+
// =============================================================================
|
|
263
|
+
|
|
264
|
+
describe('the system chain (org_id IS NULL) resolves rather than throwing', () => {
|
|
265
|
+
it('reads back with org.label = "system"', async () => {
|
|
266
|
+
const fl = await Filelayer.quickstart();
|
|
267
|
+
await fl.orgs.create('acme', { owner: 'alice' });
|
|
268
|
+
// A sweep against a link secret that resolves to nothing: no file, no
|
|
269
|
+
// tenant. This is exactly the event that has no org to name.
|
|
270
|
+
await rejects(() => fl.shares.redeem('not-a-real-secret'), 404);
|
|
271
|
+
|
|
272
|
+
const system = await fl.store.listAuditResolved(null);
|
|
273
|
+
assert.ok(system.length > 0, 'the probe was not recorded on the system chain');
|
|
274
|
+
for (const e of system) {
|
|
275
|
+
assert.equal(e.orgId, null);
|
|
276
|
+
assert.equal(e.org.id, null);
|
|
277
|
+
assert.equal(e.org.externalId, null);
|
|
278
|
+
assert.equal(e.org.label, 'system');
|
|
279
|
+
assert.equal(e.org.resolution, 'system');
|
|
280
|
+
assert.ok(e.summary.endsWith('@system'), e.summary);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// =============================================================================
|
|
286
|
+
// 5. P8: RESOLUTION MAY NOT CROSS A PROJECT BOUNDARY
|
|
287
|
+
// =============================================================================
|
|
288
|
+
|
|
289
|
+
describe('resolution is scoped to the project, like every other read', () => {
|
|
290
|
+
it('an id belonging to another customer resolves to nothing, not to their word for it', async () => {
|
|
291
|
+
// An audit event may legitimately name an identifier from outside the
|
|
292
|
+
// project -- a probe at a uuid that belongs to another application. If the
|
|
293
|
+
// join were unscoped, tenant A's audit log would print tenant B's customer
|
|
294
|
+
// vocabulary back at them, which is P8 reopened on a presentation axis.
|
|
295
|
+
const { db } = await createTestDb();
|
|
296
|
+
const storage = new MemoryStorage();
|
|
297
|
+
const a = new Filelayer(db, storage, { baseUrl: 'https://a.test' });
|
|
298
|
+
const pb = (await a.createProject('customer-b', 'Customer B')).id;
|
|
299
|
+
const b = new Filelayer(db, storage, { baseUrl: 'https://b.test', projectId: pb });
|
|
300
|
+
|
|
301
|
+
await a.orgs.create('acme', { owner: 'alice' });
|
|
302
|
+
const fileA = await a.files.put(bytes('A CONFIDENTIAL'), {
|
|
303
|
+
org: 'acme',
|
|
304
|
+
owner: 'alice',
|
|
305
|
+
name: 'contract.pdf',
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Customer B has their own 'mallory'. Their uuid is a perfectly well-formed
|
|
309
|
+
// id, and presenting it against customer A's file is a denial that A's log
|
|
310
|
+
// must record -- by uuid.
|
|
311
|
+
await b.orgs.create('bcorp', { owner: 'mallory' });
|
|
312
|
+
const mallory = (
|
|
313
|
+
await db.query<{ id: string }>(
|
|
314
|
+
`SELECT id FROM actor WHERE external_id = 'mallory' AND project_id = $1`,
|
|
315
|
+
[pb],
|
|
316
|
+
)
|
|
317
|
+
).rows[0]!.id;
|
|
318
|
+
|
|
319
|
+
await rejects(() => a.read({ actorId: mallory }, fileA.id), 404);
|
|
320
|
+
|
|
321
|
+
const denials = await a.orgs.audit('acme', { as: 'alice', decision: 'deny' });
|
|
322
|
+
const probe = denials.find((e) => e.actorId === mallory);
|
|
323
|
+
assert.ok(probe, 'the cross-project probe was not recorded in acme\'s chain');
|
|
324
|
+
assert.equal(probe.actor.externalId, null, 'another customer\'s id space leaked');
|
|
325
|
+
assert.notEqual(probe.actor.label, 'mallory');
|
|
326
|
+
assert.equal(probe.actor.label, mallory, 'an unresolvable id keeps its uuid');
|
|
327
|
+
assert.equal(probe.actor.resolution, 'unresolved');
|
|
328
|
+
|
|
329
|
+
// The same must hold from B's side for A's identifiers.
|
|
330
|
+
assert.equal(a.projectId, DEFAULT_PROJECT_ID);
|
|
331
|
+
assert.equal(b.projectId, pb);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// =============================================================================
|
|
336
|
+
// 6. ONE QUERY, NOT N+1
|
|
337
|
+
// =============================================================================
|
|
338
|
+
|
|
339
|
+
describe('the resolved read costs one query', () => {
|
|
340
|
+
it('a trail of many events is still a single statement', async () => {
|
|
341
|
+
const { db } = await createTestDb();
|
|
342
|
+
let recording = false;
|
|
343
|
+
const seen: string[] = [];
|
|
344
|
+
const counting: Queryable = {
|
|
345
|
+
query: (sql, params) => {
|
|
346
|
+
if (recording) seen.push(sql);
|
|
347
|
+
return db.query(sql, params);
|
|
348
|
+
},
|
|
349
|
+
...(db.withTransaction ? { withTransaction: db.withTransaction.bind(db) } : {}),
|
|
350
|
+
};
|
|
351
|
+
const fl = new Filelayer(counting, new MemoryStorage(), { baseUrl: 'https://one.test' });
|
|
352
|
+
|
|
353
|
+
await fl.orgs.create('acme', { owner: 'alice' });
|
|
354
|
+
for (let i = 0; i < 12; i++) {
|
|
355
|
+
const f = await fl.files.put(bytes(`doc ${i}`), {
|
|
356
|
+
org: 'acme',
|
|
357
|
+
owner: 'alice',
|
|
358
|
+
name: `doc-${i}.txt`,
|
|
359
|
+
});
|
|
360
|
+
await fl.files.get(f.id, { as: 'alice' });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const orgId = (
|
|
364
|
+
await db.query<{ id: string }>(`SELECT id FROM org WHERE external_id = 'acme'`)
|
|
365
|
+
).rows[0]!.id;
|
|
366
|
+
|
|
367
|
+
recording = true;
|
|
368
|
+
const log = await fl.store.listAuditResolved(orgId);
|
|
369
|
+
recording = false;
|
|
370
|
+
|
|
371
|
+
assert.ok(log.length >= 24, `expected a substantial trail, got ${log.length}`);
|
|
372
|
+
assert.equal(
|
|
373
|
+
seen.length,
|
|
374
|
+
1,
|
|
375
|
+
`resolution issued ${seen.length} statements for ${log.length} rows -- this is the N+1 the joins exist to avoid`,
|
|
376
|
+
);
|
|
377
|
+
assert.match(seen[0]!, /LEFT JOIN/);
|
|
378
|
+
});
|
|
379
|
+
});
|