@vida-global/core 2.3.3 → 2.3.5

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/AGENTS.md CHANGED
@@ -2,12 +2,53 @@
2
2
 
3
3
  Use this guide to understand and write code for the vida-core repo.
4
4
 
5
+ `@vida-global/core` is the shared library behind all Vida apps. It also ships to
6
+ customers inside `vida-apps-tools`, so the exported surface is a public API.
7
+ Treat a changed or removed export as a breaking change, not a refactor.
8
+
9
+ CommonJS throughout (`require` / `module.exports`). There is no linter in this
10
+ repo — the style guide below is the only enforcement, so follow it closely.
11
+
12
+
5
13
  # Style and conventions
14
+
15
+ Always in force:
16
+
6
17
  @agents/style.md
7
- @agents/server.md
8
- @agents/db.md
9
- @agents/apis.md
10
18
  @agents/testing.md
11
19
 
20
+ Read only when the work touches that area:
21
+
22
+ - `agents/server.md` — Express endpoints, controllers, validation, status codes
23
+ - `agents/db.md` — ActiveRecord, Sequelize, migrations, caching
24
+ - `agents/apis.md` — outbound HTTP clients to third parties
25
+ - `agents/git.md` — branches, commits, GitHub
26
+ - `agents/frontend.md` — Next.js/React only. Not used by this package.
27
+
28
+
29
+ # Commands
30
+
31
+ npm test # jest, coverage on by default
32
+ npm test -- test/server # narrow to a directory
33
+ npm test -- -t 'name' # narrow by test name
34
+ npm run develop # prerelease via @vida-global/release
35
+ npm run release # release via @vida-global/release
36
+
37
+ `npm test` carries required flags (`LOG_LEVEL=test`, `NODE_NO_WARNINGS=1`,
38
+ `node --experimental-vm-modules`). Always go through the script. Calling `jest`
39
+ directly drops them and the run misbehaves.
40
+
41
+ Coverage is collected on every run. `/helpers/` is excluded by design.
42
+
43
+
44
+ # Repository map
45
+
46
+ `index.js` is the whole public surface. `lib/` holds the modules; `test/` mirrors
47
+ `lib/` one directory per module. Per-module guides live next to the code and are
48
+ listed in `README.md`: `activeRecord`, `http`, `logger`, `server`, `jobQueue`,
49
+ plus `cache`, `redis`, `apm`, `observability`, `utils`.
12
50
 
13
- # Helpers for common use cases
51
+ One non-obvious mechanic in `index.js`: server error classes are exported by
52
+ filtering `lib/server` exports on `prototype instanceof AbstractServerError`. A
53
+ new error class that extends it is exported automatically — do not add it to
54
+ `module.exports` by hand. One that does not extend it is silently absent.
@@ -72,6 +72,14 @@ Wire up the migration CLI by adding scripts to `package.json`:
72
72
 
73
73
  Generate a migration with `npm run db:create_migration createUsers`. Pass `-- --databaseId metrics` to scope the migration to a non-default database. Run `npm run db:migrate` to apply migrations or `npm run db:rollback` to undo the most recent one.
74
74
 
75
+ `db:migrate` and `db:rollback` take an optional `--envFile <path>`. Both open a database connection, and the CLI runs as a bare node process, so nothing has loaded the environment that `config/db/config.js` reads from. Point the flag at a `.env` file and it is loaded before the command runs:
76
+
77
+ ```
78
+ npm run db:migrate -- --envFile .env.staging
79
+ ```
80
+
81
+ A variable already set in the environment wins over the file, so an inline override on the command line still takes effect. Leave the flag off and nothing is loaded, which is what an application that gets its environment some other way wants. `db:create_migration` only writes a file, so it does not accept the flag.
82
+
75
83
  Migration files are named `<timestamp>_<description>.js`, with the database id appended before the extension when one is given — for example `1754500000000_createUsers.js` or `1754500000000_createUsers.metrics.js`. The leading timestamp is the version recorded in `migration_versions` and the order migrations run in, so files sort chronologically by name. Files that don't match this pattern are ignored.
76
84
 
