Skip to content

Commit cb1ea8a

Browse files
authored
refactor(NODE-4685): unified spec runner to support logging tests (#3578)
1 parent 8e87e5c commit cb1ea8a

File tree

9 files changed

+541
-15
lines changed

9 files changed

+541
-15
lines changed

src/connection_string.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -531,7 +531,8 @@ export function parseOptions(
531531
MONGODB_LOG_CONNECTION: process.env.MONGODB_LOG_CONNECTION,
532532
MONGODB_LOG_ALL: process.env.MONGODB_LOG_ALL,
533533
MONGODB_LOG_MAX_DOCUMENT_LENGTH: process.env.MONGODB_LOG_MAX_DOCUMENT_LENGTH,
534-
MONGODB_LOG_PATH: process.env.MONGODB_LOG_PATH
534+
MONGODB_LOG_PATH: process.env.MONGODB_LOG_PATH,
535+
...mongoOptions[Symbol.for('@@mdb.internalLoggerConfig')]
535536
};
536537
loggerClientOptions = {
537538
mongodbLogPath: mongoOptions.mongodbLogPath
@@ -1282,7 +1283,10 @@ export const OPTIONS = {
12821283
index: { type: 'any' },
12831284
// Legacy Options, these are unused but left here to avoid errors with CSFLE lib
12841285
useNewUrlParser: { type: 'boolean' } as OptionDescriptor,
1285-
useUnifiedTopology: { type: 'boolean' } as OptionDescriptor
1286+
useUnifiedTopology: { type: 'boolean' } as OptionDescriptor,
1287+
// MongoLogger
1288+
// TODO(NODE-4849): Tighten the type of mongodbLogPath
1289+
mongodbLogPath: { type: 'any' }
12861290
} as Record<keyof MongoClientOptions, OptionDescriptor>;
12871291

12881292
export const DEFAULT_OPTIONS = new CaseInsensitiveMap(

src/mongo_logger.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,24 @@ export const SeverityLevel = Object.freeze({
1919
/** @internal */
2020
export type SeverityLevel = (typeof SeverityLevel)[keyof typeof SeverityLevel];
2121

22+
/** @internal */
23+
export const SeverityLevelMap: Map<string | number, string | number> = new Map([
24+
[SeverityLevel.OFF, -Infinity],
25+
[SeverityLevel.EMERGENCY, 0],
26+
[SeverityLevel.ALERT, 1],
27+
[SeverityLevel.CRITICAL, 2],
28+
[SeverityLevel.ERROR, 3],
29+
[SeverityLevel.WARNING, 4],
30+
[SeverityLevel.NOTICE, 5],
31+
[SeverityLevel.INFORMATIONAL, 6],
32+
[SeverityLevel.DEBUG, 7],
33+
[SeverityLevel.TRACE, 8]
34+
]);
35+
36+
for (const [level, value] of SeverityLevelMap) {
37+
SeverityLevelMap.set(value, level);
38+
}
39+
2240
/** @internal */
2341
export const MongoLoggableComponent = Object.freeze({
2442
COMMAND: 'command',

test/integration/command-logging-and-monitoring/command_monitoring.spec.test.ts renamed to test/integration/command-logging-and-monitoring/command_logging_and_monitoring.spec.test.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,18 @@ import { runUnifiedSuite } from '../../tools/unified-spec-runner/runner';
1111
// the server. There's nothing we can change here.
1212
const SKIP = ['A successful unordered bulk write with an unacknowledged write concern'];
1313

14-
describe('Command Monitoring Spec (unified)', () => {
15-
runUnifiedSuite(
16-
loadSpecTests(path.join('command-logging-and-monitoring', 'monitoring')),
17-
({ description }) =>
18-
SKIP.includes(description) ? `TODO(NODE-4261): support skip reasons in unified tests` : false
19-
);
14+
describe('Command Logging and Monitoring Spec', function () {
15+
describe('Command Monitoring Spec (unified)', () => {
16+
runUnifiedSuite(
17+
loadSpecTests(path.join('command-logging-and-monitoring', 'monitoring')),
18+
({ description }) =>
19+
SKIP.includes(description)
20+
? `TODO(NODE-4261): support skip reasons in unified tests`
21+
: false
22+
);
23+
});
24+
25+
describe.skip('Command Logging Spec', () => {
26+
runUnifiedSuite(loadSpecTests(path.join('command-logging-and-monitoring', 'logging')));
27+
}).skipReason = 'TODO(NODE-4686): Unskip these tests';
2028
});

test/tools/unified-spec-runner/entities.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* eslint-disable @typescript-eslint/no-non-null-assertion */
22
import { expect } from 'chai';
33
import { EventEmitter } from 'events';
4+
import { Writable } from 'stream';
45

56
import {
67
AbstractCursor,
@@ -37,7 +38,12 @@ import {
3738
import { ejson, getEnvironmentalOptions } from '../../tools/utils';
3839
import type { TestConfiguration } from '../runner/config';
3940
import { trace } from './runner';
40-
import type { ClientEncryption, ClientEntity, EntityDescription } from './schema';
41+
import type {
42+
ClientEncryption,
43+
ClientEntity,
44+
EntityDescription,
45+
ExpectedLogMessage
46+
} from './schema';
4147
import {
4248
createClientEncryption,
4349
makeConnectionString,
@@ -95,16 +101,40 @@ export type CmapEvent =
95101
| ConnectionCheckedInEvent
96102
| ConnectionPoolClearedEvent;
97103
export type SdamEvent = ServerDescriptionChangedEvent;
104+
export type LogMessage = Omit<ExpectedLogMessage, 'failureIsRedacted'>;
98105

99106
function getClient(address) {
100107
return new MongoClient(`mongodb://${address}`, getEnvironmentalOptions());
101108
}
102109

110+
// TODO(NODE-4813): Remove this class in favour of a simple object with a write method
111+
/* TODO(NODE-4813): Ensure that the object that we replace this with has logic to convert the
112+
* collected log into the format require by the unified spec runner
113+
* (see ExpectedLogMessage type in schema.ts) */
114+
export class UnifiedLogCollector extends Writable {
115+
collectedLogs: LogMessage[] = [];
116+
117+
constructor() {
118+
super({ objectMode: true });
119+
}
120+
121+
_write(
122+
log: LogMessage,
123+
_: string,
124+
callback: (e: Error | null, l: LogMessage | undefined) => void
125+
) {
126+
this.collectedLogs.push(log);
127+
callback(null, log);
128+
}
129+
}
130+
103131
export class UnifiedMongoClient extends MongoClient {
104132
commandEvents: CommandEvent[] = [];
105133
cmapEvents: CmapEvent[] = [];
106134
sdamEvents: SdamEvent[] = [];
107135
failPoints: Document[] = [];
136+
logCollector: UnifiedLogCollector;
137+
108138
ignoredEvents: string[];
109139
observedCommandEvents: ('commandStarted' | 'commandSucceeded' | 'commandFailed')[];
110140
observedCmapEvents: (
@@ -148,13 +178,35 @@ export class UnifiedMongoClient extends MongoClient {
148178
serverDescriptionChangedEvent: 'serverDescriptionChanged'
149179
} as const;
150180

181+
static LOGGING_COMPONENT_TO_ENV_VAR_NAME = {
182+
command: 'MONGODB_LOG_COMMAND',
183+
serverSelection: 'MONGODB_LOG_SERVER_SELECTION',
184+
connection: 'MONGODB_LOG_CONNECTION',
185+
topology: 'MONGODB_LOG_TOPOLOGY'
186+
} as const;
187+
151188
constructor(uri: string, description: ClientEntity) {
189+
const logCollector = new UnifiedLogCollector();
190+
const componentSeverities = {
191+
MONGODB_LOG_ALL: 'off'
192+
};
193+
194+
// NOTE: this is done to override the logger environment variables
195+
for (const key in description.observeLogMessages) {
196+
componentSeverities[UnifiedMongoClient.LOGGING_COMPONENT_TO_ENV_VAR_NAME[key]] =
197+
description.observeLogMessages[key];
198+
}
199+
152200
super(uri, {
153201
monitorCommands: true,
154202
[Symbol.for('@@mdb.skipPingOnConnect')]: true,
203+
[Symbol.for('@@mdb.enableMongoLogger')]: true,
204+
[Symbol.for('@@mdb.internalMongoLoggerConfig')]: componentSeverities,
205+
mongodbLogPath: logCollector,
155206
...getEnvironmentalOptions(),
156207
...(description.serverApi ? { serverApi: description.serverApi } : {})
157208
});
209+
this.logCollector = logCollector;
158210

159211
this.ignoredEvents = [
160212
...(description.ignoreCommandMonitoringEvents ?? []),
@@ -242,6 +294,10 @@ export class UnifiedMongoClient extends MongoClient {
242294
this.off(eventName, this.pushSdamEvent);
243295
}
244296
}
297+
298+
get collectedLogs(): LogMessage[] {
299+
return this.logCollector.collectedLogs;
300+
}
245301
}
246302

247303
export class FailPointMap extends Map<string, Document> {

test/tools/unified-spec-runner/match.ts

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/* eslint-disable @typescript-eslint/no-non-null-assertion */
2+
import { EJSON } from 'bson';
23
import { expect } from 'chai';
34
import { inspect } from 'util';
45

@@ -34,6 +35,7 @@ import {
3435
ExpectedCommandEvent,
3536
ExpectedError,
3637
ExpectedEventsForClient,
38+
ExpectedLogMessage,
3739
ExpectedSdamEvent
3840
} from './schema';
3941

@@ -73,12 +75,26 @@ export interface SessionLsidOperator {
7375
export function isSessionLsidOperator(value: unknown): value is SessionLsidOperator {
7476
return typeof value === 'object' && value != null && '$$sessionLsid' in value;
7577
}
78+
export interface MatchAsDocumentOperator {
79+
$$matchAsDocument: unknown;
80+
}
81+
export function isMatchAsDocumentOperator(value: unknown): value is MatchAsDocumentOperator {
82+
return typeof value === 'object' && value != null && '$$matchAsDocument' in value;
83+
}
84+
export interface MatchAsRootOperator {
85+
$$matchAsRoot: unknown;
86+
}
87+
export function isMatchAsRootOperator(value: unknown): value is MatchAsRootOperator {
88+
return typeof value === 'object' && value != null && '$$matchAsRoot' in value;
89+
}
7690

7791
export const SpecialOperatorKeys = [
7892
'$$exists',
7993
'$$type',
8094
'$$matchesEntity',
8195
'$$matchesHexBytes',
96+
'$$matchAsRoot',
97+
'$$matchAsDocument',
8298
'$$unsetOrMatches',
8399
'$$sessionLsid'
84100
];
@@ -89,7 +105,9 @@ export type SpecialOperator =
89105
| MatchesEntityOperator
90106
| MatchesHexBytesOperator
91107
| UnsetOrMatchesOperator
92-
| SessionLsidOperator;
108+
| SessionLsidOperator
109+
| MatchAsDocumentOperator
110+
| MatchAsRootOperator;
93111

94112
type KeysOfUnion<T> = T extends object ? keyof T : never;
95113
export type SpecialOperatorKey = KeysOfUnion<SpecialOperator>;
@@ -100,7 +118,9 @@ export function isSpecialOperator(value: unknown): value is SpecialOperator {
100118
isMatchesEntityOperator(value) ||
101119
isMatchesHexBytesOperator(value) ||
102120
isUnsetOrMatchesOperator(value) ||
103-
isSessionLsidOperator(value)
121+
isSessionLsidOperator(value) ||
122+
isMatchAsRootOperator(value) ||
123+
isMatchAsDocumentOperator(value)
104124
);
105125
}
106126

@@ -199,6 +219,10 @@ export function resultCheck(
199219
return;
200220
}
201221

222+
if (typeof actual !== 'object') {
223+
expect.fail('Expected actual value to be an object');
224+
}
225+
202226
const expectedEntries = Object.entries(expected);
203227

204228
if (Array.isArray(expected)) {
@@ -294,8 +318,9 @@ export function specialCheck(
294318
// $$sessionLsid
295319
const session = entities.getEntity('session', expected.$$sessionLsid, false);
296320
expect(session, `Session ${expected.$$sessionLsid} does not exist in entities`).to.exist;
297-
const entitySessionHex = session.id!.id.buffer.toString('hex').toUpperCase();
298-
const actualSessionHex = actual.id.buffer.toString('hex').toUpperCase();
321+
const entitySessionHex = session.id!.id.toString('hex').toUpperCase();
322+
const actualSessionHex = actual.id!.toString('hex').toUpperCase();
323+
299324
expect(
300325
entitySessionHex,
301326
`Session entity ${expected.$$sessionLsid} does not match lsid`
@@ -323,6 +348,25 @@ export function specialCheck(
323348
ejson`expected value at path ${path.join('')} NOT to exist, but received ${actual}`
324349
).to.be.false;
325350
}
351+
} else if (isMatchAsDocumentOperator(expected)) {
352+
if (typeof actual === 'string') {
353+
const actualDoc = EJSON.parse(actual, { relaxed: false });
354+
resultCheck(actualDoc, expected.$$matchAsDocument as any, entities, path, true);
355+
} else {
356+
expect.fail(
357+
`Expected value at path '${path.join('')}' to be string, but received ${inspect(actual)}`
358+
);
359+
}
360+
} else if (isMatchAsRootOperator(expected)) {
361+
expect(
362+
typeof actual,
363+
`Expected value at path '${path.join('')}' to be object, but received ${inspect(actual)}`
364+
).to.equal('object');
365+
expect(typeof expected.$$matchAsRoot, 'Value of $$matchAsRoot must be an object').to.equal(
366+
'object'
367+
);
368+
369+
resultCheck(actual, expected.$$matchAsRoot as any, entities, path, false);
326370
} else {
327371
expect.fail(`Unknown special operator: ${JSON.stringify(expected)}`);
328372
}
@@ -523,6 +567,44 @@ export function matchesEvents(
523567
}
524568
}
525569

570+
export function compareLogs(
571+
expected: ExpectedLogMessage[],
572+
actual: ExpectedLogMessage[],
573+
entities: EntitiesMap
574+
): void {
575+
expect(actual).to.have.lengthOf(expected.length);
576+
577+
for (const [index, actualLog] of actual.entries()) {
578+
const rootPrefix = `expectLogMessages[${index}]`;
579+
const expectedLog = expected[index];
580+
581+
// Check that log levels match
582+
expect(actualLog).to.have.property('level', expectedLog.level);
583+
584+
// Check that components match
585+
expect(actualLog).to.have.property('component', expectedLog.component);
586+
587+
// NOTE: The spec states that if the failureIsRedacted flag is present, we
588+
// must assert that a failure occurred.
589+
if (expectedLog.failureIsRedacted !== undefined) {
590+
expect(expectedLog.failureIsRedacted).to.be.a('boolean');
591+
expect(actualLog.data.failure, 'Expected failure to exist').to.exist;
592+
if (expectedLog.failureIsRedacted) {
593+
// Assert that failure has been redacted
594+
expect(actualLog.data.failure, 'Expected failure to have been redacted').to.deep.equal({});
595+
} else {
596+
// Assert that failure has not been redacted
597+
expect(
598+
actualLog.data.failure,
599+
'Expected failure to have not been redacted'
600+
).to.not.deep.equal({});
601+
}
602+
}
603+
604+
resultCheck(actualLog.data, expectedLog.data, entities, [rootPrefix], false);
605+
}
606+
}
607+
526608
function isMongoCryptError(err): boolean {
527609
if (err.constructor.name === 'MongoCryptError') {
528610
return true;

test/tools/unified-spec-runner/operations.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { getSymbolFrom, sleep } from '../../tools/utils';
2323
import { TestConfiguration } from '../runner/config';
2424
import { EntitiesMap, UnifiedChangeStream } from './entities';
2525
import { expectErrorCheck, resultCheck } from './match';
26-
import type { ExpectedEvent, OperationDescription } from './schema';
26+
import type { ExpectedEvent, ExpectedLogMessage, OperationDescription } from './schema';
2727
import { getMatchingEventCount, translateOptions } from './unified-utils';
2828

2929
interface OperationFunctionParams {

test/tools/unified-spec-runner/runner.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { MongoClient } from '../../mongodb';
66
import { MONGODB_ERROR_CODES, ns, ReadPreference, TopologyType } from '../../mongodb';
77
import { ejson } from '../utils';
88
import { EntitiesMap, UnifiedMongoClient } from './entities';
9-
import { matchesEvents } from './match';
9+
import { compareLogs, matchesEvents } from './match';
1010
import { executeOperationAndCheck } from './operations';
1111
import * as uni from './schema';
1212
import { isAnyRequirementSatisfied, patchVersion, zip } from './unified-utils';
@@ -219,6 +219,16 @@ async function runUnifiedTest(
219219
}
220220
}
221221

222+
if (test.expectLogMessages) {
223+
for (const expectedLogsForClient of test.expectLogMessages) {
224+
const clientId = expectedLogsForClient.client;
225+
const testClient = clientList.get(clientId);
226+
227+
expect(testClient, `No client entity found with id ${clientId}`).to.exist;
228+
compareLogs(expectedLogsForClient.messages, testClient!.collectedLogs, entities);
229+
}
230+
}
231+
222232
if (test.outcome) {
223233
for (const collectionData of test.outcome) {
224234
const collection = utilClient

0 commit comments

Comments
 (0)