pixl-server-storage 4.2.1 → 4.2.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/README.md CHANGED
@@ -56,9 +56,14 @@ Here is the table of contents for this current document:
56
56
  + [S3 Cache](#s3-cache)
57
57
  + [S3 Compatible Services](#s3-compatible-services)
58
58
  * [Redis](#redis)
59
+ + [Redis Auth and TLS](#redis-auth-and-tls)
59
60
  + [RedisCluster](#rediscluster)
61
+ - [RedisCluster Auth and TLS](#rediscluster-auth-and-tls)
60
62
  * [SQLite](#sqlite)
61
63
  * [Postgres](#postgres)
64
+ + [Postgres SSL](#postgres-ssl)
65
+ + [AWS RDS](#aws-rds)
66
+ + [Postgres Password Plugin](#postgres-password-plugin)
62
67
  * [Hybrid](#hybrid)
63
68
  - [Key Normalization](#key-normalization)
64
69
  - [Basic Functions](#basic-functions)
@@ -590,6 +595,8 @@ Then configure your storage thusly:
590
595
  "Redis": {
591
596
  "host": "127.0.0.1",
592
597
  "port": 6379,
598
+ "username": "",
599
+ "password": "",
593
600
  "keyPrefix": "",
594
601
  "keyTemplate": "",
595
602
  "cache": {
@@ -603,6 +610,58 @@ Then configure your storage thusly:
603
610
 
604
611
  Set the `host` and `port` for your own Redis server setup. Please see [Common Redis Options](https://redis.github.io/ioredis/interfaces/CommonRedisOptions.html) for other things you can include here, such as timeouts, authentication and database selection.
605
612
 
613
+ ### Redis Auth and TLS
614
+
615
+ For production, Redis is usually deployed on a private network, firewalled to trusted clients, and protected with authentication as an extra layer of safety. Modern Redis ACL users use both `username` and `password`.
616
+
617
+ Configure `username` and `password` like this:
618
+
619
+ ```json
620
+ {
621
+ "engine": "Redis",
622
+ "Redis": {
623
+ "host": "redis.example.com",
624
+ "port": 6379,
625
+ "username": "default",
626
+ "password": "your-redis-password"
627
+ }
628
+ }
629
+ ```
630
+
631
+ Redis `AUTH` is sent over the Redis connection itself, so use TLS too when traffic can cross an untrusted network, a shared network, or a managed provider endpoint that requires encryption. The Redis engine passes extra config properties through to [ioredis](https://github.com/redis/ioredis), so you can enable TLS with the `tls` option:
632
+
633
+ ```json
634
+ {
635
+ "engine": "Redis",
636
+ "Redis": {
637
+ "host": "redis.example.com",
638
+ "port": 6380,
639
+ "username": "default",
640
+ "password": "your-redis-password",
641
+ "tls": {}
642
+ }
643
+ }
644
+ ```
645
+
646
+ For a private CA or self-signed certificate, pass TLS options such as `ca`, `cert`, `key` or `servername` inside the `tls` object. These are passed through to Node.js TLS. If you store PEM text directly in JSON, preserve line breaks as `\n`:
647
+
648
+ ```json
649
+ {
650
+ "engine": "Redis",
651
+ "Redis": {
652
+ "host": "redis.example.com",
653
+ "port": 6380,
654
+ "username": "default",
655
+ "password": "your-redis-password",
656
+ "tls": {
657
+ "ca": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"
658
+ }
659
+ }
660
+ }
661
+ ```
662
+
663
+ Please avoid `tls: { "rejectUnauthorized": false }` for production use. It can be useful as a short diagnostic test, but it disables certificate verification.
664
+
606
665
  The optional `keyPrefix` property works similarly to the [S3 Key Prefix](#s3-key-prefix) feature. It allows you to prefix all the Redis keys with a common string, to separate your application's data in a shared database situation.
607
666
 
608
667
  The optional `keyTemplate` property works similarly to the [S3 Key Template](#s3-key-template) feature. It allows you to specify an exact layout of MD5 hash characters, which can be prefixed, mixed in with or postfixed after the key.
@@ -648,6 +707,53 @@ Set the `host` and `port` for your own Redis cluster setup. The `host` should p
648
707
 
649
708
  The `clusterOpts` property can hold several different cluster configuration options. Please see [Cluster Options](https://redis.github.io/ioredis/interfaces/ClusterOptions.html) for other things you can include here, such as `scaleReads` and `redisOptions`. For the `redisOptions` object, please see [Common Redis Options](https://redis.github.io/ioredis/interfaces/CommonRedisOptions.html) for other things you can include, such as timeouts, authentication and database selection.
650
709
 
710
+ #### RedisCluster Auth and TLS
711
+
712
+ For RedisCluster, shared authentication and TLS options should go inside `clusterOpts.redisOptions`, because those options are passed to every Redis node connection created by ioredis:
713
+
714
+ ```json
715
+ {
716
+ "engine": "RedisCluster",
717
+ "RedisCluster": {
718
+ "host": "clustercfg.redis.example.com",
719
+ "port": 6379,
720
+ "clusterOpts": {
721
+ "scaleReads": "master",
722
+ "redisOptions": {
723
+ "commandTimeout": 5000,
724
+ "connectTimeout": 5000,
725
+ "username": "default",
726
+ "password": "your-cluster-password"
727
+ }
728
+ }
729
+ }
730
+ }
731
+ ```
732
+
733
+ To connect to a TLS-enabled Redis cluster, add `tls` under `redisOptions`:
734
+
735
+ ```json
736
+ {
737
+ "engine": "RedisCluster",
738
+ "RedisCluster": {
739
+ "host": "clustercfg.redis.example.com",
740
+ "port": 6380,
741
+ "clusterOpts": {
742
+ "scaleReads": "master",
743
+ "redisOptions": {
744
+ "commandTimeout": 5000,
745
+ "connectTimeout": 5000,
746
+ "username": "default",
747
+ "password": "your-cluster-password",
748
+ "tls": {}
749
+ }
750
+ }
751
+ }
752
+ }
753
+ ```
754
+
755
+ AWS ElastiCache for Redis commonly uses this pattern when authentication and in-transit encryption are enabled. Use `username` and `password` for ACL users.
756
+
651
757
  It is **highly recommended** that you keep the `scaleReads` property set to `"master"`, for immediate consistency (required for [Lists](https://github.com/jhuckaby/pixl-server-storage/blob/master/docs/Lists.md), [Hashes](https://github.com/jhuckaby/pixl-server-storage/blob/master/docs/Hashes.md), [Transactions](https://github.com/jhuckaby/pixl-server-storage/blob/master/docs/Transactions.md) and the [Indexer](https://github.com/jhuckaby/pixl-server-storage/blob/master/docs/Indexer.md)).
652
758
 
653
759
  The optional `keyPrefix` property works similarly to the [S3 Key Prefix](#s3-key-prefix) feature. It allows you to prefix all the Redis keys with a common string, to separate your application's data in a shared database situation.
@@ -752,7 +858,156 @@ You can customize the table name via the `table` configuration property. It def
752
858
 
753
859
  [Connection Pooling](https://node-postgres.com/features/pooling) is utilized to open many simultaneous connections to greatly increase throughput. Set the `max` property to the maximum number of concurrent connections your database can handle. Ideally this should be set to the global [concurrency](#concurrency) setting.
754
860
 
755
- See [S3 Cache](#s3-cache) for details on the `cache` section, as it works in the same way.
861
+ ### Postgres SSL
862
+
863
+ The Postgres engine passes all extra properties in the `Postgres` configuration block directly into the [pg.Pool constructor](https://node-postgres.com/apis/pool), except for `table`, `cache`, `passwordPlugin`, and `passwordPluginTimeout`, which are used by pixl-server-storage itself. This means you can use the standard [node-postgres SSL options](https://node-postgres.com/features/ssl) without any special pixl-server-storage code.
864
+
865
+ For a server with a certificate trusted by Node.js already, you can enable SSL by simply adding `"ssl":true` like this:
866
+
867
+ ```json
868
+ {
869
+ "engine": "Postgres",
870
+ "Postgres": {
871
+ "host": "postgres.example.com",
872
+ "database": "postgres",
873
+ "user": "postgres",
874
+ "password": "postgres",
875
+ "port": 5432,
876
+ "ssl": true
877
+ }
878
+ }
879
+ ```
880
+
881
+ For a private CA, self-signed server certificate, or hosted database that requires its own CA bundle, pass an `ssl` object. This object is passed through to Node.js TLS, so you can provide `ca`, `cert`, `key`, `servername`, and other supported TLS options. If you store PEM text directly in JSON, preserve line breaks as `\n` (standard JSON string encoding):
882
+
883
+ ```json
884
+ {
885
+ "engine": "Postgres",
886
+ "Postgres": {
887
+ "host": "postgres.example.com",
888
+ "database": "postgres",
889
+ "user": "postgres",
890
+ "password": "postgres",
891
+ "port": 5432,
892
+ "ssl": {
893
+ "ca": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"
894
+ }
895
+ }
896
+ }
897
+ ```
898
+
899
+ If you need to load the CA certificate from disk, you can also use a `connectionString` with the standard Postgres SSL parameters. The `pg` module will read `sslrootcert` and use it as the TLS CA:
900
+
901
+ ```json
902
+ {
903
+ "engine": "Postgres",
904
+ "Postgres": {
905
+ "connectionString": "postgres://postgres:postgres@postgres.example.com:5432/postgres?sslmode=verify-full&sslrootcert=/path/to/root-ca.pem",
906
+ "max": 32,
907
+ "table": "items"
908
+ }
909
+ }
910
+ ```
911
+
912
+ If you put SSL parameters such as `sslmode`, `sslcert`, `sslkey`, or `sslrootcert` in the connection string, avoid also setting a separate `ssl` object. The `pg` connection string parser replaces the `ssl` object when those URL parameters are present.
913
+
914
+ Please avoid `ssl: { "rejectUnauthorized": false }` for production use. It can be useful as a short diagnostic test, but it disables certificate verification.
915
+
916
+ ### AWS RDS
917
+
918
+ Amazon RDS for PostgreSQL works well with this setup. Download the appropriate RDS CA bundle from the [AWS RDS SSL/TLS documentation](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html), place it somewhere readable by your app, and use `sslmode=verify-full` with `sslrootcert`:
919
+
920
+ ```json
921
+ {
922
+ "engine": "Postgres",
923
+ "Postgres": {
924
+ "connectionString": "postgres://xyops:secret@mydb.abc123.us-east-1.rds.amazonaws.com:5432/xyops?sslmode=verify-full&sslrootcert=/opt/xyops/conf/rds-ca.pem",
925
+ "max": 32,
926
+ "table": "items"
927
+ }
928
+ }
929
+ ```
930
+
931
+ AWS has a few quirks worth knowing:
932
+
933
+ * RDS for PostgreSQL 15 and newer enables `rds.force_ssl` by default, so non-SSL connections may be rejected with an error similar to `no pg_hba.conf entry ... SSL off`.
934
+ * `sslmode=verify-full` verifies both the certificate chain and the hostname. Use the actual RDS endpoint hostname in your connection string when possible.
935
+ * If you connect through a custom DNS name, SSH tunnel, or local proxy, hostname verification may need the original RDS endpoint as the TLS `servername` option. In that case, prefer explicit config properties and an `ssl` object instead of URL SSL parameters.
936
+ * AWS recommends trusting the root RDS CA certificate. Avoid pinning intermediate certificates, as that can cause trouble when RDS rotates server certificates.
937
+
938
+ ### Postgres Password Plugin
939
+
940
+ If your Postgres password needs to be generated dynamically, such as with a cloud provider token service, you can configure a password plugin command. This uses the [node-postgres dynamic password callback](https://node-postgres.com/features/connecting), so the command is called whenever the pool needs a new physical database connection.
941
+
942
+ ```json
943
+ {
944
+ "engine": "Postgres",
945
+ "Postgres": {
946
+ "host": "mypg.postgres.example.com",
947
+ "database": "mydb",
948
+ "user": "my-app-user",
949
+ "ssl": true,
950
+ "passwordPlugin": "npx -y pxpg-plug-example@1.0.0"
951
+ }
952
+ }
953
+ ```
954
+
955
+ In this example the command uses the [npx](https://docs.npmjs.com/cli/commands/npx) CLI to automatically download and run a NPM package. The package is also cached on local disk (in `~/.npm`) for fast subsequent runs.
956
+
957
+ The command receives one compact JSON document on STDIN, delimited by an EOL (pretty-printed here for display purposes):
958
+
959
+ ```json
960
+ {
961
+ "type": "postgres_password",
962
+ "config": {
963
+ "host": "mypg.postgres.example.com",
964
+ "database": "mydb",
965
+ "user": "my-app-user",
966
+ "ssl": true
967
+ }
968
+ }
969
+ ```
970
+
971
+ The `config` object contains the Postgres configuration with `password`, `passwordPlugin`, and `passwordPluginTimeout` removed.
972
+
973
+ The command should print one compact JSON document to STDOUT. Extra logging is allowed, as pixl-server-storage only parses the last line of STDOUT:
974
+
975
+ ```json
976
+ {
977
+ "password": "GENERATED_PASSWORD_OR_TOKEN",
978
+ "ttl": 3000
979
+ }
980
+ ```
981
+
982
+ The `password` property is required, and must be a string. The optional `ttl` property is a cache duration in seconds. If `ttl` is greater than zero, pixl-server-storage caches the returned password in memory and reuses it for subsequent new database connections until the TTL expires. If `ttl` is omitted or zero, the command is invoked for every new database connection.
983
+
984
+ If the plugin cannot produce a password, it can return an error:
985
+
986
+ ```json
987
+ {
988
+ "code": "auth",
989
+ "description": "Could not fetch provider token."
990
+ }
991
+ ```
992
+
993
+ The default plugin timeout is 30 seconds. You can customize it with `passwordPluginTimeout`, in milliseconds:
994
+
995
+ ```json
996
+ {
997
+ "engine": "Postgres",
998
+ "Postgres": {
999
+ "host": "mypg.postgres.example.com",
1000
+ "database": "mydb",
1001
+ "user": "my-app-user",
1002
+ "passwordPlugin": "/usr/local/bin/px-pg-plug-example",
1003
+ "passwordPluginTimeout": 10000
1004
+ }
1005
+ }
1006
+ ```
1007
+
1008
+ For production, consider preinstalling the plugin and pointing `passwordPlugin` at the installed executable. This avoids starting `npx` and checking the NPM registry whenever the cached password expires.
1009
+
1010
+ See [S3 Cache](#s3-cache) for details on the `cache` section, as Postgres works in the same way.
756
1011
 
757
1012
  Note that pooling and caching have little effect when the database is running locally (i.e. localhost). They offer much more benefit when the database is located remotely over some number of network hops.
758
1013
 
package/docs/API.md CHANGED
@@ -24,6 +24,7 @@ let storage = server.Storage;
24
24
  * [headMulti](#headmulti)
25
25
  * [delete](#delete)
26
26
  * [deleteMulti](#deletemulti)
27
+ * [deleteAll](#deleteall)
27
28
  * [copy](#copy)
28
29
  * [rename](#rename)
29
30
  * [lock](#lock)
@@ -328,6 +329,22 @@ storage.deleteMulti( ['test1', 'test2', 'test3'], function(err) {
328
329
  } );
329
330
  ```
330
331
 
332
+ Note that this function returns immediately if it fails to delete any key in the specified set.
333
+
334
+ ## deleteAll
335
+
336
+ ```js
337
+ storage.deleteAll( KEYS, CALLBACK );
338
+ ```
339
+
340
+ The `deleteAll()` method deletes multiple records at once, from a specified array of keys. This is similar to [deleteMulti()](#deletemulti), except it does not stop on the first error. Each delete error is logged, and the remaining keys are still attempted. Example:
341
+
342
+ ```js
343
+ storage.deleteAll( ['test1', 'test2', 'test3'], function(err) {
344
+ if (err) throw err;
345
+ } );
346
+ ```
347
+
331
348
  ## copy
332
349
 
333
350
  ```js
@@ -7,6 +7,8 @@
7
7
  const fs = require('fs');
8
8
  const Path = require('path');
9
9
  const zlib = require('zlib');
10
+ const os = require('os');
11
+ const cp = require('child_process');
10
12
  const Class = require("pixl-class");
11
13
  const Component = require("pixl-server/component");
12
14
  const pg = require('pg');
@@ -33,6 +35,8 @@ module.exports = Class.create({
33
35
  query_timeout: 6000,
34
36
  connectionTimeoutMillis: 30000,
35
37
  idleTimeoutMillis: 10000,
38
+ passwordPlugin: "",
39
+ passwordPluginTimeout: 30000,
36
40
  table: "items"
37
41
  },
38
42
 
@@ -75,9 +79,16 @@ module.exports = Class.create({
75
79
  }
76
80
 
77
81
  // pass entire config to pg.Pool, sans our custom keys
78
- var db = this.db = new pg.Pool(
79
- Tools.copyHashRemoveKeys( pg_config, { cache: 1, table: 1 })
80
- );
82
+ var pool_config = Tools.copyHashRemoveKeys( pg_config, { cache: 1, table: 1, passwordPlugin: 1, passwordPluginTimeout: 1 } );
83
+
84
+ if (pg_config.passwordPlugin) {
85
+ // node-postgres supports password as a sync/async callback, called for each
86
+ // new physical connection. We use that hook to ask an external command for
87
+ // short-lived provider tokens without baking provider-specific code in here.
88
+ pool_config.password = this.getPasswordFromPlugin.bind(this);
89
+ }
90
+
91
+ var db = this.db = new pg.Pool( pool_config );
81
92
 
82
93
  db.on('error', function(err) {
83
94
  self.logError('db', "DB Error: " + err, err);
@@ -104,6 +115,89 @@ module.exports = Class.create({
104
115
  this.queryWithClient( this.db, sql, args, callback );
105
116
  },
106
117
 
118
+ getPasswordFromPlugin: function() {
119
+ // execute an external command to resolve the Postgres password
120
+ // the plugin may also return a TTL, allowing us to cache it in memory
121
+ var self = this;
122
+ var pg_config = this.config.get();
123
+ var now = Date.now();
124
+
125
+ if (this.passwordPluginCache && (now < this.passwordPluginCache.expires)) {
126
+ return Promise.resolve( this.passwordPluginCache.password );
127
+ }
128
+
129
+ // If a plugin call is already in-flight, return the same Promise.
130
+ // This coalesces simultaneous pool reconnects into one plugin exec.
131
+ if (this.passwordPluginFetch) return this.passwordPluginFetch;
132
+
133
+ var hook_args = {
134
+ type: 'postgres_password',
135
+ config: Tools.copyHashRemoveKeys( pg_config, { password: 1, passwordPlugin: 1, passwordPluginTimeout: 1 } )
136
+ };
137
+
138
+ var child_cmd = pg_config.passwordPlugin;
139
+ var child_opts = {
140
+ cwd: os.tmpdir(),
141
+ timeout: pg_config.passwordPluginTimeout || 30000
142
+ };
143
+
144
+ this.logDebug(5, "Calling Postgres password plugin");
145
+
146
+ this.passwordPluginFetch = new Promise( function(resolve, reject) {
147
+ var child = cp.exec( child_cmd, child_opts, function(err, stdout, stderr) {
148
+ var json = null;
149
+
150
+ if (!err && stdout.match(/\S/)) {
151
+ // parse last line only, to omit any noise from plugin
152
+ try { json = JSON.parse( stdout.replace(/\r\n/g, "\n").trim().split(/\n/).pop() ); }
153
+ catch (e) {
154
+ err = new Error("Postgres password plugin JSON parse error: " + (e.message || e));
155
+ err.code = 'json';
156
+ }
157
+ }
158
+
159
+ if (stderr && stderr.match(/\S/)) {
160
+ self.logDebug(9, "Postgres password plugin emitted STDERR: " + Buffer.byteLength(stderr) + " bytes");
161
+ }
162
+
163
+ if (err) return reject( err );
164
+ if (!json) return reject( new Error("Postgres password plugin error: No JSON found in response STDOUT") );
165
+ if (json.code) return reject( new Error("Postgres password plugin error: " + (json.description || json.code)) );
166
+ if (typeof(json.password) != 'string') return reject( new Error("Postgres password plugin error: No password string found in response JSON") );
167
+
168
+ var ttl = parseInt( json.ttl || 0, 10 ) || 0;
169
+
170
+ if (ttl > 0) {
171
+ self.passwordPluginCache = {
172
+ password: json.password,
173
+ expires: Date.now() + (ttl * 1000)
174
+ };
175
+
176
+ self.logDebug(6, "Postgres password plugin returned password with TTL: " + ttl + " seconds");
177
+ }
178
+ else {
179
+ self.passwordPluginCache = null;
180
+ self.logDebug(6, "Postgres password plugin returned password without TTL");
181
+ }
182
+
183
+ resolve( json.password );
184
+ } ); // cp.exec
185
+
186
+ // Write hook data to child's stdin, using one compact JSON document.
187
+ child.stdin.on('error', noop);
188
+ child.stdin.write( JSON.stringify(hook_args) + "\n" );
189
+ child.stdin.end();
190
+ } ).then( function(password) {
191
+ self.passwordPluginFetch = null;
192
+ return password;
193
+ }, function(err) {
194
+ self.passwordPluginFetch = null;
195
+ throw err;
196
+ } );
197
+
198
+ return this.passwordPluginFetch;
199
+ },
200
+
107
201
  queryWithClient: function(client, sql, args, callback) {
108
202
  // run and log db query, and error if applicable
109
203
  // allow for named variables in sql, expand to numbered $ variables for pg
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixl-server-storage",
3
- "version": "4.2.1",
3
+ "version": "4.2.2",
4
4
  "description": "A key/value/list storage component for the pixl-server framework.",
5
5
  "author": "Joseph Huckaby <jhuckaby@gmail.com>",
6
6
  "homepage": "https://github.com/jhuckaby/pixl-server-storage",
package/storage.js CHANGED
@@ -516,6 +516,35 @@ module.exports = Class.create({
516
516
  );
517
517
  },
518
518
 
519
+ deleteAll: function(keys, callback) {
520
+ // delete multiple records at once, given array of keys
521
+ // continue through all keys, even if some deletes fail
522
+ var self = this;
523
+ if (!this.started) return callback( new Error("Storage has not completed startup.") );
524
+
525
+ // if engine provides its own deleteAll, call that directly
526
+ if (("deleteAll" in this.engine) && !this.currentTransactionPath) {
527
+ return this.engine.deleteAll(keys, callback);
528
+ }
529
+
530
+ async.eachLimit(keys, this.concurrency,
531
+ function(key, callback) {
532
+ // iterator for each key
533
+ self.delete(key, function(err) {
534
+ if (err) {
535
+ // log and swallow the error so the remaining deletes can proceed
536
+ self.logError('storage', "Failed to delete key in deleteAll: " + key + ": " + err);
537
+ }
538
+ callback();
539
+ } );
540
+ },
541
+ function() {
542
+ // all keys attempted
543
+ callback();
544
+ }
545
+ );
546
+ },
547
+
519
548
  copy: function(old_key, new_key, callback) {
520
549
  // copy record to new key
521
550
  var self = this;
package/test/test-main.js CHANGED
@@ -656,6 +656,38 @@ module.exports = {
656
656
  } );
657
657
  },
658
658
 
659
+ function testDeleteAll(test) {
660
+ // delete multiple keys using deleteAll, continuing past errors
661
+ test.expect(5);
662
+ var self = this;
663
+ var keys = ['deleteall1', 'deleteall_missing', 'deleteall2'];
664
+
665
+ this.storage.putMulti( {
666
+ deleteall1: { fruit: 'apple' },
667
+ deleteall2: { fruit: 'banana' }
668
+ }, function(err) {
669
+ test.ok( !err, "No error creating deleteAll test records: " + err );
670
+
671
+ self.storage.deleteAll( keys, function(err) {
672
+ test.ok( !err, "No error calling deleteAll: " + err );
673
+
674
+ // the missing middle key should not prevent the final key from deleting
675
+ self.storage.get( 'deleteall1', function(err) {
676
+ test.ok( !!err, "Expected error fetching first deleteAll key after delete" );
677
+
678
+ self.storage.get( 'deleteall_missing', function(err) {
679
+ test.ok( !!err, "Expected error fetching missing deleteAll key after delete" );
680
+
681
+ self.storage.get( 'deleteall2', function(err) {
682
+ test.ok( !!err, "Expected error fetching final deleteAll key after delete" );
683
+ test.done();
684
+ } );
685
+ } );
686
+ } );
687
+ } );
688
+ } );
689
+ },
690
+
659
691
  function testOptimize(test) {
660
692
  // run engine-specific optimization, or a safe no-op if unsupported
661
693
  test.expect(8);
@@ -0,0 +1,243 @@
1
+ // Unit tests for Postgres password plugin support
2
+ // Copyright (c) 2026 Joseph Huckaby
3
+ // Released under the MIT License
4
+
5
+ var cp = require('child_process');
6
+ var EventEmitter = require('events').EventEmitter;
7
+ var Module = require('module');
8
+
9
+ var origLoad = Module._load;
10
+ var origExec = cp.exec;
11
+
12
+ // The Postgres engine requires 'pg' at module load time. Stub it here so this
13
+ // focused test can run without needing a live Postgres module or database.
14
+ var fakePG = {
15
+ lastConfig: null,
16
+ Pool: function(config) {
17
+ fakePG.lastConfig = config;
18
+ this.on = function() {};
19
+ this.query = function(sql, args, callback) {
20
+ callback( null, { command: 'CREATE', rowCount: 0, rows: [] } );
21
+ };
22
+ }
23
+ };
24
+
25
+ Module._load = function(request, parent, isMain) {
26
+ if (request == 'pg') return fakePG;
27
+ return origLoad.apply( this, arguments );
28
+ };
29
+
30
+ var PostgresEngine = require('../engines/Postgres.js');
31
+ Module._load = origLoad;
32
+
33
+ function makeEngine(config) {
34
+ // Keep this intentionally tiny. The password plugin helper only needs the
35
+ // component config and log methods, and setup() only needs query logging.
36
+ var engine = Object.create( PostgresEngine.prototype );
37
+ engine.config = {
38
+ get: function() { return config; }
39
+ };
40
+ engine.logDebug = function() {};
41
+ engine.logError = function() {};
42
+ return engine;
43
+ }
44
+
45
+ function mockExec(responder) {
46
+ var calls = [];
47
+
48
+ cp.exec = function(cmd, opts, callback) {
49
+ var call = { cmd: cmd, opts: opts, stdin: '' };
50
+ var child = { stdin: new EventEmitter() };
51
+ calls.push( call );
52
+
53
+ child.stdin.write = function(data) {
54
+ call.stdin += data;
55
+ };
56
+ child.stdin.end = function() {
57
+ responder( call, callback );
58
+ };
59
+
60
+ return child;
61
+ };
62
+
63
+ return calls;
64
+ }
65
+
66
+ function restoreExec() {
67
+ cp.exec = origExec;
68
+ }
69
+
70
+ module.exports = {
71
+ tests: [
72
+
73
+ function setup_installsPasswordFunctionAndStripsPluginKeys(test) {
74
+ test.expect(4);
75
+
76
+ var engine = makeEngine({
77
+ host: 'localhost',
78
+ database: 'test',
79
+ user: 'test',
80
+ password: 'static',
81
+ passwordPlugin: 'node ./plugin.js',
82
+ passwordPluginTimeout: 1234,
83
+ table: 'items'
84
+ });
85
+
86
+ fakePG.lastConfig = null;
87
+ engine.setup( function(err) {
88
+ test.ok( !err, "Setup completed without error" );
89
+ test.ok( typeof(fakePG.lastConfig.password) == 'function', "Password was converted to a callback function" );
90
+ test.ok( !('passwordPlugin' in fakePG.lastConfig), "passwordPlugin was stripped from pool config" );
91
+ test.ok( !('passwordPluginTimeout' in fakePG.lastConfig), "passwordPluginTimeout was stripped from pool config" );
92
+ test.done();
93
+ } );
94
+ },
95
+
96
+ function passwordPlugin_returnsPasswordAndSendsConfig(test) {
97
+ test.expect(5);
98
+
99
+ var engine = makeEngine({
100
+ host: 'localhost',
101
+ database: 'test',
102
+ user: 'tester',
103
+ password: 'static',
104
+ passwordPlugin: 'node ./plugin.js',
105
+ passwordPluginTimeout: 1234,
106
+ table: 'items'
107
+ });
108
+
109
+ var calls = mockExec( function(call, callback) {
110
+ callback( null, JSON.stringify({ xy: 1, password: 'dynamic-secret' }) + "\n", "" );
111
+ } );
112
+
113
+ engine.getPasswordFromPlugin().then( function(password) {
114
+ var input = JSON.parse( calls[0].stdin );
115
+
116
+ test.ok( password == 'dynamic-secret', "Returned dynamic password" );
117
+ test.ok( calls[0].cmd == 'node ./plugin.js', "Used configured plugin command" );
118
+ test.ok( calls[0].opts.timeout == 1234, "Used configured plugin timeout" );
119
+ test.ok( input.type == 'postgres_password', "Sent expected hook type" );
120
+ test.ok( !('password' in input.config), "Static password was omitted from plugin input" );
121
+
122
+ restoreExec();
123
+ test.done();
124
+ } ).catch( function(err) {
125
+ restoreExec();
126
+ test.ok( false, "Unexpected error: " + err );
127
+ test.done();
128
+ } );
129
+ },
130
+
131
+ function passwordPlugin_cachesPasswordForTTL(test) {
132
+ test.expect(3);
133
+
134
+ var engine = makeEngine({
135
+ passwordPlugin: 'node ./plugin.js',
136
+ table: 'items'
137
+ });
138
+
139
+ var calls = mockExec( function(call, callback) {
140
+ callback( null, JSON.stringify({ xy: 1, password: 'cached-secret', ttl: 60 }) + "\n", "" );
141
+ } );
142
+
143
+ engine.getPasswordFromPlugin().then( function(first) {
144
+ return engine.getPasswordFromPlugin().then( function(second) {
145
+ test.ok( first == 'cached-secret', "First password was returned" );
146
+ test.ok( second == 'cached-secret', "Second password was returned from cache" );
147
+ test.ok( calls.length == 1, "Plugin was only invoked once" );
148
+
149
+ restoreExec();
150
+ test.done();
151
+ } );
152
+ } ).catch( function(err) {
153
+ restoreExec();
154
+ test.ok( false, "Unexpected error: " + err );
155
+ test.done();
156
+ } );
157
+ },
158
+
159
+ function passwordPlugin_doesNotCacheWithoutTTL(test) {
160
+ test.expect(2);
161
+
162
+ var counter = 0;
163
+ var engine = makeEngine({
164
+ passwordPlugin: 'node ./plugin.js',
165
+ table: 'items'
166
+ });
167
+
168
+ var calls = mockExec( function(call, callback) {
169
+ counter++;
170
+ callback( null, JSON.stringify({ xy: 1, password: 'secret-' + counter }) + "\n", "" );
171
+ } );
172
+
173
+ engine.getPasswordFromPlugin().then( function(first) {
174
+ return engine.getPasswordFromPlugin().then( function(second) {
175
+ test.ok( first != second, "Passwords were fetched separately" );
176
+ test.ok( calls.length == 2, "Plugin was invoked twice" );
177
+
178
+ restoreExec();
179
+ test.done();
180
+ } );
181
+ } ).catch( function(err) {
182
+ restoreExec();
183
+ test.ok( false, "Unexpected error: " + err );
184
+ test.done();
185
+ } );
186
+ },
187
+
188
+ function passwordPlugin_coalescesConcurrentFetches(test) {
189
+ test.expect(2);
190
+
191
+ var engine = makeEngine({
192
+ passwordPlugin: 'node ./plugin.js',
193
+ table: 'items'
194
+ });
195
+
196
+ var calls = mockExec( function(call, callback) {
197
+ setTimeout( function() {
198
+ callback( null, JSON.stringify({ xy: 1, password: 'shared-secret', ttl: 60 }) + "\n", "" );
199
+ }, 10 );
200
+ } );
201
+
202
+ Promise.all([
203
+ engine.getPasswordFromPlugin(),
204
+ engine.getPasswordFromPlugin(),
205
+ engine.getPasswordFromPlugin()
206
+ ]).then( function(passwords) {
207
+ test.ok( passwords.join(',') == 'shared-secret,shared-secret,shared-secret', "All callers received the same password" );
208
+ test.ok( calls.length == 1, "Only one plugin process was spawned" );
209
+
210
+ restoreExec();
211
+ test.done();
212
+ } ).catch( function(err) {
213
+ restoreExec();
214
+ test.ok( false, "Unexpected error: " + err );
215
+ test.done();
216
+ } );
217
+ },
218
+
219
+ function passwordPlugin_reportsPluginError(test) {
220
+ test.expect(1);
221
+
222
+ var engine = makeEngine({
223
+ passwordPlugin: 'node ./plugin.js',
224
+ table: 'items'
225
+ });
226
+
227
+ mockExec( function(call, callback) {
228
+ callback( null, JSON.stringify({ xy: 1, code: 'auth', description: 'Token rejected' }) + "\n", "" );
229
+ } );
230
+
231
+ engine.getPasswordFromPlugin().then( function() {
232
+ restoreExec();
233
+ test.ok( false, "Expected plugin error" );
234
+ test.done();
235
+ } ).catch( function(err) {
236
+ restoreExec();
237
+ test.ok( /Token rejected/.test(err.message), "Reported plugin error: " + err.message );
238
+ test.done();
239
+ } );
240
+ }
241
+
242
+ ]
243
+ };