diff --git a/index.js b/index.js index d329777..34b82ea 100644 --- a/index.js +++ b/index.js @@ -8,4 +8,9 @@ * Copyright (c) 2014, Joyent, Inc. */ -module.exports = require('./lib/client.js'); +var client = require('./lib/client.js'); +var scopeSchema = require('./lib/scope-schema.js'); + +client.scopeSchema = scopeSchema; + +module.exports = client; diff --git a/lib/client.js b/lib/client.js index 9cfd049..cf3cfc1 100644 --- a/lib/client.js +++ b/lib/client.js @@ -17,6 +17,7 @@ var LRU = require('lru-cache'); var httpSignature = require('http-signature'); var qs = require('querystring'); var restify = require('restify'); +var scopeSchema = require('./scope-schema.js'); var sprintf = require('util').format; @@ -797,7 +798,7 @@ MahiClient.prototype.getLookup = function getLookup(opts, cb) { * * accessKeyId: AWS access key ID (e.g., "AKIA123456789EXAMPLE") * cb: callback in the form fn(err, userInfo) - * + * * Returns user object without access key secrets: * { * type: "account", @@ -811,14 +812,14 @@ MahiClient.prototype.getLookup = function getLookup(opts, cb) { * AccessKeyNotFoundError * RedisError */ -MahiClient.prototype.getUserByAccessKey = function +MahiClient.prototype.getUserByAccessKey = function getUserByAccessKey(accessKeyId, cb) { assert.string(accessKeyId, 'accessKeyId'); assert.func(cb, 'callback'); - + var self = this; var path = sprintf('/aws-auth/%s', accessKeyId); - + self.http.get(path, function (err, req, res, obj) { if (err) { cb(err); @@ -831,20 +832,20 @@ MahiClient.prototype.getUserByAccessKey = function /** * Verify AWS Signature Version 4 authentication - * + * * request: HTTP request object with AWS4-HMAC-SHA256 authorization header * cb: callback in the form fn(err, result) * * Returns: * { * valid: true, - * accessKeyId: "AKIA123456789EXAMPLE", + * accessKeyId: "AKIA123456789EXAMPLE", * userUuid: "user-uuid" * } * * errors: * InvalidSignatureError - * AccessKeyNotFoundError + * AccessKeyNotFoundError * RequestTimeTooSkewedError */ MahiClient.prototype.verifySigV4 = function verifySigV4(request, cb) { @@ -852,21 +853,20 @@ MahiClient.prototype.verifySigV4 = function verifySigV4(request, cb) { assert.func(cb, 'callback'); var self = this; - + // Forward the original headers to mahi for SigV4 verification var requestOptions = { path: '/aws-verify', headers: request.headers }; - - // Add request method and URL as query parameters since mahi needs them for verification - var qs = require('querystring'); + + /* Add method and URL as query parameters for mahi verification */ var queryParams = { method: request.method, url: request.url }; requestOptions.path += '?' + qs.stringify(queryParams); - + self.http.post(requestOptions, {}, function (err, req, res, obj) { if (err) { cb(err); @@ -877,6 +877,112 @@ MahiClient.prototype.verifySigV4 = function verifySigV4(request, cb) { }; +/** + * @brief Push an access key to mahi's Redis cache + * + * Writes the key directly to Redis, bypassing the UFDS replication delay. + * Best-effort: errors are logged but not propagated to the caller. + * + * @param {Object} opts + * opts.accesskeyid: {string} Key ID (required) + * opts.accesskeysecret: {string} Secret (required) + * opts.ownerUuid: {string} Owner UUID (required) + * opts.status: {string} 'Active' or 'Inactive' + * opts.scope: {string|null} Scope JSON + * @param {Function} cb - callback(err) + */ +MahiClient.prototype.cachePush = + function cachePush(opts, cb) { + assert.object(opts, 'opts'); + assert.string(opts.accesskeyid, 'opts.accesskeyid'); + assert.string(opts.accesskeysecret, + 'opts.accesskeysecret'); + assert.string(opts.ownerUuid, 'opts.ownerUuid'); + assert.optionalString(opts.status, 'opts.status'); + assert.optionalString(opts.scope, 'opts.scope'); + assert.func(cb, 'callback'); + + /* + * Validate scope at the client boundary to prevent malformed scope strings + * from poisoning the Redis cache. Null/undefined scope is valid + * (unrestricted key). + */ + if (opts.scope) { + var parsed = scopeSchema.parseScope(opts.scope); + if (parsed === null) { + cb(new Error('cachePush: opts.scope is not' + + ' valid scope JSON')); + return; + } + var result = scopeSchema.validateScope(parsed); + if (!result.valid) { + cb(new Error('cachePush: invalid scope: ' + result.error)); + return; + } + } + + var self = this; + var path = sprintf('/cache-push/%s', + opts.accesskeyid); + var body = { + accesskeysecret: opts.accesskeysecret, + ownerUuid: opts.ownerUuid, + status: opts.status || 'Active', + scope: opts.scope || null + }; + + self.http.post(path, body, + function (err, req, res, obj) { + if (err) { + if (self.http.log) { + self.http.log.warn({ + err: err, + accesskeyid: opts.accesskeyid + }, 'cachePush: mahi call failed' + + ' (non-fatal)'); + } + cb(err); + return; + } + cb(null, obj); + }); +}; + + +/** + * @brief Revoke an access key from mahi's Redis cache + * + * Deletes the key immediately from Redis, bypassing + * the UFDS replication delay. Best-effort: errors + * are logged but not propagated. + * + * @param {string} accesskeyid - Key ID to revoke + * @param {Function} cb - callback(err) + */ +MahiClient.prototype.scopeRevoke = + function scopeRevoke(accesskeyid, cb) { + assert.string(accesskeyid, 'accesskeyid'); + assert.func(cb, 'callback'); + + var self = this; + var path = sprintf('/key-revoke/%s', accesskeyid); + + self.http.post(path, {}, function (err, req, res, obj) { + if (err) { + if (self.http.log) { + self.http.log.warn({ + err: err, + accesskeyid: accesskeyid + }, 'scopeRevoke: mahi call failed' + ' (non-fatal)'); + } + cb(err); + return; + } + cb(null, obj); + }); +}; + + module.exports = { MahiClient: MahiClient, createClient: function (opts) { diff --git a/lib/scope-schema.js b/lib/scope-schema.js new file mode 100644 index 0000000..f879efc --- /dev/null +++ b/lib/scope-schema.js @@ -0,0 +1,332 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* + * Copyright 2026 Edgecast Cloud LLC. + */ + +/* + * Canonical per-bucket access key scope schema. + * + * This module is the single source of truth for the + * scope data format shared across sdc-cloudapi, + * manta-buckets-api, and mahi. sdc-ufds has its own + * validation (LDAP schema layer) but must agree with + * the constants defined here. + * + * Scope envelope: + * { + * "version": 1, + * "permissions": [ + * { "bucket": "", + * "level": "read|readwrite|full" } + * ] + * } + * + * Wildcard grammar: + * "*" — matches all buckets + * "prefix*" — trailing wildcard + * "exact" — exact match only + * Non-trailing wildcards are rejected. + * + * Absent/null scope = unrestricted (backward compat). + */ + + +// ---- Constants ---- + +/** + * @brief Permission levels (string values) + * + * These are the only valid values for the "level" + * field in a scope permission entry. + */ +var VALID_LEVELS = ['read', 'readwrite', 'full']; + +/** + * @brief Numeric permission level constants + * + * Higher value = more permissive. Used for + * comparison: a granted level >= required level + * means access is allowed. + */ +var LEVEL_READ = 1; +var LEVEL_READWRITE = 2; +var LEVEL_FULL = 3; + +/** + * @brief Map from level string to numeric constant + */ +var LEVEL_MAP = { + read: LEVEL_READ, + readwrite: LEVEL_READWRITE, + full: LEVEL_FULL +}; + +/** + * @brief Current scope schema version + */ +var SCOPE_VERSION = 1; + +/** + * @brief Maximum permissions entries per scope + */ +var MAX_PERMISSIONS = 1000; + +/** + * @brief Maximum scope JSON size in bytes + */ +var MAX_SCOPE_SIZE = 51200; + +/** + * @brief Minimum bucket name length + */ +var MIN_BUCKET_LENGTH = 1; + +/** + * @brief Maximum bucket name length (AWS S3 spec) + */ +var MAX_BUCKET_LENGTH = 63; + + +// ---- Validation functions ---- + +/** + * @brief Validate a bucket name or pattern against + * S3 naming rules + * + * Allows exact names, trailing wildcard (logs-*), + * or bare wildcard (*). Rejects non-trailing + * wildcards (*-logs, pre-*-mid). + * + * Valid chars: lowercase letters, numbers, hyphens, + * periods (per AWS bucket naming spec). + * + * @param {string} pattern - Bucket name or pattern + * @return {boolean} true if valid + */ +function isValidBucketPattern(pattern) { + if (pattern === '*') { + return (true); + } + + /* + * Reject non-trailing wildcards: '*' only valid + * as the last character. + */ + var starPos = pattern.indexOf('*'); + if (starPos !== -1 && + starPos !== pattern.length - 1) { + return (false); + } + + /* Strip trailing wildcard for base validation */ + var name = pattern; + if (name.charAt(name.length - 1) === '*') { + name = name.substring(0, name.length - 1); + } + if (name.length === 0) { + return (false); + } + return (/^[a-z0-9][a-z0-9.\-]*$/.test(name)); +} + +/** + * @brief Match a bucket name against a scope pattern + * + * Runtime matching (not validation). Supports exact + * match, bare wildcard, and trailing wildcard. + * + * @param {string} pattern - Scope pattern + * @param {string} name - Actual bucket name + * @return {boolean} true if pattern matches name + */ +function matchBucketPattern(pattern, name) { + if (pattern === '*') { + return (true); + } + if (pattern.charAt(pattern.length - 1) === '*') { + var prefix = pattern.substring( + 0, pattern.length - 1); + return (name.indexOf(prefix) === 0); + } + return (pattern === name); +} + +/** + * @brief Parse and validate a scope JSON string + * + * Returns the parsed scope object or null on any + * validation failure (fail closed). + * + * @param {string} raw - JSON scope string + * @return {Object|null} Parsed scope or null + */ +function parseScope(raw) { + var scope; + try { + scope = JSON.parse(raw); + } catch (e) { + return (null); + } + + if (!scope || + scope.version !== SCOPE_VERSION || + !Array.isArray(scope.permissions)) { + return (null); + } + + return (scope); +} + +/** + * @brief Validate a scope object (full validation) + * + * Performs complete validation of a scope envelope + * including all permission entries. Returns an + * object with valid flag, canonical JSON string, + * and error message. + * + * Time complexity: O(n) where n = permissions count + * Space complexity: O(n) for duplicate check + * + * @param {Object} scope - Scope envelope object + * @return {Object} {valid, scope, error} + */ +function validateScope(scope) { + if (!scope || typeof (scope) !== 'object' || + Array.isArray(scope)) { + return ({ + valid: false, + scope: null, + error: 'scope: must be an object with' + + ' version and permissions fields' + }); + } + + if (scope.version !== SCOPE_VERSION) { + return ({ + valid: false, + scope: null, + error: 'scope: version must be ' + + SCOPE_VERSION + }); + } + + if (!Array.isArray(scope.permissions)) { + return ({ + valid: false, + scope: null, + error: 'scope: permissions must be' + + ' an array' + }); + } + + if (scope.permissions.length === 0) { + return ({ + valid: false, + scope: null, + error: 'scope: permissions array is empty' + }); + } + + if (scope.permissions.length > MAX_PERMISSIONS) { + return ({ + valid: false, + scope: null, + error: 'scope: exceeds maximum of ' + + MAX_PERMISSIONS + ' entries' + }); + } + + var scopeJson = JSON.stringify(scope); + if (scopeJson.length > MAX_SCOPE_SIZE) { + return ({ + valid: false, + scope: null, + error: 'scope: JSON exceeds ' + + (MAX_SCOPE_SIZE / 1024) + + 'KB size limit' + }); + } + + var seen = {}; + for (var i = 0; i < scope.permissions.length; i++) { + var p = scope.permissions[i]; + if (!p || typeof (p) !== 'object') { + return ({ + valid: false, + scope: null, + error: 'scope: permissions[' + i + + '] must be an object' + }); + } + if (typeof (p.bucket) !== 'string' || + p.bucket.length < MIN_BUCKET_LENGTH || + p.bucket.length > MAX_BUCKET_LENGTH) { + return ({ + valid: false, + scope: null, + error: 'scope: permissions[' + i + '].bucket must be ' + + MIN_BUCKET_LENGTH + '-' + MAX_BUCKET_LENGTH + ' characters' + }); + } + if (!isValidBucketPattern(p.bucket)) { + return ({ + valid: false, + scope: null, + error: 'scope: permissions[' + i + + '].bucket: invalid characters' + ' or wildcard position' + }); + } + if (VALID_LEVELS.indexOf(p.level) === -1) { + return ({ + valid: false, + scope: null, + error: 'scope: permissions[' + i + '].level must be one of: ' + + VALID_LEVELS.join(', ') + }); + } + if (seen[p.bucket]) { + return ({ + valid: false, + scope: null, + error: 'scope: duplicate bucket' + ' pattern \'' + p.bucket + + '\'' + }); + } + seen[p.bucket] = true; + } + + return ({ + valid: true, + scope: scopeJson, + error: null + }); +} + + +module.exports = { + /* Constants */ + VALID_LEVELS: VALID_LEVELS, + LEVEL_READ: LEVEL_READ, + LEVEL_READWRITE: LEVEL_READWRITE, + LEVEL_FULL: LEVEL_FULL, + LEVEL_MAP: LEVEL_MAP, + SCOPE_VERSION: SCOPE_VERSION, + MAX_PERMISSIONS: MAX_PERMISSIONS, + MAX_SCOPE_SIZE: MAX_SCOPE_SIZE, + MIN_BUCKET_LENGTH: MIN_BUCKET_LENGTH, + MAX_BUCKET_LENGTH: MAX_BUCKET_LENGTH, + + /* Validation */ + isValidBucketPattern: isValidBucketPattern, + validateScope: validateScope, + + /* Runtime */ + matchBucketPattern: matchBucketPattern, + parseScope: parseScope +}; diff --git a/package.json b/package.json index 9975b49..d1a072b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mahi", "description": "Mahi client", - "version": "2.5.0", + "version": "2.5.1", "author":"Edgecast Cloud (edgecast.io)", "main": "index.js", "dependencies": { diff --git a/test/scope-schema.test.js b/test/scope-schema.test.js new file mode 100644 index 0000000..c1301b8 --- /dev/null +++ b/test/scope-schema.test.js @@ -0,0 +1,407 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* + * Copyright 2026 Edgecast Cloud LLC. + */ + +/* + * Unit tests for lib/scope-schema.js. + * + * The module is the canonical scope envelope schema + * shared with sdc-cloudapi, manta-buckets-api, and + * mahi. validateScope() is fail-closed: any + * malformed input must return {valid:false, + * scope:null, error:}. These tests exercise + * each rejection branch and the success path. + */ + +var test = require('tap').test; +var schema = require('../lib/scope-schema'); + + +// ---- Helpers ---- + +/** + * @brief Build a permissions array of size n with + * unique short bucket names. Used to exercise the + * MAX_PERMISSIONS branch without tripping the + * MAX_SCOPE_SIZE branch first. + * + * @param {number} n - Number of entries + * @return {Array} permissions array + */ +function makePerms(n) { + var out = []; + var i; + for (i = 0; i < n; i++) { + out.push({bucket: 'b' + i, level: 'read'}); + } + return (out); +} + +/** + * @brief Build a permissions array whose serialized + * JSON exceeds MAX_SCOPE_SIZE while keeping the + * entry count <= MAX_PERMISSIONS. Each bucket + * name is 63 chars (max per S3 spec) and unique. + * + * @param {number} n - Number of entries + * @return {Array} permissions array + */ +function makeFatPerms(n) { + var prefix = ''; + var i; + for (i = 0; i < 60; i++) { + prefix += 'a'; + } + var out = []; + for (i = 0; i < n; i++) { + var idx = ('000' + i).slice(-3); + out.push({ + bucket: prefix + idx, + level: 'readwrite' + }); + } + return (out); +} + + +// ---- validateScope: shape rejection ---- + +test('validateScope: null is rejected', function (t) { + var r = schema.validateScope(null); + t.equal(r.valid, false, 'invalid'); + t.equal(r.scope, null, 'scope null'); + t.ok(r.error, 'has error'); + t.end(); +}); + +test('validateScope: undefined is rejected', function (t) { + var r = schema.validateScope(undefined); + t.equal(r.valid, false, 'invalid'); + t.ok(r.error, 'has error'); + t.end(); +}); + +test('validateScope: string is rejected', function (t) { + var r = schema.validateScope('not-an-object'); + t.equal(r.valid, false, 'invalid'); + t.ok(r.error, 'has error'); + t.end(); +}); + +test('validateScope: number is rejected', function (t) { + var r = schema.validateScope(42); + t.equal(r.valid, false, 'invalid'); + t.ok(r.error, 'has error'); + t.end(); +}); + +test('validateScope: array is rejected', function (t) { + var r = schema.validateScope([]); + t.equal(r.valid, false, 'array rejected'); + t.ok(r.error, 'has error'); + t.end(); +}); + + +// ---- validateScope: envelope rejection ---- + +test('validateScope: missing version', function (t) { + var r = schema.validateScope({permissions: []}); + t.equal(r.valid, false, 'invalid'); + t.ok(/version/.test(r.error), 'mentions version'); + t.end(); +}); + +test('validateScope: wrong version', function (t) { + var r = schema.validateScope({ + version: 2, + permissions: [ + {bucket: 'a', level: 'read'} + ] + }); + t.equal(r.valid, false, 'invalid'); + t.ok(/version/.test(r.error), 'mentions version'); + t.end(); +}); + +test('validateScope: permissions not array', function (t) { + var r = schema.validateScope({ + version: schema.SCOPE_VERSION, + permissions: 'oops' + }); + t.equal(r.valid, false, 'invalid'); + t.ok(/array/.test(r.error), 'mentions array'); + t.end(); +}); + +test('validateScope: empty permissions', function (t) { + var r = schema.validateScope({ + version: schema.SCOPE_VERSION, + permissions: [] + }); + t.equal(r.valid, false, 'invalid'); + t.ok(/empty/.test(r.error), 'mentions empty'); + t.end(); +}); + +test('validateScope: too many permissions', function (t) { + var perms = makePerms(schema.MAX_PERMISSIONS + 1); + var r = schema.validateScope({ + version: schema.SCOPE_VERSION, + permissions: perms + }); + t.equal(r.valid, false, 'invalid'); + t.ok(/maximum/.test(r.error), 'mentions maximum'); + t.end(); +}); + +test('validateScope: oversize JSON', function (t) { + /* + * 600 entries x ~93 bytes/entry > 51200 bytes + * but still <= MAX_PERMISSIONS, so the size + * branch fires before the count branch. + */ + var perms = makeFatPerms(600); + var r = schema.validateScope({ + version: schema.SCOPE_VERSION, + permissions: perms + }); + t.equal(r.valid, false, 'invalid'); + t.ok(/size limit/.test(r.error), 'mentions size'); + t.end(); +}); + + +// ---- validateScope: per-entry rejection ---- + +test('validateScope: entry not object', function (t) { + var r = schema.validateScope({ + version: schema.SCOPE_VERSION, + permissions: [null] + }); + t.equal(r.valid, false, 'invalid'); + t.ok(/permissions\[0\]/.test(r.error), 'index'); + t.end(); +}); + +test('validateScope: bucket too short', function (t) { + var r = schema.validateScope({ + version: schema.SCOPE_VERSION, + permissions: [ + {bucket: '', level: 'read'} + ] + }); + t.equal(r.valid, false, 'invalid'); + t.ok(/bucket/.test(r.error), 'mentions bucket'); + t.end(); +}); + +test('validateScope: bucket too long', function (t) { + var name = ''; + var i; + for (i = 0; i < schema.MAX_BUCKET_LENGTH + 1; i++) { + name += 'a'; + } + var r = schema.validateScope({ + version: schema.SCOPE_VERSION, + permissions: [ + {bucket: name, level: 'read'} + ] + }); + t.equal(r.valid, false, 'invalid'); + t.ok(/bucket/.test(r.error), 'mentions bucket'); + t.end(); +}); + +test('validateScope: bucket non-string', function (t) { + var r = schema.validateScope({ + version: schema.SCOPE_VERSION, + permissions: [ + {bucket: 5, level: 'read'} + ] + }); + t.equal(r.valid, false, 'invalid'); + t.end(); +}); + +test('validateScope: invalid bucket chars', function (t) { + var r = schema.validateScope({ + version: schema.SCOPE_VERSION, + permissions: [ + {bucket: 'NoCaps', level: 'read'} + ] + }); + t.equal(r.valid, false, 'invalid'); + t.ok(/invalid/.test(r.error), 'mentions invalid'); + t.end(); +}); + +test('validateScope: non-trailing wildcard', function (t) { + var r = schema.validateScope({ + version: schema.SCOPE_VERSION, + permissions: [ + {bucket: 'pre*mid', level: 'read'} + ] + }); + t.equal(r.valid, false, 'invalid'); + t.ok(/wildcard/.test(r.error), 'mentions wildcard'); + t.end(); +}); + +test('validateScope: invalid level', function (t) { + var r = schema.validateScope({ + version: schema.SCOPE_VERSION, + permissions: [ + {bucket: 'a', level: 'admin'} + ] + }); + t.equal(r.valid, false, 'invalid'); + t.ok(/level/.test(r.error), 'mentions level'); + t.end(); +}); + +test('validateScope: duplicate bucket', function (t) { + var r = schema.validateScope({ + version: schema.SCOPE_VERSION, + permissions: [ + {bucket: 'logs', level: 'read'}, + {bucket: 'logs', level: 'full'} + ] + }); + t.equal(r.valid, false, 'invalid'); + t.ok(/duplicate/.test(r.error), 'mentions dup'); + t.end(); +}); + + +// ---- validateScope: success path ---- + +test('validateScope: minimal valid scope', function (t) { + var r = schema.validateScope({ + version: schema.SCOPE_VERSION, + permissions: [ + {bucket: 'logs', level: 'read'} + ] + }); + t.equal(r.valid, true, 'valid'); + t.equal(r.error, null, 'no error'); + t.equal(typeof (r.scope), 'string', + 'scope is JSON'); + var parsed = JSON.parse(r.scope); + t.equal(parsed.version, schema.SCOPE_VERSION, + 'version preserved'); + t.equal(parsed.permissions.length, 1, '1 entry'); + t.end(); +}); + +test('validateScope: all levels and wildcard', + function (t) { + var r = schema.validateScope({ + version: schema.SCOPE_VERSION, + permissions: [ + {bucket: '*', level: 'read'}, + {bucket: 'logs-*', level: 'readwrite'}, + {bucket: 'archive', level: 'full'} + ] + }); + t.equal(r.valid, true, 'valid'); + t.equal(r.error, null, 'no error'); + t.end(); + }); + +test('validateScope: at MAX_PERMISSIONS boundary', + function (t) { + var perms = makePerms(schema.MAX_PERMISSIONS); + var r = schema.validateScope({ + version: schema.SCOPE_VERSION, + permissions: perms + }); + t.equal(r.valid, true, 'valid'); + t.end(); + }); + + +// ---- isValidBucketPattern smoke ---- + +test('isValidBucketPattern: accepts exact', function (t) { + t.ok(schema.isValidBucketPattern('logs')); + t.ok(schema.isValidBucketPattern('a-b.c')); + t.ok(schema.isValidBucketPattern('0abc')); + t.end(); +}); + +test('isValidBucketPattern: accepts wildcard', + function (t) { + t.ok(schema.isValidBucketPattern('*')); + t.ok(schema.isValidBucketPattern('logs-*')); + t.end(); + }); + +test('isValidBucketPattern: rejects bad input', + function (t) { + t.notOk(schema.isValidBucketPattern('')); + t.notOk(schema.isValidBucketPattern('UPPER')); + t.notOk(schema.isValidBucketPattern('pre*mid')); + t.notOk(schema.isValidBucketPattern('-leading')); + t.notOk(schema.isValidBucketPattern('.dot')); + t.end(); + }); + + +// ---- matchBucketPattern smoke ---- + +test('matchBucketPattern: exact match', function (t) { + t.ok(schema.matchBucketPattern('foo', 'foo')); + t.notOk(schema.matchBucketPattern('foo', 'bar')); + t.notOk(schema.matchBucketPattern('foo', 'foobar')); + t.end(); +}); + +test('matchBucketPattern: wildcard', function (t) { + t.ok(schema.matchBucketPattern('*', 'anything')); + t.ok(schema.matchBucketPattern('logs-*', + 'logs-2026')); + t.notOk(schema.matchBucketPattern('logs-*', + 'archive')); + t.end(); +}); + + +// ---- parseScope ---- + +test('parseScope: invalid JSON returns null', + function (t) { + t.equal(schema.parseScope('not json'), null); + t.end(); + }); + +test('parseScope: wrong version returns null', + function (t) { + var raw = JSON.stringify({ + version: 99, + permissions: [] + }); + t.equal(schema.parseScope(raw), null); + t.end(); + }); + +test('parseScope: well-formed returns object', + function (t) { + var raw = JSON.stringify({ + version: schema.SCOPE_VERSION, + permissions: [ + {bucket: 'a', level: 'read'} + ] + }); + var parsed = schema.parseScope(raw); + t.ok(parsed, 'parsed'); + t.equal(parsed.version, schema.SCOPE_VERSION); + t.equal(parsed.permissions.length, 1); + t.end(); + });