77
85
  ```js
@@ -119,8 +119,11 @@ class Migrator {
119
119
 
120
120
 
121
121
  idColumn(idType=DEFAULT_ID_TYPE) {
122
+ if (idType === false) return {};
123
+
122
124
  if (!ID_TYPES.includes(idType)) {
123
- throw new Error(`Unknown id type "${idType}". Use one of: ${ID_TYPES.join(', ')}`);
125
+ throw new Error(`Unknown id type "${idType}". Use one of: ${ID_TYPES.join(', ')}, or `
126
+ + `false for a table that declares its own primary key.`);
124
127
  }
125
128
 
126
129
  return this[`${idType}IdColumn`];
@@ -140,9 +143,10 @@ class Migrator {
140
143
  get uuidIdColumn() {
141
144
  return {
142
145
  id: {
143
- allowNull: false,
144
- primaryKey: true,
145
- type: this.DataTypes.UUID, }
146
+ allowNull: false,
147
+ primaryKey: true,
148
+ type: this.DataTypes.UUID,
149
+ defaultValue: Sequelize.literal('gen_random_uuid()'), }
146
150
  };
147
151
  }
148
152
 
@@ -184,6 +188,20 @@ class Migrator {
184
188
  }
185
189
 
186
190
 
191
+ async tableExists(tableName) {
192
+ const tables = await this.#sequelizeQueryInterface.showAllTables(this.queryOptions);
193
+ return tables.some(table => (table?.tableName ?? table) == tableName);
194
+ }
195
+
196
+
197
+ // Escape hatch for DDL the typed methods cannot express — CHECK constraints, sequences shared
198
+ // between tables. Runs inside the migration transaction, so it rolls back with everything else.
199
+ // Prefer the typed methods; reach for this only when there is no other way.
200
+ async execute(sql, replacements) {
201
+ return await this.#connection._sequelize.query(sql, {...this.queryOptions, replacements});
202
+ }
203
+
204
+
187
205
  async addColumn(tableName, columnName, columnDetails) {
188
206
  await this.#sequelizeQueryInterface.addColumn(tableName, columnName, columnDetails, this.queryOptions);
189
207
  }
@@ -9,6 +9,13 @@ class StreamInProgressError extends Error {}
9
9
  class NoActiveStreamError extends Error {}
10
10
 
11
11
 
12
+ // xmlbuilder2's default object-notation control prefixes: @attribute, #text, $cdata,
13
+ // !comment, ?processing-instruction. A key using one of these must survive sanitization
14
+ // unmangled, or an intended attribute (e.g. {'@track': 'both'}) silently turns into a
15
+ // child element (`<_track>both</_track>`) instead of `track="both"`.
16
+ const XML_CONVERT_PREFIXES = ['@', '#', '$', '!', '?'];
17
+
18
+
12
19
  const CONTENT_TYPE_BY_EXTENSION = {
13
20
  css: 'text/css; charset=utf-8',
14
21
  gif: 'image/gif',
@@ -88,12 +95,17 @@ const InstanceMethods = {
88
95
  if (!this.responseContentType) this.responseContentType = CONTENT_TYPE_BY_EXTENSION.xml;
89
96
  const doc = create({ [this.xmlRootElement]: body });
90
97
  const xml = doc.end();
91
- this._response.send(xml);
98
+ this._send(xml);
92
99
  },
93
100
 
94
101
 
95
102
  renderTextResponse(body) {
96
103
  if (!this.responseContentType) this.responseContentType = CONTENT_TYPE_BY_EXTENSION.txt;
104
+ this._send(body);
105
+ },
106
+
107
+
108
+ _send(body) {
97
109
  this._response.send(body);
98
110
  },
99
111
 
@@ -182,8 +194,23 @@ const InstanceMethods = {
182
194
 
183
195
  // XML element names can't start with a digit or contain spaces/other invalid characters,
184
196
  // so sanitize keys that would otherwise make xmlbuilder2 throw on dynamic response data.
197
+ // A leading xmlbuilder2 control prefix (see XML_CONVERT_PREFIXES) is preserved as-is;
198
+ // only the remainder (the attribute/PI name, if any) is sanitized.
185
199
  _toXMLElementName(key) {
186
- let name = `${key}`.replace(/[^a-zA-Z0-9_.-]/g, '_');
200
+ key = `${key}`;
201
+
202
+ const prefix = XML_CONVERT_PREFIXES.find(p => key.startsWith(p));
203
+ if (prefix) {
204
+ const rest = key.slice(prefix.length);
205
+ return rest ? prefix + this._sanitizeXMLNamePart(rest) : prefix;
206
+ }
207
+
208
+ return this._sanitizeXMLNamePart(key);
209
+ },
210
+
211
+
212
+ _sanitizeXMLNamePart(name) {
213
+ name = name.replace(/[^a-zA-Z0-9_.-]/g, '_');
187
214
  if (!/^[a-zA-Z_]/.test(name)) name = `_${name}`;
188
215
  return name;
189
216
  },
@@ -47,8 +47,10 @@ const Accessors = {
47
47
 
48
48
  contentType: { get() { return this.requestHeaders['content-type'] }},
49
49
  cookies: { get() { return this._request.cookies || {} }},
50
+ isProxiedRequest:{ get() { return Boolean(this.requestHeaders['x-forwarded-for']) }},
50
51
  requestBody: { get() { return this._request.body }},
51
52
  requestHeaders: { get() { return structuredClone(this._request.headers || {}) }},
53
+ requestHost: { get() { return this.requestHeaders.host }},
52
54
  requestId: { get() { return this._request.id }},
53
55
  requestIp: { get() { return this._request.ip }},
54
56
  requestMethod: { get() { return this._request.method }},
@@ -73,6 +75,36 @@ const Accessors = {
73
75
  return match[1].trim();
74
76
  }
75
77
  },
78
+
79
+
80
+ // The absolute URL the caller addressed, rebuilt from the request. Webhook signature schemes
81
+ // sign this string, so it has to match what the sender used character for character.
82
+ requestUrl: {
83
+ get() { return `${this.requestProtocol}://${this.requestHost}${this.url}` }
84
+ },
85
+
86
+
87
+ // TLS usually terminates at a load balancer, and not every balancer forwards
88
+ // X-Forwarded-Proto, so `_request.protocol` can report http on a request the caller made over
89
+ // https. Prefer the forwarded protocol, assume https for anything else that arrived through a
90
+ // proxy, and fall back to the connection's own protocol for direct requests.
91
+ requestProtocol: {
92
+ get() {
93
+ if (this.forwardedProtocol) return this.forwardedProtocol;
94
+ if (this.isProxiedRequest) return 'https';
95
+ return this._request.protocol;
96
+ }
97
+ },
98
+
99
+
100
+ // A comma-joined chain lists the original client's protocol first.
101
+ forwardedProtocol: {
102
+ get() {
103
+ const header = this.requestHeaders['x-forwarded-proto'];
104
+ if (!header) return null;
105
+ return header.split(',')[0].trim();
106
+ }
107
+ },
76
108
  }
