├── .eslintrc ├── .travis.yml ├── logos └── logo-box-madefor.png ├── .gitignore ├── .github └── workflows │ └── test.yml ├── package.json ├── CHANGELOG.md ├── README.md ├── index.js └── test └── test.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "apostrophe" 3 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "stable" 4 | - "lts/*" 5 | -------------------------------------------------------------------------------- /logos/logo-box-madefor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apostrophecms/launder/main/logos/logo-box-madefor.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | npm-debug.log 3 | *.DS_Store 4 | node_modules 5 | # We do not commit CSS, only LESS 6 | public/css/*.css 7 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["*"] 8 | 9 | workflow_dispatch: 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: [18, 20] 17 | mongodb-version: [6.0, 7.0] 18 | 19 | steps: 20 | - name: Git checkout 21 | uses: actions/checkout@v4 22 | 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | 28 | - name: Start MongoDB 29 | uses: supercharge/mongodb-github-action@1.11.0 30 | with: 31 | mongodb-version: ${{ matrix.mongodb-version }} 32 | 33 | - run: npm install 34 | 35 | - run: npm test 36 | env: 37 | CI: true 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "launder", 3 | "version": "1.7.0", 4 | "description": "A sanitize module for the people. Built for ApostropheCMS.", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "npm run eslint", 8 | "eslint": "eslint .", 9 | "test": "npm run lint && npm run mocha", 10 | "mocha": "mocha" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/apostrophecms/launder.git" 15 | }, 16 | "keywords": [ 17 | "sanitize", 18 | "launder" 19 | ], 20 | "author": "Apostrophe Technologies, Inc.", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/apostrophecms/launder/issues" 24 | }, 25 | "homepage": "https://github.com/apostrophecms/launder", 26 | "devDependencies": { 27 | "eslint-config-apostrophe": "^5.0.0", 28 | "mocha": "^5.0.0" 29 | }, 30 | "dependencies": { 31 | "dayjs": "^1.11.7" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.7.0 - 2023-05-03 4 | 5 | * Modernize dependencies to correct installation warnings, and eliminate use of `var`. 6 | 7 | ## 1.6.0 - 2023-03-06 8 | 9 | * `sms:` urls are now accepted. Thanks to [Ruben](https://github.com/reubendsouza) for this change. 10 | 11 | ## 1.5.1 - 2021-10-28 12 | 13 | * The `date` method now correctly returns `null` if the date argument is unparseable and the `def` parameter is explicitly `null`. As was always documented, a default of `undefined` still returns the current date. 14 | 15 | ## 1.5.0 16 | 17 | * The `url` method now accepts a third argument, `httpsFix`. If it is `true` and the URL passed in has no protocol, the URL will be prepended with `https://` rather than `http://`. 18 | 19 | ## 1.4.0 20 | 21 | * `tel:` urls are now accepted. 22 | 23 | ## 1.3.0 24 | 25 | * `booleanOrNull` accepts the string `'null'` as a synonym for `null`. Note that `'any'` was already accepted. `'null'` can be an attractive choice when the user will not see it in the query string and conflict with other uses of `'any'` is a concern. 26 | 27 | ## 1.2.0 28 | 29 | * `idRegExp` option may be passed to change the rules for `launder.id`. 30 | 31 | ## 1.1.2 32 | 33 | * linting. 34 | 35 | ## 1.1.1 36 | 37 | * for improved bc, `launder.select` does not crash if some of the choices given to `select` are null or undefined. Although this is a developer error rather than a sanitization issue, versions prior to 1.1.0 did tolerate this situation, so 1.1.1 does so as well. Thanks to Anthony Tarlao for his code contributions, and to Michelin for making this fix possible via [Apostrophe Enterprise Support](https://apostrophecms.com/support/enterprise-support). 38 | 39 | ## 1.1.0 40 | 41 | * `launder.select` now handles numeric values for choices gracefully. Specifically, if the value passed in is a string, it will be validated as a match for a choice that is a number, as long as they have the same string representation, and the number (not the string) will be returned. Previously there was no match in this situation. 42 | 43 | Thanks to Michelin for making this possible via [Apostrophe Enterprise Support](https://apostrophecms.com/support/enterprise-support). 44 | 45 | ## 1.0.1 46 | 47 | * `launder.time` will now also accept a `.` (dot) as the separator (until now only `:` colon was recognized). Thanks to Lars Houmark. 48 | 49 | ## 1.0.0 50 | 51 | * switched to a maintained, secure fork of lodash 3, declared 1.0.0 as this has been a stable part of Apostrophe for years. 52 | 53 | ## 0.1.3 54 | 55 | * `launder.booleanOrNull` broken out from `launder.addBooleanFilterCriteria` so that you can get the tri-state value without modifying a criteria object. 56 | 57 | ## 0.1.2 58 | 59 | * `launder.tags` also accepts a comma-separated string. 60 | 61 | ## 0.1.1 62 | 63 | * removed never-used and undocumented `parseTime` method. 64 | 65 | ## 0.1.0 66 | 67 | * initial release. Based on stable code recently refactored from Apostrophe 0.5.x. 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | A sanitization module for the people. Built for use in the [ApostropheCMS](https://apostrophecms.com), useful for many other things. 4 | 5 | ## Purpose 6 | 7 | Launder can be used to sanitize strings, integers, floats, urls, and more. It's best for cases where you've already used front-end validation to encourage smart input, and now you want to make sure your inputs are reasonable. 8 | 9 | Launder does not always assume your data is a string, which makes it highly compatible with the use of JSON to deliver data from browser to server. For instance, `launder.boolean` accepts the actual JavaScript values `true` and `false` as well as various string representations. 10 | 11 | Launder's support for dates and times permits users to enter both in colloquial formats like `8/25` or `3pm` and "just does the right thing," converting to `2015-08-25` and `15:00:00` respectively. 12 | 13 | In addition to sanitization methods, Launder does contain a few other tools, such as `formatDate` and `formatTime` which simply output a Date object in the `2015-08-25` and `15:00:00` formats. 14 | 15 | ## Usage 16 | 17 | ```javascript 18 | const launder = require('launder')(); 19 | 20 | app.post('/form', function(req, res) { 21 | const units = launder.integer(req.body.units, 0, 0, 100); 22 | const birthdate = launder.date(req.body.birthdate); 23 | }); 24 | ``` 25 | 26 | You can also specify global options: 27 | 28 | ```javascript 29 | const launder = require('launder')({ 30 | filterTag: function(tag) { return tag.toLowerCase(); } 31 | }); 32 | ``` 33 | 34 | ## Frequently used methods 35 | 36 | ### `launder.string(s, def)` 37 | 38 | Converts `s` to a string. `s` is coerced to a string, then leading and trailing whitespace is trimmed. If `def` is provided, it is returned when the string is empty or the value passed is not a string. If `def` is undefined, empty strings are left alone, and values that are not strings become empty strings. 39 | 40 | ### `launder.strings(arr)` 41 | 42 | If `arr` is an array, each element is sanitized with `launder.string`, and a new array containing the result is returned. If `arr` is not an array, an empty array is returned. 43 | 44 | ### `launder.integer(i, def, min, max)` 45 | 46 | Converts `i` to an integer. `i` is first coerced to an integer, if needed; if it is an empty string, undefined or otherwise not convertible, `def` is returned. If `min` is provided, and the result would be less than `min`, `min` is returned. If `max` is provided, and the result would be greater than `max`, `max` is returned. If `def` is not provided, the default is `0`. If a number has a fractional part it is discarded, not rounded. 47 | 48 | ### `launder.float(f, def, min, max)` 49 | 50 | Converts `f` to a floating-point number. `f` is first coerced to a floating-point number, if needed; if it is an empty string, undefined or otherwise not convertible, `def` is returned. If `min` is provided, and the result would be less than `min`, `min` is returned. If `max` is provided, and the result would be greater than `max`, `max` is returned. If `def` is not provided, the default is `0`. 51 | 52 | ### `launder.url(s, def, httpsFix)` 53 | 54 | Attempts to ensure that `s` is a valid URL. This method allows only the `http:`, `https:`, `ftp:`, `mailto:`, `tel:` and `sms:` URL schemes, but does allow relative URLs. 55 | 56 | It attempts to automatically fix common user mistakes such as typing: `www.mycompany.com` or `www.mycompany.com/my/page.html`, not supplying the URL protocol. By default it prepends `http://`. If `httpsFix` is `true`, it prepends `https://`. 57 | 58 | `s` is first sanitized with `launder.string()`. 59 | 60 | `def` is returned if the input is an empty string, not convertible to a URL, or suspicious (such as a `javascript:` URL). Spaces are removed as they are ignored by browsers in a surprising number of situations. 61 | 62 | ### `launder.select(choice, choices, def)` 63 | 64 | Sanitize a choice made via a `select` element. If `choice` is one of the `choices`, it is returned, otherwise `def` is returned. If `choices` is an array of objects, then `choice` is compared to the `value` property of each object to find a match. 65 | 66 | Choices can be numbers or strings. The choices and the input value are compared as strings. The matching choice is returned with its original type. 67 | 68 | ### `launder.boolean(b, def)` 69 | 70 | Sanitize a boolean value. 71 | 72 | If the value is any of the following, `true` is returned: 73 | 74 | `true` 75 | `'true'` 76 | `'True'` 77 | `'t'` 78 | `'yes'` 79 | `'Yes'` 80 | `'y'` 81 | `'1'` 82 | *Any other string starting with `t`, `y`, `T`, `Y`, or `1`)* 83 | `1` 84 | 85 | Note that both the string `'1'` and the number `1` are accepted. 86 | 87 | If `b` is not `true` or `false`, and `launder.string(b)` returns the empty string, then `false` is returned unless `def` is defined, in which case `def` is returned. 88 | 89 | ### `launder.date(d, def, now)` 90 | 91 | Converts `d` to a date string in `YYYY-MM-DD` format, such as `2015-02-20`. 92 | 93 | `d` must be either a string or a `Date` object, otherwise `def` is returned. *If `def` is undefined, the current date is returned. If `def` is `null`, `null` is returned.* 94 | 95 | `now` can be the current date object for resolving ambiguous dates. If it is not provided, a new `Date` object is created. 96 | 97 | The following date string formats are supported: 98 | 99 | `YYYY-MM-DD` 100 | `MM/DD/YYYY` 101 | `MM/DD/YY` (*) 102 | `MM/DD` (implies current year) 103 | 104 | (*) Implies the current century, unless the result would be more than 50 years in the future, in which case it implies the previous century. This works well for the popular usage of two-digit years. If it bothers you, use four-digit years! 105 | 106 | ### `launder.time(t, def)` 107 | 108 | Converts `t` to a time string in `HH:MM:SS` format, such as `16:30:00`. 109 | 110 | The following formats are accepted: 111 | 112 | `16:30:00` 113 | `16:30` 114 | `16` 115 | `1pm` 116 | `2:37am` 117 | `2:37:12am` 118 | `2PM` (case insensitive, in general) 119 | `2p` (`m` is optional) 120 | `2 pm` (spaces don't matter) 121 | `4:30a` 122 | 123 | If `launder.string(t)` returns the empty string, `def` is returned. *If `def` is not provided, the current time is returned.* 124 | 125 | ### `launder.tags(arr, filter)` 126 | 127 | Sanitize an array of tags. All strings and numbers in the supplied array are passed through `launder.string`, then through `filter`. If `filter` is not passed, the `filterTag` function provided as an option when configuring `launder` is used. If that option is not passed, the default `filterTag` function is used. 128 | 129 | The default `filterTag` function trims whitespace and converts to lowercase. 130 | 131 | Any elements which have been laundered to the empty string are discarded. 132 | 133 | ### `launder.id(s, def)` 134 | 135 | Sanitize an ID. For our purposes an ID is made up of the characters `A-Z`, `a-z`, `0-9` and `_`. An ID may begin with any of these characters. An ID must contain at least one character. `launder.string` is first used to coerce `s` to a string. 136 | 137 | If any of these criteria are not met, `def` is returned. 138 | 139 | ### `launder.ids(ids)` 140 | 141 | Sanitize an array of IDs. Each element is passed through `launder.id`. Any IDs that do not meet the criteria are omitted from the returned array. 142 | 143 | ## Miscellaneous methods 144 | 145 | We use these a lot in Apostrophe, but they might not feel as relevant for other applications. Use them if you wish! 146 | 147 | ### `launder.addBooleanFilterCriteria(options, name, criteria, def)` 148 | 149 | Use a tri-state filter value such as `'true'`, `'false'`, or `'any'` to build a MongoDB-style query criteria object. 150 | 151 | `options[name]` should be a string such as `'true'`, `'false'` or `'any'`. 152 | 153 | `criteria[name]` will then be set to `true`, `{ $ne: true }`, or left entirely unset. 154 | 155 | Any value accepted by `launder.boolean` is acceptable to specify `true` and `false`. Also, `null` is accepted as a synonym for `'any'`. 156 | 157 | If `def` is not specified, the default behavior is `any`. 158 | 159 | ### `launder.formatDate(date)` 160 | 161 | Output the given `Date` object in `YYYY-MM-DD` format. This is the canonical date format for Apostrophe. 162 | 163 | ### `launder.formatTime(date)` 164 | 165 | Output the given `Date` object in `HH:mm:ss` format. This is the canonical time format for Apostrophe. 166 | 167 | ### `launder.padInteger(i, places)` 168 | 169 | Pads the specified integer with leading zeroes to ensure it has at least `places` digits and returns the resulting string. 170 | 171 | ## About ApostropheCMS 172 | 173 | `launder` was created for use in ApostropheCMS, an open-source content management system built on Node.js. If you like `launder` you should definitely [check out apostrophecms.org](https://apostrophecms.com). Also be sure to visit us on [github](http://github.com/apostrophecms). 174 | 175 | ## Support 176 | 177 | Feel free to open issues on [github](http://github.com/apostrophecms/launder). 178 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const dayjs = require('dayjs'); 2 | 3 | module.exports = function(options) { 4 | const self = {}; 5 | self.options = options || {}; 6 | 7 | self.filterTag = self.options.filterTag || function(tag) { 8 | tag = tag.trim(); 9 | return tag.toLowerCase(); 10 | }; 11 | 12 | self.string = function(s, def) { 13 | if (typeof (s) !== 'string') { 14 | if ((typeof (s) === 'number') || (typeof (s) === 'boolean')) { 15 | s += ''; 16 | } else { 17 | s = ''; 18 | } 19 | } 20 | s = s.trim(); 21 | if (def !== undefined) { 22 | if (s === '') { 23 | s = def; 24 | } 25 | } 26 | return s; 27 | }; 28 | 29 | self.strings = function(strings) { 30 | if (!Array.isArray(strings)) { 31 | return []; 32 | } 33 | return strings.map(function(s) { 34 | return self.string(s); 35 | }); 36 | }; 37 | 38 | self.integer = function(i, def, min, max) { 39 | if (def === undefined) { 40 | def = 0; 41 | } 42 | if (typeof (i) === 'number') { 43 | i = Math.floor(i); 44 | } else { 45 | try { 46 | i = parseInt(i, 10); 47 | if (isNaN(i)) { 48 | i = def; 49 | } 50 | } catch (e) { 51 | i = def; 52 | } 53 | } 54 | if ((typeof (min) === 'number') && (i < min)) { 55 | i = min; 56 | } 57 | if ((typeof (max) === 'number') && (i > max)) { 58 | i = max; 59 | } 60 | return i; 61 | }; 62 | 63 | self.padInteger = function(i, places) { 64 | let s = i + ''; 65 | while (s.length < places) { 66 | s = '0' + s; 67 | } 68 | return s; 69 | }; 70 | 71 | self.float = function(i, def, min, max) { 72 | if (def === undefined) { 73 | def = 0; 74 | } 75 | if (!(typeof (i) === 'number')) { 76 | try { 77 | i = parseFloat(i, 10); 78 | if (isNaN(i)) { 79 | i = def; 80 | } 81 | } catch (e) { 82 | i = def; 83 | } 84 | } 85 | if ((typeof (min) === 'number') && (i < min)) { 86 | i = min; 87 | } 88 | if ((typeof (max) === 'number') && (i > max)) { 89 | i = max; 90 | } 91 | return i; 92 | }; 93 | 94 | self.url = function(s, def, httpsFix) { 95 | s = self.string(s, def); 96 | // Allow the default to be undefined, null, false, etc. 97 | if (s === def) { 98 | return s; 99 | } 100 | s = fixUrl(s); 101 | if (s === null) { 102 | return def; 103 | } 104 | s = naughtyHref(s); 105 | if (s === true) { 106 | return def; 107 | } 108 | return s; 109 | 110 | function fixUrl(href) { 111 | if (href.match(/^(((https?|ftp):\/\/)|((mailto|tel|sms):)|#|([^/.]+)?\/|[^/.]+$)/)) { 112 | // All good - no change required 113 | return href; 114 | } else if (href.match(/^[^/.]+\.[^/.]+/)) { 115 | // Smells like a domain name. Educated guess: they left off http:// 116 | const protocol = httpsFix ? 'https://' : 'http://'; 117 | return protocol + href; 118 | } else { 119 | return null; 120 | } 121 | }; 122 | 123 | function naughtyHref(href) { 124 | // Browsers ignore character codes of 32 (space) and below in a surprising 125 | // number of situations. Start reading here: 126 | // https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet#Embedded_tab 127 | // eslint-disable-next-line no-control-regex 128 | href = href.replace(/[\x00-\x20]+/g, ''); 129 | // Clobber any comments in URLs, which the browser might 130 | // interpret inside an XML data island, allowing 131 | // a javascript: URL to be snuck through 132 | while (true) { 133 | const firstIndex = href.indexOf('', firstIndex + 4); 138 | if (lastIndex === -1) { 139 | break; 140 | } 141 | href = href.substring(0, firstIndex) + href.substring(lastIndex + 3); 142 | } 143 | // Case insensitive so we don't get faked out by JAVASCRIPT #1 144 | // Allow more characters after the first so we don't get faked 145 | // out by certain schemes browsers accept 146 | const matches = href.match(/^([a-zA-Z]+):/); 147 | if (!matches) { 148 | // No scheme = no way to inject js (right?) 149 | return href; 150 | } 151 | const scheme = matches[1].toLowerCase(); 152 | 153 | return (![ 'http', 'https', 'ftp', 'mailto', 'tel', 'sms' ].includes(scheme)) ? true : href; 154 | } 155 | }; 156 | 157 | self.select = function(s, choices, def) { 158 | s = self.string(s); 159 | if (!choices || !choices.length) { 160 | return def; 161 | } 162 | let choice; 163 | if (typeof (choices[0]) === 'object') { 164 | choice = choices.find(function(choice) { 165 | if ((choice.value === null) || (choice.value === undefined)) { 166 | // Don't crash on invalid choices 167 | return false; 168 | } 169 | return choice.value.toString() === s; 170 | }); 171 | if (choice != null) { 172 | return choice.value; 173 | } 174 | return def; 175 | } 176 | choice = choices.find(function(choice) { 177 | if ((choice === null) || (choice === undefined)) { 178 | // Don't crash on invalid choices 179 | return false; 180 | } 181 | return choice.toString() === s; 182 | }); 183 | if (choice !== undefined) { 184 | return choice; 185 | } 186 | return def; 187 | }; 188 | 189 | self.boolean = function(b, def) { 190 | if (b === true) { 191 | return true; 192 | } 193 | if (b === false) { 194 | return false; 195 | } 196 | b = self.string(b, def); 197 | if (b === def) { 198 | if (b === undefined) { 199 | return false; 200 | } 201 | return b; 202 | } 203 | b = b.toLowerCase().charAt(0); 204 | if ((b === '') || (b === 'n') || (b === '0') || (b === 'f')) { 205 | return false; 206 | } 207 | if ((b === 't') || (b === 'y') || (b === '1')) { 208 | return true; 209 | } 210 | return false; 211 | }; 212 | 213 | // Given an `options` object in which options[name] is a string 214 | // set to '0', '1', or 'any', this method adds mongodb criteria 215 | // to the `criteria` object. 216 | // 217 | // '0' or false means "the property must be false or absent," '1' or true 218 | // means "the property must be true," and 'any' or null means "we don't care 219 | // what the property is." 220 | // 221 | // See `booleanOrNull` for additional synonyms accepted for the 222 | // three possible values. 223 | // 224 | // An empty string is considered equivalent to '0'. 225 | // 226 | // This is not the same as apos.sanitizeBoolean which is concerned only with 227 | // true or false and does not address "any." 228 | // 229 | // `def` defaults to `any`. 230 | // 231 | // This method is most often used with REST API parameters and forms. 232 | 233 | self.addBooleanFilterToCriteria = function(options, name, criteria, def) { 234 | // if any or null, we aren't changing criteria 235 | if (def === undefined) { 236 | def = null; 237 | } 238 | 239 | // allow object or boolean 240 | let value = (typeof (options) === 'object' && options !== null) ? options[name] : options; 241 | value = (value === undefined) ? def : value; 242 | value = self.booleanOrNull(value); 243 | 244 | if (value === null) { 245 | // Don't care, show all 246 | } else if (!value) { 247 | // Must be absent or false. Hooray for $ne 248 | criteria[name] = { $ne: true }; 249 | } else { 250 | // Must be true 251 | criteria[name] = true; 252 | } 253 | }; 254 | 255 | // This method is used for tristate filters, i.e. "published," 256 | // "unpublished", and "show me both". 257 | // 258 | // Accepts `true`, `false`, or `null` and returns them exactly 259 | // as such; if the parameter is none of those or their synonyms 260 | // below, returns `def`. 261 | // 262 | // Also accepts the strings `'yes'` (or starting with y), `'no'` (or 263 | // starting with n), `'true'` (or starting with t), `'false'` 264 | // (or starting with f), `'1'`, `'0'` and the strings `'any'` and 265 | // `'null'`. The string `'null'` must be an exact match, anything 266 | // else starting with `n` is taken as `no` (false). 267 | // 268 | // These various synonyms are useful for string input, such as from 269 | // a user friendly query string. 270 | 271 | self.booleanOrNull = function(b, def) { 272 | if (b === true) { 273 | return b; 274 | } 275 | if (b === false) { 276 | return b; 277 | } 278 | if (b === null) { 279 | return b; 280 | } 281 | 282 | b = self.string(b, def); 283 | if (b === def) { 284 | if (def === undefined) { 285 | return null; 286 | } 287 | return b; 288 | } 289 | 290 | // String 'null' must match as a full string to disambiguate from string 'n' for 'no' 291 | if (b === 'null') { 292 | return null; 293 | } 294 | 295 | b = b.toLowerCase().charAt(0); 296 | 297 | if ((b === '') || (b === 'n') || (b === '0') || (b === 'f')) { 298 | return false; 299 | } 300 | 301 | if ((b === 't') || (b === 'y') || (b === '1')) { 302 | return true; 303 | } 304 | 305 | if ((b === 'a')) { 306 | return null; 307 | } 308 | 309 | return def; 310 | }; 311 | 312 | // Accept a user-entered string in YYYY-MM-DD, MM/DD, MM/DD/YY, or MM/DD/YYYY format 313 | // (tolerates missing leading zeroes on MM and DD). Also accepts a Date object. 314 | // Returns YYYY-MM-DD. 315 | // 316 | // The current year is assumed when MM/DD is used. If there is no explicit default 317 | // any unparseable date is returned as today's date. 318 | // 319 | // If the default is explicitly `null` (not `undefined`) then `null` is returned for 320 | // any unparseable date. 321 | // 322 | // The `now` argument can be passed for performance if you prefer to call 323 | // `new Date()` just once before many calls to this method and pass that single 324 | // value to all of them. 325 | 326 | self.date = function(date, def, now) { 327 | let components; 328 | 329 | function returnDefault() { 330 | if (def === undefined) { 331 | def = dayjs().format('YYYY-MM-DD'); 332 | } 333 | return def; 334 | } 335 | 336 | if (typeof (date) === 'string') { 337 | if (date.match(/\//)) { 338 | components = date.split('/'); 339 | if (components.length === 2) { 340 | // Convert mm/dd to yyyy-mm-dd 341 | return (now || new Date()).getFullYear() + '-' + self.padInteger(components[0], 2) + '-' + self.padInteger(components[1], 2); 342 | } else if (components.length === 3) { 343 | // Convert mm/dd/yy to mm/dd/yyyy 344 | if (components[2] < 100) { 345 | // Add the current century. If the result is more than 346 | // 50 years in the future, assume they meant the 347 | // previous century. Thus in 2015, we find that 348 | // we get the intuitive result for both 1/1/75, 349 | // 1/1/99 and 1/1/25. It's a nasty habit among 350 | // us imprecise humans. -Tom 351 | const d = (now || new Date()); 352 | const nowYear = d.getFullYear() % 100; 353 | const nowCentury = d.getFullYear() - nowYear; 354 | let theirYear = parseInt(components[2]) + nowCentury; 355 | if (theirYear - d.getFullYear() > 50) { 356 | theirYear -= 100; 357 | } 358 | components[2] = theirYear; 359 | } 360 | // Convert mm/dd/yyyy to yyyy-mm-dd 361 | return self.padInteger(components[2], 4) + '-' + self.padInteger(components[0], 2) + '-' + self.padInteger(components[1], 2); 362 | } else { 363 | return returnDefault(); 364 | } 365 | } else if (date.match(/-/)) { 366 | components = date.split('-'); 367 | if (components.length === 2) { 368 | // Convert mm-dd to yyyy-mm-dd 369 | return (now || new Date()).getFullYear() + '-' + self.padInteger(components[0], 2) + '-' + self.padInteger(components[1], 2); 370 | } else if (components.length === 3) { 371 | // Convert yyyy-mm-dd (with questionable padding) to yyyy-mm-dd 372 | return self.padInteger(components[0], 4) + '-' + self.padInteger(components[1], 2) + '-' + self.padInteger(components[2], 2); 373 | } else { 374 | return returnDefault(); 375 | } 376 | } 377 | } 378 | try { 379 | if (date === null) { 380 | return returnDefault(); 381 | } 382 | date = (now || new Date(date)); 383 | if (isNaN(date.getTime())) { 384 | return returnDefault(); 385 | } 386 | return date.getFullYear() + '-' + self.padInteger(date.getMonth() + 1, 2) + '-' + self.padInteger(date.getDate(), 2); 387 | } catch (e) { 388 | return returnDefault(); 389 | } 390 | }; 391 | 392 | // This is likely not relevent to you unless you're using Apostrophe 393 | // Given a date object, return a date string in Apostrophe's preferred sortable, 394 | // comparable, JSON-able format, which is YYYY-MM-DD. If `date` is undefined 395 | // the current date is used. 396 | self.formatDate = function(date) { 397 | return dayjs(date).format('YYYY-MM-DD'); 398 | }; 399 | 400 | // Accepts a user-entered string in 12-hour or 24-hour time and returns a string 401 | // in 24-hour time. This method is tolerant of syntax such as `4pm`; minutes and 402 | // seconds are optional. 403 | // 404 | // If `def` is not set the default is the current time. 405 | 406 | self.time = function(time, def) { 407 | time = self.string(time).toLowerCase(); 408 | time = time.trim(); 409 | const components = time.match(/^(\d+)([:|.](\d+))?([:|.](\d+))?\s*(am|pm|AM|PM|a|p|A|M)?$/); 410 | if (components) { 411 | let hours = parseInt(components[1], 10); 412 | const minutes = (components[3] !== undefined) ? parseInt(components[3], 10) : 0; 413 | const seconds = (components[5] !== undefined) ? parseInt(components[5], 10) : 0; 414 | let ampm = (components[6]) ? components[6].toLowerCase() : components[6]; 415 | ampm = ampm && ampm.charAt(0); 416 | if ((hours === 12) && (ampm === 'a')) { 417 | hours -= 12; 418 | } else if ((hours === 12) && (ampm === 'p')) { 419 | // Leave it be 420 | } else if (ampm === 'p') { 421 | hours += 12; 422 | } 423 | if ((hours === 24) || (hours === '24')) { 424 | hours = 0; 425 | } 426 | return self.padInteger(hours, 2) + ':' + self.padInteger(minutes, 2) + ':' + self.padInteger(seconds, 2); 427 | } else { 428 | if (def !== undefined) { 429 | return def; 430 | } 431 | return dayjs().format('HH:mm'); 432 | } 433 | }; 434 | 435 | // This is likely not relevent to you unless you're using Apostrophe 436 | // Given a JavaScript Date object, return a time string in 437 | // Apostrophe's preferred sortable, comparable, JSON-able format: 438 | // 24-hour time, with seconds. 439 | // 440 | // If `date` is missing the current time is used. 441 | 442 | self.formatTime = function(date) { 443 | return dayjs(date).format('HH:mm:ss'); 444 | }; 445 | 446 | // Sanitize tags. Tags should be submitted as an array of strings, 447 | // or a comma-separated string. 448 | // 449 | // This method ensures the input is an array or string and, if 450 | // an array, that the elements of the array are strings. 451 | // 452 | // If a filterTag function is passed as an option when initializing 453 | // Launder, then all tags are passed through it (as individual 454 | // strings, one per call) and the return value is used instead. You 455 | // may also pass a filterTag when calling this function 456 | 457 | self.tags = function(tags, filter) { 458 | if (typeof (tags) === 'string') { 459 | tags = tags.split(/,\s*/); 460 | } 461 | if (!Array.isArray(tags)) { 462 | return []; 463 | } 464 | const strings = tags.map(tag => self.string(tag)); 465 | const rewritten = strings.map(filter || self.filterTag); 466 | const filtered = rewritten.filter(tag => tag.length > 0); 467 | return filtered; 468 | }; 469 | 470 | // Sanitize an id. IDs must consist solely of upper and lower case 471 | // letters, numbers, and digits unless options.idRegExp is set. 472 | 473 | self.idRegExp = self.options.idRegExp || /^[A-Za-z0-9_]+$/; 474 | 475 | self.id = function(s, def) { 476 | const id = self.string(s, def); 477 | if (id === def) { 478 | return id; 479 | } 480 | if (!id.match(self.idRegExp)) { 481 | return def; 482 | } 483 | return id; 484 | }; 485 | 486 | // Sanitize an array of IDs. IDs must consist solely of upper and lower case 487 | // letters and numbers, digits, and underscores. Any elements that are not 488 | // IDs are omitted from the final array. 489 | self.ids = function(ids) { 490 | if (!Array.isArray(ids)) { 491 | return []; 492 | } 493 | const result = ids.filter(function(id) { 494 | return (self.id(id) !== undefined); 495 | }); 496 | return result; 497 | }; 498 | return self; 499 | }; 500 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const dayjs = require('dayjs'); 3 | 4 | describe('launder', function() { 5 | 6 | const launder = require('../index.js')(); 7 | 8 | it('should exist', function() { 9 | assert(launder); 10 | }); 11 | 12 | describe('instantiation', function() { 13 | 14 | it('should have default filterTag function', function() { 15 | assert(typeof (launder.filterTag) === 'function'); 16 | assert(launder.filterTag(' HEllo ') === 'hello'); 17 | }); 18 | 19 | it('should take a new filterTag function', function() { 20 | const launderWithFilterTag = require('../index.js')({ 21 | filterTag: function(tag) { 22 | return 'punk'; 23 | } 24 | }); 25 | 26 | assert(typeof (launderWithFilterTag.filterTag) === 'function'); 27 | assert(launderWithFilterTag.filterTag(' HEllo ') === 'punk'); 28 | }); 29 | 30 | }); 31 | 32 | describe('methods', function() { 33 | it('should have a `string` method', function() { 34 | assert(launder.string); 35 | }); 36 | 37 | it('should have a `strings` method', function() { 38 | assert(launder.strings); 39 | }); 40 | 41 | it('should have a `integer` method', function() { 42 | assert(launder.integer); 43 | }); 44 | 45 | it('should have a `padInteger` method', function() { 46 | assert(launder.padInteger); 47 | }); 48 | 49 | it('should have a `float` method', function() { 50 | assert(launder.float); 51 | }); 52 | 53 | it('should have a `url` method', function() { 54 | assert(launder.url); 55 | }); 56 | 57 | it('should have a `select` method', function() { 58 | assert(launder.select); 59 | }); 60 | 61 | it('should have a `boolean` method', function() { 62 | assert(launder.boolean); 63 | }); 64 | 65 | it('should have a `addBooleanFilterToCriteria` method', function() { 66 | assert(launder.addBooleanFilterToCriteria); 67 | }); 68 | 69 | it('should have a `date` method', function() { 70 | assert(launder.date); 71 | }); 72 | 73 | it('should have a `formatDate` method', function() { 74 | assert(launder.formatDate); 75 | }); 76 | 77 | it('should have a `time` method', function() { 78 | // just a flat circle 79 | assert(launder.time); 80 | }); 81 | 82 | it('should have a `formatTime` method', function() { 83 | assert(launder.formatTime); 84 | }); 85 | 86 | it('should have a `tags` method', function() { 87 | assert(launder.tags); 88 | }); 89 | 90 | it('should have a `id` method', function() { 91 | assert(launder.id); 92 | }); 93 | 94 | it('should have an `ids` method', function() { 95 | assert(launder.ids); 96 | }); 97 | }); 98 | 99 | describe('string', function() { 100 | it('should do nothing to a good string', function() { 101 | assert(launder.string('this is great') === 'this is great'); 102 | }); 103 | 104 | it('should trim a string', function() { 105 | assert(launder.string(' remove whitespace ') === 'remove whitespace'); 106 | }); 107 | 108 | it('should convert a number to a string', function() { 109 | assert(launder.string(1234) === '1234'); 110 | }); 111 | 112 | it('should convert a boolean to a string', function() { 113 | assert(launder.string(true) === 'true'); 114 | assert(launder.string(false) === 'false'); 115 | }); 116 | 117 | it('should convert non-string/non-number to an empty string', function() { 118 | assert(launder.string({ an: 'object' }) === ''); 119 | assert(launder.string(function() { 120 | return 'still not a string'; 121 | }) === ''); 122 | }); 123 | 124 | it('should use a default for non-strings', function() { 125 | assert(launder.string({ an: 'object' }, 'default') === 'default'); 126 | }); 127 | }); 128 | 129 | describe('strings', function() { 130 | it('should do good stuff to an array of strings', function() { 131 | const s = launder.strings([ ' testing ', 123 ]); 132 | assert(s[0] === 'testing'); 133 | assert(s[1] === '123'); 134 | }); 135 | 136 | it('should return an empty array if we pass in something that is not an array', function() { 137 | const s = launder.strings({ 138 | an: 'object', 139 | is: 'not', 140 | typeof: 'array' 141 | }); 142 | assert(Array.isArray(s)); 143 | assert(s.length === 0); 144 | }); 145 | }); 146 | 147 | describe('integer', function() { 148 | it('should do nothing to a good integer', function() { 149 | assert(launder.integer(123) === 123); 150 | }); 151 | it('should convert a float to a rounded down integer', function() { 152 | assert(launder.integer(42.42) === 42); 153 | }); 154 | it('should convet a string of an integer to an integer', function() { 155 | assert(launder.integer('123') === 123); 156 | }); 157 | it('should convert a string of a float to a rounded down integer', function() { 158 | assert(launder.integer('42.42') === 42); 159 | }); 160 | it('should convert a non-number to 0 by default', function() { 161 | assert(launder.integer('nah') === 0); 162 | }); 163 | it('should convert a non-number to the passed in default', function() { 164 | assert(launder.integer('nah', 5) === 5); 165 | }); 166 | it('should set a value below min to min', function() { 167 | assert(launder.integer(5, null, 10) === 10); 168 | }); 169 | it('should set a value above max to max', function() { 170 | assert(launder.integer(25, null, null, 20) === 20); 171 | }); 172 | it('should set a non-number with no default to min', function() { 173 | assert(launder.integer('nah', null, 10, 20) === 10); 174 | }); 175 | }); 176 | 177 | describe('padInteger', function() { 178 | it('should add 0s to to an integer shorter than the pad', function() { 179 | assert(launder.padInteger(1234, 10) === '0000001234'); 180 | }); 181 | it('should not add 0s to an integer longer than the pad', function() { 182 | assert(launder.padInteger(123456789, 5) === '123456789'); 183 | }); 184 | }); 185 | 186 | describe('float', function() { 187 | it('should do nothing to a good float', function() { 188 | assert(launder.float(42.42) === 42.42); 189 | }); 190 | it('should convert a string of a float to a float', function() { 191 | assert(launder.float('42.42') === 42.42); 192 | }); 193 | it('should convert a non-number to 0 by default', function() { 194 | assert(launder.float('nah') === 0); 195 | }); 196 | it('should convert a non-number to the passed in default', function() { 197 | assert(launder.float('nah', 5.5) === 5.5); 198 | }); 199 | it('should set a value below min to min', function() { 200 | assert(launder.float(5, null, 10.6) === 10.6); 201 | }); 202 | it('should set a value above max to max', function() { 203 | assert(launder.float(25, null, null, 20.2) === 20.2); 204 | }); 205 | it('should set a non-number with no default to min', function() { 206 | assert(launder.float('nah', null, 10.6, 20.2) === 10.6); 207 | }); 208 | }); 209 | 210 | describe('url', function() { 211 | it('should do nothing to a good url', function() { 212 | assert(launder.url('http://www.apostrophenow.org') === 'http://www.apostrophenow.org'); 213 | }); 214 | it('should add http:// when missing', function() { 215 | assert(launder.url('www.apostrophenow.org') === 'http://www.apostrophenow.org'); 216 | }); 217 | it('should remove spaces from a url', function() { 218 | assert(launder.url('this is not a url') === 'thisisnotaurl'); 219 | }); 220 | it('should return the default if it is an empty string', function() { 221 | assert(launder.url('', 'http://www.apostrophenow.org') === 'http://www.apostrophenow.org'); 222 | }); 223 | it('should return the default if it is null', function() { 224 | assert(launder.url(null, 'http://www.apostrophenow.org') === 'http://www.apostrophenow.org'); 225 | }); 226 | it('should return the default if it is undefined', function() { 227 | assert(launder.url(undefined, 'http://www.apostrophenow.org') === 'http://www.apostrophenow.org'); 228 | }); 229 | it('should return the default if it is malicious', function() { 230 | assert(launder.url('javascript:alert(\'All your base are belong to us\');', 'http://www.apostrophenow.org') === 'http://www.apostrophenow.org'); 231 | }); 232 | it('should return an https url', function() { 233 | assert(launder.url('https://www.apostrophenow.org') === 'https://www.apostrophenow.org'); 234 | }); 235 | it('should return an sms url', function() { 236 | assert(launder.url('sms:123456') === 'sms:123456'); 237 | }); 238 | it('should return an tel url', function() { 239 | assert(launder.url('tel:123456') === 'tel:123456'); 240 | }); 241 | it('should return an ftp url', function() { 242 | assert(launder.url('ftp://123456') === 'ftp://123456'); 243 | }); 244 | it('should return an mailto url', function() { 245 | assert(launder.url('mailto:123456') === 'mailto:123456'); 246 | }); 247 | it('should not match a random url', function() { 248 | assert(launder.url('randomtag://123456', 'http://www.apostrophenow.org') === 'http://www.apostrophenow.org'); 249 | }); 250 | it('should add https:// when missing if httpsFix is true', function() { 251 | assert(launder.url('www.apostrophenow.org', null, true) === 'https://www.apostrophenow.org'); 252 | }); 253 | }); 254 | 255 | describe('select', function() { 256 | it('should do nothing to a good choice from an array', function() { 257 | assert(launder.select('n', [ 'p', 'u', 'n', 'k' ]) === 'n'); 258 | }); 259 | it('should do nothing to a good choice from an object', function() { 260 | const s = launder.select('n', [ 261 | { 262 | name: 'Probably amazing', 263 | value: 'p' 264 | }, 265 | { 266 | name: 'Utterly incredible', 267 | value: 'u' 268 | }, 269 | { 270 | name: 'Never gonna give you up', 271 | value: 'n' 272 | }, 273 | { 274 | name: 'Kind hearted', 275 | value: 'k' 276 | } 277 | ]); 278 | assert(s === 'n'); 279 | }); 280 | it('should return the default if choices is null', function() { 281 | assert(launder.select('hi', null, 'bye') === 'bye'); 282 | }); 283 | it('should return the default if choices is empty', function() { 284 | assert(launder.select('hi', [], 'bye') === 'bye'); 285 | }); 286 | it('should return the default if the choice is not found in an array', function() { 287 | assert(launder.select('hi', [ 'not', 'in', 'here' ], 'bye') === 'bye'); 288 | }); 289 | it('should not crash if a null choice is present', function() { 290 | assert(launder.select('yes', [ 'not', null, 'yes' ]) === 'yes'); 291 | }); 292 | it('should not crash if an undefined choice is present', function() { 293 | assert(launder.select('yes', [ 'not', undefined, 'yes' ]) === 'yes'); 294 | }); 295 | it('should not crash if a null choice is present, with labels', function() { 296 | assert(launder.select('yes', [ { 297 | value: 'not', 298 | label: 'Not' 299 | }, { 300 | value: null, 301 | label: 'broken' 302 | }, { 303 | value: 'yes', 304 | label: 'Yes' 305 | } ]) === 'yes'); 306 | }); 307 | it('should not crash if an undefined choice is present, with labels', function() { 308 | assert(launder.select('yes', [ { 309 | value: 'not', 310 | label: 'Not' 311 | }, { 312 | value: undefined, 313 | label: 'broken' 314 | }, { 315 | value: 'yes', 316 | label: 'Yes' 317 | } ]) === 'yes'); 318 | }); 319 | it('should return the default if the choice is not found in an object', function() { 320 | const s = launder.select('hi', [ 321 | { 322 | name: 'Not something', 323 | value: 'not' 324 | }, 325 | { 326 | name: 'Inside', 327 | value: 'in' 328 | }, 329 | { 330 | name: 'Here anymore', 331 | value: 'here' 332 | } 333 | ], 'bye'); 334 | assert(s === 'bye'); 335 | }); 336 | it('should return the default if the choice is not found in an array', function() { 337 | assert(launder.select('hi', [ 'not', 'in', 'here' ], 'bye') === 'bye'); 338 | }); 339 | it('should match a string input matching the string representation of a choice that is a number, and return the number, not the string', function() { 340 | assert(launder.select('5', [ 1, 3, 5 ], 1) === 5); 341 | }); 342 | it('should match a number matching a choice that is a number, and return the number, not a stringification of it', function() { 343 | assert(launder.select(5, [ 1, 3, 5 ], 1) === 5); 344 | }); 345 | }); 346 | 347 | describe('boolean', function() { 348 | it('should do nothing to a proper boolean', function() { 349 | assert(launder.boolean(true) === true); 350 | assert(launder.boolean(false) === false); 351 | }); 352 | it('should convert a string to a proper boolean', function() { 353 | assert(launder.boolean('true') === true); 354 | assert(launder.boolean('false') === false); 355 | }); 356 | it('should return true for string of `t`', function() { 357 | assert(launder.boolean('t') === true); 358 | }); 359 | it('should return true for string of `y`', function() { 360 | assert(launder.boolean('y') === true); 361 | }); 362 | it('should return true for string of `1`', function() { 363 | assert(launder.boolean('1') === true); 364 | }); 365 | it('should return true for integer of 1', function() { 366 | assert(launder.boolean(1) === true); 367 | }); 368 | it('should return false for an empty string', function() { 369 | assert(launder.boolean('') === false); 370 | }); 371 | it('should return the default if nothing is passed', function() { 372 | assert(launder.boolean(null, 'yup') === 'yup'); 373 | }); 374 | it('should return false if nothing is passed and no default', function() { 375 | assert(launder.boolean(null) === false); 376 | }); 377 | }); 378 | 379 | describe('booleanOrNull', function() { 380 | it('should do nothing to a proper boolean', function() { 381 | assert(launder.booleanOrNull(true) === true); 382 | assert(launder.booleanOrNull(false) === false); 383 | }); 384 | it('should convert a string to a proper boolean', function() { 385 | assert(launder.booleanOrNull('true') === true); 386 | assert(launder.booleanOrNull('false') === false); 387 | }); 388 | it('should return true for string of `t`', function() { 389 | assert(launder.booleanOrNull('t') === true); 390 | }); 391 | it('should return true for string of `y`', function() { 392 | assert(launder.booleanOrNull('y') === true); 393 | }); 394 | it('should return true for string of `1`', function() { 395 | assert(launder.booleanOrNull('1') === true); 396 | }); 397 | it('should return true for integer of 1', function() { 398 | assert(launder.booleanOrNull(1) === true); 399 | }); 400 | it('should return false for an empty string', function() { 401 | assert(launder.booleanOrNull('') === false); 402 | }); 403 | it('should return the default if the empty string is passed', function() { 404 | assert(launder.booleanOrNull('', 'yup') === 'yup'); 405 | }); 406 | it('should return the default if undefined is passed', function() { 407 | assert(launder.booleanOrNull(undefined, 'yup') === 'yup'); 408 | }); 409 | it('should return null if null is passed even if there is a default', function() { 410 | assert(launder.booleanOrNull(null, 'yup') === null); 411 | }); 412 | it('should return null if null is passed and no default', function() { 413 | assert(launder.booleanOrNull(null) === null); 414 | }); 415 | it('should return null if "any" is passed and there is a default', function() { 416 | assert(launder.booleanOrNull('any', 'yup') === null); 417 | }); 418 | it('should return null if "null" is passed and there is a default', function() { 419 | assert(launder.booleanOrNull('null', 'yup') === null); 420 | }); 421 | }); 422 | 423 | describe('addBooleanFilterToCriteria', function() { 424 | const name = 'published'; 425 | const criteria = {}; 426 | const optionsTrue = { published: true }; 427 | const optionsFalse = { published: false }; 428 | const optionsEmpty = { published: '' }; 429 | 430 | it('should not change criteria if option is `any`', function() { 431 | launder.addBooleanFilterToCriteria('any', name, criteria); 432 | assert(criteria[name] === undefined); 433 | }); 434 | 435 | it('should use a default of `any` if name is not found in options', function() { 436 | launder.addBooleanFilterToCriteria({}, name, criteria); 437 | assert(criteria[name] === undefined); 438 | }); 439 | it('should use a default of `any` if options is null', function() { 440 | launder.addBooleanFilterToCriteria(null, name, criteria); 441 | assert(criteria[name] === undefined); 442 | }); 443 | it('should use `name` property of `options` if `options` is an object', function() { 444 | launder.addBooleanFilterToCriteria(optionsTrue, name, criteria); 445 | assert(criteria[name] === true); 446 | const criteria2 = {}; 447 | launder.addBooleanFilterToCriteria(optionsFalse, name, criteria2); 448 | assert(criteria2[name].$ne === true); 449 | }); 450 | it('should be able to use a boolean string `true` for options', function() { 451 | const criteria = {}; 452 | launder.addBooleanFilterToCriteria('true', name, criteria); 453 | assert(criteria[name] === true); 454 | }); 455 | it('should be able to use a boolean string `y` for options', function() { 456 | const criteria = {}; 457 | launder.addBooleanFilterToCriteria('y', name, criteria); 458 | assert(criteria[name] === true); 459 | }); 460 | it('should be able to use a real boolean for options', function() { 461 | let criteria = {}; 462 | launder.addBooleanFilterToCriteria(true, name, criteria); 463 | assert(criteria[name] === true); 464 | criteria = {}; 465 | launder.addBooleanFilterToCriteria(false, name, criteria); 466 | assert(criteria[name].$ne === true); 467 | }); 468 | it('should treat empty string as false', function() { 469 | const criteria = {}; 470 | launder.addBooleanFilterToCriteria('', name, criteria); 471 | assert(criteria[name].$ne === true); 472 | }); 473 | it('should treat empty string in an object as false', function() { 474 | const criteria = {}; 475 | launder.addBooleanFilterToCriteria(optionsEmpty, name, criteria); 476 | assert(criteria[name].$ne === true); 477 | }); 478 | it('should take a default if `options` or `options[name]` is undefined', function() { 479 | const criteria = {}; 480 | launder.addBooleanFilterToCriteria({}, name, criteria, false); 481 | assert(criteria[name].$ne === true); 482 | }); 483 | }); 484 | 485 | describe('date', function() { 486 | it('should do nothing to a good date', function() { 487 | assert(launder.date('2015-02-19') === '2015-02-19'); 488 | }); 489 | it('should convert dates in MM/DD/YYYY format', function() { 490 | assert(launder.date('2/19/2015') === '2015-02-19'); 491 | }); 492 | it('should convert dates in MM/DD/YY format to the past century when that is closest', function() { 493 | assert(launder.date('2/19/99', new Date(2015, 1, 1)) === '1999-02-19'); 494 | }); 495 | it('should convert dates in MM/DD/YY format to the current century when that is closest', function() { 496 | assert(launder.date('2/19/15') === '2015-02-19'); 497 | }); 498 | it('should use the current year if in MM/DD format', function() { 499 | const year = dayjs().format('YYYY'); 500 | assert(launder.date('2/19') === year + '-02-19'); 501 | }); 502 | it('should accept a date object', function() { 503 | assert(launder.date(new Date(2015, 1, 19)) === '2015-02-19'); 504 | }); 505 | it('should return current date if the date is not parsable', function() { 506 | assert(launder.date('waffles') === dayjs().format('YYYY-MM-DD')); 507 | }); 508 | it('should return default if the date is not parsable', function() { 509 | assert(launder.date('waffles', '1989-12-13') === '1989-12-13'); 510 | }); 511 | it('should return null if the input and default are both explicitly null', function() { 512 | assert.strictEqual(launder.date(null, null), null); 513 | }); 514 | it('should return null if the date is undefined and the default is null', function() { 515 | assert(launder.date(undefined, null) === null); 516 | }); 517 | it('should return today\'s date if the date is undefined and there is no default', function() { 518 | const today = new Date().toISOString().slice(0, 10); 519 | assert.strictEqual(launder.date(null), today); 520 | }); 521 | }); 522 | 523 | describe('formatDate', function() { 524 | it('should accept a date object', function() { 525 | assert(launder.formatDate(new Date(2015, 1, 19, 11, 22, 33)) === '2015-02-19'); 526 | }); 527 | it('should default to current date', function() { 528 | assert(launder.formatDate() === dayjs().format('YYYY-MM-DD')); 529 | }); 530 | }); 531 | 532 | describe('time', function() { 533 | it('should show me a good time', function() { 534 | assert(launder.time('12:34:56') === '12:34:56'); 535 | }); 536 | it('should show me a good, quick time', function() { 537 | assert(launder.time('12:34') === '12:34:00'); 538 | }); 539 | it('should show me a really quick good time', function() { 540 | assert(launder.time('12') === '12:00:00'); 541 | }); 542 | it('should convert 12h to 24h', function() { 543 | assert(launder.time('4:30pm') === '16:30:00'); 544 | }); 545 | it('should not require the m in pm', function() { 546 | assert(launder.time('4:30p') === '16:30:00'); 547 | }); 548 | it('should handle lower or uppercase meridiems', function() { 549 | assert(launder.time('4:30pm') === '16:30:00'); 550 | assert(launder.time('4:30PM') === '16:30:00'); 551 | }); 552 | it('should accept dot as time separator', function() { 553 | assert(launder.time('4.30pm') === '16:30:00'); 554 | assert(launder.time('4.30PM') === '16:30:00'); 555 | }); 556 | it('should accept dot and colon mixed as time separator', function() { 557 | assert(launder.time('3.52:05pm') === '15:52:05'); 558 | assert(launder.time('4:32.23a') === '04:32:23'); 559 | }); 560 | it('should accept not accept any time separator', function() { 561 | assert(launder.time('3q52b05pm') !== '15:52:05'); 562 | assert(launder.time('4 32 23a') !== '04:32:23'); 563 | assert(launder.time('12Qpm') !== '12:00:00'); 564 | }); 565 | it('should handle no minutes', function() { 566 | assert(launder.time('4 PM') === '16:00:00'); 567 | }); 568 | it('should default to the current time', function() { 569 | assert(launder.time() === dayjs().format('HH:mm')); 570 | }); 571 | it('should accept a default', function() { 572 | assert(launder.time(null, '12:00:00') === '12:00:00'); 573 | }); 574 | }); 575 | 576 | describe('formatTime', function() { 577 | it('should accept a date object', function() { 578 | assert(launder.formatTime(new Date(2015, 1, 19, 11, 22, 33)) === '11:22:33'); 579 | }); 580 | it('should default to current time', function() { 581 | assert(launder.formatTime() === dayjs().format('HH:mm:ss')); 582 | }); 583 | }); 584 | 585 | describe('tags', function() { 586 | const goodTags = [ 'one', 'two', 'three' ]; 587 | const spaceyTags = [ ' One', 'TWO', ' three ' ]; 588 | const numberTags = [ 12, 34 ]; 589 | const troubleTags = [ 'one', 2, { an: 'object' }, null, 'three' ]; 590 | 591 | it('should do nothing to a good array of tags', function() { 592 | const t = launder.tags(goodTags); 593 | assert(t.length === 3); 594 | assert(t[0] === 'one'); 595 | assert(t[1] === 'two'); 596 | assert(t[2] === 'three'); 597 | }); 598 | it('should apply default filterTag function', function() { 599 | const t = launder.tags(spaceyTags); 600 | assert(t.length === 3); 601 | assert(t[0] === 'one'); 602 | assert(t[1] === 'two'); 603 | assert(t[2] === 'three'); 604 | }); 605 | it('should return an empty array if you pass in something that is not an array', function() { 606 | const t = launder.tags({ 607 | an: 'object', 608 | is: 'not', 609 | typeof: 'array' 610 | }); 611 | assert(Array.isArray(t)); 612 | assert(t.length === 0); 613 | }); 614 | it('should convert numbers to strings in tags', function() { 615 | const t = launder.tags(numberTags); 616 | assert(t.length === 2); 617 | assert(t[0] === '12'); 618 | assert(t[1] === '34'); 619 | }); 620 | it('should remove things that are not strings or numbers', function() { 621 | const t = launder.tags(troubleTags); 622 | assert(t.length === 3); 623 | assert(t[0] === 'one'); 624 | assert(t[1] === '2'); 625 | assert(t[2] === 'three'); 626 | }); 627 | it('should take a filter function', function() { 628 | const t = launder.tags(numberTags, function(tag) { 629 | return tag + '0'; 630 | }); 631 | assert(t.length === 2); 632 | assert(t[0] === '120'); 633 | assert(t[1] === '340'); 634 | }); 635 | it('should allow a different filter function to be set during initiation', function() { 636 | const launder = require('../index.js')({ 637 | filterTag: function(tag) { 638 | return tag + '0'; 639 | } 640 | }); 641 | const t = launder.tags(numberTags); 642 | assert(t.length === 2); 643 | assert(t[0] === '120'); 644 | assert(t[1] === '340'); 645 | }); 646 | it('should remove empty tags', function() { 647 | const t = launder.tags([ '1', '2', '' ]); 648 | assert(t.length === 2); 649 | assert(t[0] === '1'); 650 | assert(t[1] === '2'); 651 | }); 652 | }); 653 | 654 | describe('id', function() { 655 | it('should do nothing to a good id', function() { 656 | assert(launder.id('aBcD_1234') === 'aBcD_1234'); 657 | }); 658 | it('should return undefined if not valid', function() { 659 | assert(launder.id('@#%!#%') === undefined); 660 | assert(launder.id('abc 123') === undefined); 661 | }); 662 | it('should return default if not valid', function() { 663 | assert(launder.id('@#%!#%', '1234') === '1234'); 664 | }); 665 | }); 666 | 667 | describe('ids', function() { 668 | const goodIds = [ '1001', '1002', '1003' ]; 669 | const troubleIds = [ '1001', '1002', { an: 'object' }, null, '1003', '1004-en' ]; 670 | it('should do nothing with a good array of ids', function() { 671 | const i = launder.ids(goodIds); 672 | assert(i.length === 3); 673 | assert(i[0] === '1001'); 674 | assert(i[1] === '1002'); 675 | assert(i[2] === '1003'); 676 | }); 677 | it('should return an empty array if you pass in something that is not an array', function() { 678 | const i = launder.ids({ 679 | an: 'object', 680 | is: 'not', 681 | typeof: 'array' 682 | }); 683 | assert(Array.isArray(i)); 684 | assert(i.length === 0); 685 | }); 686 | it('should remove items that are not valid ids', function() { 687 | const i = launder.ids(troubleIds); 688 | assert(i.length === 3); 689 | assert(i[0] === '1001'); 690 | assert(i[1] === '1002'); 691 | assert(i[2] === '1003'); 692 | }); 693 | it('should honor the idRegExp option', function() { 694 | const launder = require('../index.js')({ idRegExp: /^[A-Za-z0-9_-]+$/ }); 695 | const testIds = [ '1001', '1002', { an: 'object' }, null, '1003', '1004-en' ]; 696 | const i = launder.ids(testIds); 697 | assert.strictEqual(i.length, 4); 698 | assert(i[0] === '1001'); 699 | assert(i[1] === '1002'); 700 | assert(i[2] === '1003'); 701 | assert(i[3] === '1004-en'); 702 | }); 703 | }); 704 | 705 | }); 706 | --------------------------------------------------------------------------------