diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..2c5ec4a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "bracketSpacing": true, + "printWidth": 80, + "singleQuote": true, + "tabWidth": 4, + "trailingComma": "none", + "useTabs": false +} diff --git a/README.md b/README.md index e82a763..3d73bd8 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,229 @@ # greenlock-manager-test.js -A simple test suite for Greenlock manager plugins. \ No newline at end of file +A simple test suite for Greenlock manager plugins. + +# Greenlock Manager + +A greenlock manager is just a set of a few callbacks to keeps track of: + +- **Default settings** that apply to all sites such as + - `subscriberEmail` + - `agreeToTerms` + - `store` (the account key and ssl certificate store) +- **Site settings** such as + - `subject` (ex: example.com) + - `altnames` (ex: example.com,www.example.com) + - `renewAt` (ex: '45d') + - `challenges` (plugins for 'http-01', 'dns-01', etc) + +The callbacks are: + +- `set({ subject, altnames, renewAt })` to save site details +- `find({ subject, altnames, renewBefore })` which returns a list of matching sites (perhaps all sites) +- `remove({ subject })` which marks a site as deleted +- `defaults()` which either **gets** or **sets** the global configs that apply to all sites + +# Some Terminology + +- `subject` refers to the **primary domain** on an SSL certificate +- `altnames` refers to the list of **domain names** on the certificate (including the subject) +- `renewAt` is a pre-calculated value based on `expiresAt` or `issuedAt` on the certificate + +Those are the only values you really have to worry about. + +The rest you can make up for your own needs, or they're just opaque values you'll get from Greenlock. + +# Do you want to build a plugin? + +You can start _really_ simple: just make a file that exports a `create()` function: + +## A great first, failing plugin: + +`my-plugin.js`: + +```js +'use strict'; + +var MyManager = module.exports; +MyManager.create = function(options) { + console.log('The tests will make me stronger'); + return {}; +}; +``` + +## The test suite from heaven + +You write your test file, run it, +and then you get a play-by-play of what to do. + +``` +npm install --save-dev greenlock-manager-test +``` + +`test.js`: + +```js +'use strict'; + +var Tester = require('greenlock-manager-test'); +var MyManager = require('./'); +var myConfigOptions = { + someApiTokenForMyManager: 'xxx' +}; + +Tester.test(MyManager, myConfigOptions) + .then(function() { + console.log('All Tests Passed'); + }) + .catch(function(err) { + console.error('Oops... something bad happened:'); + console.error(err); + }); +``` + +You just follow the error messages and, which a little help from this README, +bam!, you get a working plugin. It's insane! + +# The lazy, hacky way. + +If you're going to publish a module, you should pass the full test suite. + +If not, eh, you can be lazy. + +## Bare minimum... + +At a bare minimum, you must implement `find()` to return an array of `{ subject, altnames }`. + +For example: + +```js +function find(argsToIgnore) { + return Promise.resolve([ + { subject: 'example.com', altnames: ['example.com', 'www.example.com'] } + ]); +} +``` + +If that's absolutely all that you do, all of the other methods will be implemented around `greenlock-manager-fs`. + +# The Right Way™ + +If you want to publish a module to the community you should do a slightly better job: + +```js +module.exports.create = function(options) { + var manager = {}; + + // add some things to... wherever you save things + + manager.set = async function(siteConfig) { + // You can see in the tests a sample of common values, + // but you don't really need to worry about it. + var subject = siteConfig; + + // Cherry pick what you like for indexing / search, and JSONify the rest + return mergeOrCreateSite(subject, siteConfig); + }; + + // find the things you've saved before + + manager.find = async function({ subject, altnames, renewBefore }) { + var results = []; + var gotten = {}; + + if (subject) { + var site = await getSiteBySubject(subject); + if (site) { + results.push(site); + gotten[site.subject] = true; + } + } + + if (altnames) { + var sites = await getSiteByAltnames(subject); + sites.forEach(function() {}); + if (site) { + if (!gotten[site.subject]) { + results.push(site); + gotten[site.subject] = true; + } + } + } + + if (subject || altnames) { + return results; + } + + if (renewBefore) { + return getSitesThatShouldBeRenewedBefore(renewBefore); + } + + return getAllSites(); + }; + + // delete a site config + + manager.remove = async function({ subject }) { + // set deletedAt to a value, or actually delete it - however you like + return mergeOrCreateSite(subject, { deletedAt: Date.now() }); + }; + + // get / set global things + + manager.defaults = async function(options) { + if (!options) { + return getDefaultConfigValues(); + } + + return mergeDefaultConfigValues(options); + }; + + // optional, if you need it + + manager.init = async function(deps) { + // a place to do some init, if you need it + + return doMyInit(); + + // Also, `deps` will have some common dependencies + // than many modules need, such as `request`. + // This cuts down on stray dependencies, and helps + // with browser compatibility. + + request = deps.request; + }; +}; +``` + +# How to use your plugin + +The **Right Way**: + +```js +var Greenlock = require('greenlock'); +var greenlock = Greenlock.create({ + manager: '/absolute/path/to/manager' + someOptionYouWant: true, +}); +``` + +## Why no require? + +Okay, so you **expect** it to look like this: + +```js +var Greenlock = require('greenlock'); +var greenlock = Greenlock.create({ + // WRONG!! + manager: require('./relative/path/to/manager').create({ + someOptionYouWant: true + }) +}); +``` + +**NOPE**! + +It just has to do with some plugin architecture decisions around making the configuration +serializable. + +I may go back and add the other way, but this is how it is right now. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b32efa6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,13 @@ +{ + "name": "greenlock-manager-test", + "version": "3.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@root/request": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@root/request/-/request-1.4.1.tgz", + "integrity": "sha512-2zSP1v9VhJ3gvm4oph0C4BYCoM3Sj84/Wx4iKdt0IbqbJzfON04EodBq5dsV65UxO/aHZciUBwY2GCZcHqaTYg==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..3154783 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "greenlock-manager-test", + "version": "3.0.0", + "description": "A simple test suite for Greenlock manager plugins.", + "main": "tester.js", + "scripts": { + "test": "node tests" + }, + "files": [ + "*.js", + "lib" + ], + "repository": { + "type": "git", + "url": "https://git.rootprojects.org/root/greenlock-manager-test.js" + }, + "keywords": [ + "Greenlock", + "manager", + "plugin" + ], + "author": "AJ ONeal (https://coolaj86.com/)", + "license": "MPL-2.0", + "dependencies": { + "@root/request": "^1.4.1", + "greenlock-manager-fs": "^3.0.0" + } +} diff --git a/tester.js b/tester.js new file mode 100644 index 0000000..10e16ad --- /dev/null +++ b/tester.js @@ -0,0 +1,165 @@ +'use strict'; + +var request = require('@root/request'); + +var domains = ['example.com', 'www.example.com']; +module.exports.test = async function(pkg, config) { + if ('function' !== typeof pkg.create) { + throw new Error( + 'must have a create function that accepts a single options object' + ); + } + + var manager = pkg.create(config); + + if (manager.init) { + await manager.init({ + request: request + }); + } else { + console.warn( + 'WARN: should have an init(deps) function which returns a promise' + ); + } + + await manager.set({ + subject: domains[0], + altnames: domains + }); + + await manager.find({}).then(function(results) { + if (!results.length) { + console.log(results); + throw new Error('should have found all managed sites'); + } + }); + console.log('PASS: set'); + + await manager.find({ subject: 'www.example.com' }).then(function(results) { + if (results.length) { + console.log(results); + throw new Error( + "shouldn't find what doesn't exist, exactly, by subject" + ); + } + }); + + await manager + .find({ altnames: ['www.example.com'] }) + .then(function(results) { + if (!results.length) { + console.log(results); + throw new Error('should have found sites matching altname'); + } + }); + + await manager.find({ altnames: ['*.example.com'] }).then(function(results) { + if (results.length) { + console.log(results); + throw new Error( + 'should only find an exact (literal) wildcard match' + ); + } + }); + console.log('PASS: find'); + + await manager.remove({ subject: '*.example.com' }).then(function(result) { + if (result) { + throw new Error( + 'should not return prior object when deleting non-existing site' + ); + } + }); + + await manager.remove({ subject: 'www.example.com' }).then(function(result) { + if (result) { + throw new Error( + 'should not return prior object when deleting non-existing site' + ); + } + }); + + await manager.remove({ subject: 'example.com' }).then(function(result) { + if (!result || !result.subject || !result.altnames) { + throw new Error('should return prior object when deleting site'); + } + }); + + await manager + .find({ altnames: ['example.com', 'www.example.com'] }) + .then(function(results) { + if (results.length) { + console.log(results); + throw new Error('should not find deleted sites'); + } + }); + console.log('PASS: remove'); + + var originalInput = { + serverKeyType: 'RSA-2048', + accountKeyType: 'P-256', + subscriberEmail: 'jon@example.com', + agreeToTerms: true, + store: { module: '/path/to/store-module', foo: 'foo' }, + challenges: { + 'http-01': { module: '/path/to/http-01-module', bar: 'bar' }, + 'dns-01': { module: '/path/to/dns-01-module', baz: 'baz' }, + 'tls-alpn-01': { + module: '/path/to/tls-alpn-01-module', + qux: 'quux' + } + }, + customerEmail: 'jane@example.com' + }; + //var backup = JSON.parse(JSON.stringify(originalInput)); + var configUpdate = { + renewOffset: '45d', + renewStagger: '12h', + subscriberEmail: 'pat@example.com' + }; + + var internalConfig; + await manager.defaults().then(function(result) { + internalConfig = result; + if (!result) { + throw new Error( + 'should at least return an empty object, perhaps one with some defaults set' + ); + } + }); + + await manager.defaults(originalInput).then(function(result) { + // can't say much... what _should_ this return? + // probably nothing? or maybe the full config object? + if (internalConfig === result) { + console.warn( + 'WARN: should return a new copy, not the same internal object' + ); + } + if (originalInput === result) { + console.warn( + 'WARN: should probably return a copy, not the original input' + ); + } + }); + + await manager.defaults().then(function(result) { + if (originalInput === result) { + console.warn('WARN: should probably return a copy, not the prior input'); + } + }); + + await manager.defaults(configUpdate).then(function() { + if (originalInput.renewOffset) { + console.warn('WARN: should probably modify the prior input'); + } + }); + + await manager.defaults().then(function(result) { + if (!result.subscriberEmail || !result.renewOffset) { + throw new Error('should merge config values together'); + } + }); + + console.log('PASS: defaults'); +}; diff --git a/tests/index.js b/tests/index.js new file mode 100644 index 0000000..515b615 --- /dev/null +++ b/tests/index.js @@ -0,0 +1,19 @@ +'use strict'; + +var Tester = require('../'); + +var Manager = require('greenlock-manager-fs'); +var config = { + configFile: 'greenlock-manager-test.delete-me.json' +}; + +Tester.test(Manager, config) + .then(function() { + console.log('PASS: Known-good test module passes'); + }) + .catch(function(err) { + console.error('Oops, you broke it. Here are the details:'); + console.error(err.stack); + console.error(); + console.error("That's all I know."); + });