77
109
 
78
110
 
@@ -19,6 +19,11 @@ Within an action, the controller exposes:
19
19
  | `this.requestMethod` | HTTP method. |
20
20
  | `this.requestIp` | Client IP address. |
21
21
  | `this.url` | Original URL. |
22
+ | `this.requestHost` | Shortcut for `requestHeaders['host']`. |
23
+ | `this.requestUrl` | Absolute URL the caller addressed: `requestProtocol://requestHost + url`. Use this when verifying a webhook signature that covers the URL. |
24
+ | `this.requestProtocol` | `forwardedProtocol`, else `https` when `isProxiedRequest`, else the connection's own protocol. |
25
+ | `this.forwardedProtocol` | First entry of `X-Forwarded-Proto`, or `null`. |
26
+ | `this.isProxiedRequest` | `true` when `X-Forwarded-For` is present. |
22
27
  | `this.bearerToken` | Parsed `Authorization: Bearer <token>` value, or `null`. |
23
28
  | `this.statusCode` | Current outgoing status (settable, but prefer the render helpers below). |
24
29
  | `this.logger` | Per-request child logger. |
@@ -192,7 +192,7 @@ class VidaServer {
192
192
  const method = action.method.toLowerCase();
193
193
  const requestHandler = this.requestHandler(action.action, controllerCls)
194
194
  if (process.env.NODE_ENV != 'test') {
195
- this.logger.verbose(`ROUTE: ${method.toUpperCase().padEnd(6)} ${action.path}`);
195
+ this.logger.debug(`ROUTE: ${method.toUpperCase().padEnd(6)} ${action.path}`);
196
196
  }
197
197
  this['_'+method](action.path, requestHandler);
198
198
  }
@@ -0,0 +1,14 @@
1
+ const dotenv = require('dotenv');
2
+ const fs = require('node:fs');
3
+
4
+
5
+ function loadEnvFile(filePath) {
6
+ if (!filePath) return false;
7
+ if (!fs.existsSync(filePath)) throw new Error(`Environment file ${filePath} does not exist`);
8
+
9
+ dotenv.config({ path: filePath, quiet: true });
10
+ return true;
11
+ }
12
+
13
+
14
+ module.exports = { loadEnvFile };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vida-global/core",
3
- "version": "2.3.3",
3
+ "version": "2.3.5",
4
4
  "description": "Core libraries for supporting Vida development",
5
5
  "author": "",
6
6
  "license": "ISC",
@@ -24,6 +24,7 @@
24
24
  "bullmq": "^5.0.0",
25
25
  "commander": "^13.1.0",
26
26
  "cookie-parser": "^1.4.7",
27
+ "dotenv": "^16.6.1",
27
28
  "express": "^4.21.2",
28
29
  "express-request-id": "1.4.1",
29
30
  "express-winston": "^4.0.0",
@@ -1,5 +1,13 @@
1
- const migration = require('../../lib/activeRecord/db/migration');
2
- const { Command } = require('commander');
1
+ const { loadEnvFile } = require('../../lib/utils/envFile');
2
+ const migration = require('../../lib/activeRecord/db/migration');
3
+ const { Command } = require('commander');
4
+
5
+
6
+ // Declared on the commands that touch the database rather than on the program, so it can be passed
7
+ // after the command name, which is where npm puts it: `npm run db:migrate -- --envFile .env.staging`.
8
+ function withEnvFileOption(command) {
9
+ return command.option('--envFile <path>', 'a .env file to load before the command runs');
10
+ }
3
11
 
4
12
 
5
13
  const program = new Command();
@@ -7,6 +15,10 @@ program.name('Active Record Migration')
7
15
  .description('A CLI for generating and running Active Record migrations')
8
16
  .version('1.0.0');
9
17
 
18
+ program.hook('preAction', (_program, command) => {
19
+ loadEnvFile(command.opts().envFile);
20
+ });
21
+
10
22
 
11
23
  program.command('create_migration')
12
24
  .argument('<Description>', 'Description of your migration')
@@ -16,12 +28,13 @@ program.command('create_migration')
16
28
  });
17
29
 
18
30
 
19
- program.command('migrate')
31
+ withEnvFileOption(program.command('migrate'))
20
32
  .action(async () => {
21
33
  await migration.runMigrations();
22
34
  });
23
35
 
24
- program.command('rollback')
36
+
37
+ withEnvFileOption(program.command('rollback'))
25
38
  .action(async () => {
26
39
  await migration.rollbackMigration();
27
40
  });
@@ -161,13 +161,28 @@ describe('Migrator', () => {
161
161
  expect(columns.id).toEqual({ allowNull: false, autoIncrement: true, primaryKey: true, type: 'INTEGER' });
162
162
  });
163
163
 
164
- it ('gives the table a uuid id that the database does not generate', async () => {
164
+ it ('gives the table a uuid id the database generates', async () => {
165
165
  const tableName = Helpers.randomString();
166
166
 
167
167
  await Helpers.migrator.createTable(tableName, {}, { id: 'uuid' });
168
168
 
169
169
  const [, columns] = Helpers.mockQueryInterface.createTable.mock.calls[0];
170
- expect(columns.id).toEqual({ allowNull: false, primaryKey: true, type: 'UUID' });
170
+ expect(columns.id).toEqual({
171
+ allowNull: false,
172
+ primaryKey: true,
173
+ type: 'UUID',
174
+ defaultValue: { literal: 'gen_random_uuid()' },
175
+ });
176
+ });
177
+
178
+
179
+ it ('does not give an auto-increment id a database default', async () => {
180
+ const tableName = Helpers.randomString();
181
+
182
+ await Helpers.migrator.createTable(tableName, {}, { id: 'bigint' });
183
+
184
+ const [, columns] = Helpers.mockQueryInterface.createTable.mock.calls[0];
185
+ expect(columns.id).not.toHaveProperty('defaultValue');
171
186
  });
172
187
 
173
188
  it ('refuses an id type it does not recognize', async () => {
@@ -177,6 +192,48 @@ describe('Migrator', () => {
177
192
  .rejects.toThrow(`Unknown id type "${idType}". Use one of: bigint, int, uuid`);
178
193
  });
179
194
 
195
+ it ('tells the caller false is an option when refusing an id type', async () => {
196
+ await expect(Helpers.migrator.createTable(Helpers.randomString(), {}, { id: Helpers.randomString() }))
197
+ .rejects.toThrow('or false for a table that declares its own primary key');
198
+ });
199
+
200
+ // For a table whose primary key is a natural column, e.g. a baseline migration recreating
201
+ // a table that predates the migrations directory.
202
+ it ('adds no id column when id is false', async () => {
203
+ const tableName = Helpers.randomString();
204
+
205
+ await Helpers.migrator.createTable(tableName, {}, { id: false });
206
+
207
+ const [, columns] = Helpers.mockQueryInterface.createTable.mock.calls[0];
208
+ expect(columns).not.toHaveProperty('id');
209
+ });
210
+
211
+ it ('keeps the table details when id is false', async () => {
212
+ const tableName = Helpers.randomString();
213
+ const uuid = { allowNull: false, primaryKey: true, type: 'UUID' };
214
+
215
+ await Helpers.migrator.createTable(tableName, { uuid }, { id: false, timestamps: false });
216
+
217
+ expect(Helpers.mockQueryInterface.createTable).toHaveBeenCalledWith(
218
+ tableName, { uuid }, Helpers.noTransaction,
219
+ );
220
+ });
221
+
222
+ const notFalseCases = [['null', null], ['zero', 0], ['an empty string', '']];
223
+ it.each(notFalseCases)('does not treat %s as false', async (_label, idType) => {
224
+ await expect(Helpers.migrator.createTable(Helpers.randomString(), {}, { id: idType }))
225
+ .rejects.toThrow('Unknown id type');
226
+ });
227
+
228
+ it ('still defaults to a bigint id when no id option is given', async () => {
229
+ const tableName = Helpers.randomString();
230
+
231
+ await Helpers.migrator.createTable(tableName, {}, {});
232
+
233
+ const [, columns] = Helpers.mockQueryInterface.createTable.mock.calls[0];
234
+ expect(columns.id).toEqual({ allowNull: false, autoIncrement: true, primaryKey: true, type: 'BIGINT' });
235
+ });
236
+
180
237
  it ('lets the details override the id column', async () => {
181
238
  const tableName = Helpers.randomString();
182
239
  const id = { allowNull: false, primaryKey: true, type: 'UUID' };
@@ -255,6 +312,78 @@ describe('Migrator', () => {
255
312
  });
256
313
 
257
314
 
315
+ describe('Migrator#tableExists', () => {
316
+ it ('reports a table that is present', async () => {
317
+ const tableName = Helpers.randomString();
318
+ Helpers.mockQueryInterface.showAllTables.mockResolvedValue([Helpers.randomString(), tableName]);
319
+
320
+ await expect(Helpers.migrator.tableExists(tableName)).resolves.toBe(true);
321
+ });
322
+
323
+ it ('reports a table that is absent', async () => {
324
+ Helpers.mockQueryInterface.showAllTables.mockResolvedValue([Helpers.randomString()]);
325
+
326
+ await expect(Helpers.migrator.tableExists(Helpers.randomString())).resolves.toBe(false);
327
+ });
328
+
329
+ // Some dialects return objects rather than bare names.
330
+ it ('reads a name off an object entry', async () => {
331
+ const tableName = Helpers.randomString();
332
+ Helpers.mockQueryInterface.showAllTables.mockResolvedValue([{ tableName }]);
333
+
334
+ await expect(Helpers.migrator.tableExists(tableName)).resolves.toBe(true);
335
+ });
336
+
337
+ it ('runs inside the open transaction', async () => {
338
+ Helpers.mockQueryInterface.showAllTables.mockResolvedValue([]);
339
+
340
+ await Helpers.runInMigration(async function() {
341
+ await this.tableExists(Helpers.randomString());
342
+ });
343
+
344
+ expect(Helpers.mockQueryInterface.showAllTables).toHaveBeenCalledWith(Helpers.inTransaction);
345
+ });
346
+ });
347
+
348
+
349
+ describe('Migrator#execute', () => {
350
+ it ('runs the statement and returns its result', async () => {
351
+ const sql = `CREATE SEQUENCE ${Helpers.randomString()}`;
352
+ const result = {};
353
+ Helpers.mockQuery.mockResolvedValue(result);
354
+
355
+ await expect(Helpers.migrator.execute(sql)).resolves.toBe(result);
356
+
357
+ expect(Helpers.mockQuery).toHaveBeenCalledWith(sql, {
358
+ ...Helpers.noTransaction,
359
+ replacements: undefined,
360
+ });
361
+ });
362
+
363
+ it ('passes replacements through for binding', async () => {
364
+ const sql = 'SELECT :value';
365
+ const replacements = { value: Helpers.randomString() };
366
+ Helpers.mockQuery.mockResolvedValue(undefined);
367
+
368
+ await Helpers.migrator.execute(sql, replacements);
369
+
370
+ expect(Helpers.mockQuery).toHaveBeenCalledWith(sql, expect.objectContaining({ replacements }));
371
+ });
372
+
373
+ // Raw statements have to roll back with the rest of the migration.
374
+ it ('runs inside the open transaction', async () => {
375
+ const sql = `CREATE SEQUENCE ${Helpers.randomString()}`;
376
+ Helpers.mockQuery.mockResolvedValue(undefined);
377
+
378
+ await Helpers.runInMigration(async function() {
379
+ await this.execute(sql);
380
+ });
381
+
382
+ expect(Helpers.mockQuery).toHaveBeenCalledWith(sql, expect.objectContaining(Helpers.inTransaction));
383
+ });
384
+ });
385
+
386
+
258
387
  describe('Migrator#renameColumn', () => {
259
388
  it ('snake_cases renameColumn target', async () => {
260
389
  const tableName = Helpers.randomString();
@@ -16,6 +16,7 @@ const randomString = TestHelpers.Faker.Text.randomString;
16
16
  const transaction = { id: randomString() };
17
17
  const noTransaction = { transaction: null };
18
18
  const inTransaction = { transaction };
19
+ const mockQuery = jest.fn();
19
20
  const mockQueryInterface = {
20
21
  createTable: jest.fn(),
21
22
  dropTable: jest.fn(),
@@ -25,6 +26,7 @@ const mockQueryInterface = {
25
26
  removeIndex: jest.fn(),
26
27
  renameColumn: jest.fn(),
27
28
  changeColumn: jest.fn(),
29
+ showAllTables: jest.fn(),
28
30
  };
29
31
 
30
32
 
@@ -39,10 +41,12 @@ function randomColumnDetails() {
39
41
  jest.mock('sequelize', () => {
40
42
  class MockSequelize {
41
43
  static DataTypes = { BIGINT: 'BIGINT', DATE: 'DATE', INTEGER: 'INTEGER', UUID: 'UUID' };
44
+ static literal(sql) { return { literal: sql }; }
42
45
  }
43
46
  return { Sequelize: MockSequelize };
44
47
  });
45
48
  Sequelize.prototype.getQueryInterface = () => mockQueryInterface;
49
+ Sequelize.prototype.query = (...args) => mockQuery(...args);
46
50
 
47
51
 
48
52
  jest.spyOn(ConnectionConfiguration, '_fetchAllConfigs').mockImplementation(() => ({
@@ -81,6 +85,7 @@ module.exports = {
81
85
  inTransaction,
82
86
  migrationModule,
83
87
  migrator,
88
+ mockQuery,
84
89
  mockQueryInterface,
85
90
  noTransaction,
86
91
  randomColumnDetails,
@@ -494,6 +494,68 @@ describe('VidaServerController', () => {
494
494
  expect(controller._toXMLElementName('first name')).toBe('first_name');
495
495
  expect(controller._toXMLElementName('a:b')).toBe('a_b');
496
496
  });
497
+
498
+
499
+ // xmlbuilder2 reads these prefixes as instructions rather than as part of the name.
500
+ // Sanitizing one away silently turns an intended attribute into a child element.
501
+ const prefixCases = [
502
+ ['attribute', '@', '@track', 'both'],
503
+ ['text', '#', '#text', 'value'],
504
+ ['cdata', '$', '$cdata', 'value'],
505
+ ['comment', '!', '!comment', 'value'],
506
+ ['processing instruction', '?', '?target', 'value'],
507
+ ];
508
+ it.each(prefixCases)('preserves the %s prefix', (_label, _prefix, key) => {
509
+ expect(controller._toXMLElementName(key)).toBe(key);
510
+ });
511
+
512
+ it ('sanitizes the name after the prefix but keeps the prefix', () => {
513
+ expect(controller._toXMLElementName('@my attr')).toBe('@my_attr');
514
+ expect(controller._toXMLElementName('@a:b')).toBe('@a_b');
515
+ });
516
+
517
+ it ('underscores a prefixed name that starts with a digit', () => {
518
+ expect(controller._toXMLElementName('@123')).toBe('@_123');
519
+ });
520
+
521
+ it ('returns a bare prefix untouched', () => {
522
+ expect(controller._toXMLElementName('@')).toBe('@');
523
+ expect(controller._toXMLElementName('#')).toBe('#');
524
+ });
525
+
526
+ it ('only treats a prefix as a prefix in the leading position', () => {
527
+ expect(controller._toXMLElementName('track@')).toBe('track_');
528
+ expect(controller._toXMLElementName('a#b')).toBe('a_b');
529
+ });
530
+ });
531
+
532
+
533
+ // The end-to-end reason the prefixes are preserved: without it these render as child
534
+ // elements (<_track>both</_track>) rather than attributes.
535
+ describe('#renderXMLResponse attribute rendering', () => {
536
+ it ('renders @-prefixed keys as XML attributes', async () => {
537
+ const response = buildResponseMock();
538
+ const controller = new FooController({query: {_format: 'xml'}}, response);
539
+ controller.formatResponseBody = body => body;
540
+
541
+ await controller.render({Record: {'@track': 'both', '@trim': 'do-not-trim'}});
542
+
543
+ expect(response.send).toHaveBeenCalledWith(
544
+ expect.stringContaining('<Record track="both" trim="do-not-trim"/>')
545
+ );
546
+ });
547
+
548
+ it ('still renders an ordinary key as a child element', async () => {
549
+ const response = buildResponseMock();
550
+ const controller = new FooController({query: {_format: 'xml'}}, response);
551
+ controller.formatResponseBody = body => body;
552
+
553
+ await controller.render({Record: {'weird key!': 'x'}});
554
+
555
+ expect(response.send).toHaveBeenCalledWith(
556
+ expect.stringContaining('<weird_key_>x</weird_key_>')
557
+ );
558
+ });
497
559
  });
498
560
 
499
561
 
@@ -88,6 +88,72 @@ describe('VidaServerController', () => {
88
88
  });
89
89
 
90
90
 
91
+ describe('#requestHost', () => {
92
+ it ('returns the host request header', () => {
93
+ request.headers.host = `${TestHelpers.Faker.Text.randomString()}.vida.dev`;
94
+ expect(controller.requestHost).toEqual(request.headers.host);
95
+ });
96
+ });
97
+
98
+
99
+ describe('#forwardedProtocol', () => {
100
+ it ('returns null when the proxy sends no forwarded protocol', () => {
101
+ delete request.headers['x-forwarded-proto'];
102
+ expect(controller.forwardedProtocol).toBe(null);
103
+ });
104
+
105
+ it ('returns the forwarded protocol', () => {
106
+ request.headers['x-forwarded-proto'] = 'https';
107
+ expect(controller.forwardedProtocol).toEqual('https');
108
+ });
109
+
110
+ it ('returns only the first entry of a forwarded protocol chain', () => {
111
+ request.headers['x-forwarded-proto'] = 'https, http';
112
+ expect(controller.forwardedProtocol).toEqual('https');
113
+ });
114
+ });
115
+
116
+
117
+ describe('#isProxiedRequest', () => {
118
+ it.each([
119
+ ['x-forwarded-for is present', '203.0.113.7', true],
120
+ ['x-forwarded-for is absent', undefined, false],
121
+ ])('returns %s -> %s', (_label, forwardedFor, expected) => {
122
+ delete request.headers['x-forwarded-for'];
123
+ if (forwardedFor) request.headers['x-forwarded-for'] = forwardedFor;
124
+ expect(controller.isProxiedRequest).toBe(expected);
125
+ });
126
+ });
127
+
128
+
129
+ describe('#requestUrl', () => {
130
+ beforeEach(() => {
131
+ request.headers.host = 'api.vida.dev';
132
+ request.originalUrl = '/things?page=2';
133
+ request.protocol = 'http';
134
+ delete request.headers['x-forwarded-proto'];
135
+ delete request.headers['x-forwarded-for'];
136
+ });
137
+
138
+ it ('uses the forwarded protocol when the proxy sends one', () => {
139
+ request.headers['x-forwarded-proto'] = 'https';
140
+ request.headers['x-forwarded-for'] = '203.0.113.7';
141
+ expect(controller.requestUrl).toEqual('https://api.vida.dev/things?page=2');
142
+ });
143
+
144
+ // Not every balancer forwards X-Forwarded-Proto, so a proxied request can look like
145
+ // plain http even though the caller addressed an https URL.
146
+ it ('assumes https for a proxied request that carries no forwarded protocol', () => {
147
+ request.headers['x-forwarded-for'] = '203.0.113.7';
148
+ expect(controller.requestUrl).toEqual('https://api.vida.dev/things?page=2');
149
+ });
150
+
151
+ it ('keeps the connection protocol for a direct request with no proxy headers', () => {
152
+ expect(controller.requestUrl).toEqual('http://api.vida.dev/things?page=2');
153
+ });
154
+ });
155
+
156
+
91
157
  describe('#statusCode', () => {
92
158
  it ('returns the response status code', () => {
93
159
  expect(controller.statusCode).toEqual(response.statusCode);
@@ -0,0 +1,46 @@
1
+ const { loadEnvFile } = require('../../lib/utils/envFile');
2
+
3
+
4
+ const fixturePath = `${__dirname}/helpers/envFile.fixture`;
5
+ const fileKeys = ['CORE_TEST_ENV_FILE_VALUE', 'CORE_TEST_ENV_FILE_PRESET'];
6
+
7
+
8
+ beforeEach(() => {
9
+ fileKeys.forEach(key => delete process.env[key]);
10
+ });
11
+
12
+
13
+ afterEach(() => {
14
+ fileKeys.forEach(key => delete process.env[key]);
15
+ });
16
+
17
+
18
+ describe('utils/envFile', () => {
19
+ describe('loadEnvFile', () => {
20
+ it ('does nothing and reports false when given no path', () => {
21
+ expect(loadEnvFile(undefined)).toBe(false);
22
+ expect(process.env.CORE_TEST_ENV_FILE_VALUE).toBeUndefined();
23
+ });
24
+
25
+
26
+ it ('throws when the file does not exist', () => {
27
+ expect(() => loadEnvFile(`${fixturePath}.missing`))
28
+ .toThrow(`Environment file ${fixturePath}.missing does not exist`);
29
+ });
30
+
31
+
32
+ it ('loads the variables in the file into the environment', () => {
33
+ expect(loadEnvFile(fixturePath)).toBe(true);
34
+ expect(process.env.CORE_TEST_ENV_FILE_VALUE).toEqual('from_file');
35
+ });
36
+
37
+
38
+ it ('leaves a variable already set in the environment alone', () => {
39
+ process.env.CORE_TEST_ENV_FILE_PRESET = 'from_environment';
40
+
41
+ loadEnvFile(fixturePath);
42
+
43
+ expect(process.env.CORE_TEST_ENV_FILE_PRESET).toEqual('from_environment');
44
+ });
45
+ });
46
+ });
@@ -0,0 +1,2 @@
1
+ CORE_TEST_ENV_FILE_VALUE=from_file
2
+ CORE_TEST_ENV_FILE_PRESET=from_file