From 6d8034b3c1ba9e9ee5d10f7ef59872ed88603b7a Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Wed, 17 Jun 2026 14:32:17 +0300 Subject: [PATCH 1/2] test(api): add integration tests for Array read endpoints (RI-8219) Adds integration coverage for the Array read API surface, gated by `requirements('rte.version>=8.8')` so they only run against RTEs that ship the Array data type: - Per-endpoint suites under test/api/array/ for get-count, get-element, get-elements, get-length, get-next-index, get-range and scan. - Array branch of the shared /keys/get-info endpoint in test/api/array/POST-databases-id-keys-get_info-array.test.ts (the matching branch is removed from the keys suite to avoid duplicate runs); a forwarding comment is added on the keys side. - Extra cases for the existing create endpoint: duplicate-index last-write-wins, 2^64-1 sentinel rejection, and the symmetric empty-elements guard for sparse mode. --- .../POST-databases-id-array-get_count.test.ts | 151 ++++++++ ...OST-databases-id-array-get_element.test.ts | 261 +++++++++++++ ...ST-databases-id-array-get_elements.test.ts | 225 +++++++++++ ...POST-databases-id-array-get_length.test.ts | 177 +++++++++ ...-databases-id-array-get_next_index.test.ts | 183 +++++++++ .../POST-databases-id-array-get_range.test.ts | 323 ++++++++++++++++ .../POST-databases-id-array-scan.test.ts | 351 ++++++++++++++++++ .../api/array/POST-databases-id-array.test.ts | 42 +++ ...T-databases-id-keys-get_info-array.test.ts | 77 ++++ .../POST-databases-id-keys-get_info.test.ts | 4 + 10 files changed, 1794 insertions(+) create mode 100644 redisinsight/api/test/api/array/POST-databases-id-array-get_count.test.ts create mode 100644 redisinsight/api/test/api/array/POST-databases-id-array-get_element.test.ts create mode 100644 redisinsight/api/test/api/array/POST-databases-id-array-get_elements.test.ts create mode 100644 redisinsight/api/test/api/array/POST-databases-id-array-get_length.test.ts create mode 100644 redisinsight/api/test/api/array/POST-databases-id-array-get_next_index.test.ts create mode 100644 redisinsight/api/test/api/array/POST-databases-id-array-get_range.test.ts create mode 100644 redisinsight/api/test/api/array/POST-databases-id-array-scan.test.ts create mode 100644 redisinsight/api/test/api/array/POST-databases-id-keys-get_info-array.test.ts diff --git a/redisinsight/api/test/api/array/POST-databases-id-array-get_count.test.ts b/redisinsight/api/test/api/array/POST-databases-id-array-get_count.test.ts new file mode 100644 index 0000000000..65ac7cd1fd --- /dev/null +++ b/redisinsight/api/test/api/array/POST-databases-id-array-get_count.test.ts @@ -0,0 +1,151 @@ +import { + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall, + getMainCheckFn, + JoiRedisString, +} from '../deps'; + +const { server, request, constants } = deps; +const rte = deps.rte as any; + +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post( + `/${constants.API.DATABASES}/${instanceId}/array/get-count`, + ); + +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), +}; + +const responseSchema = Joi.object() + .keys({ + keyName: JoiRedisString.required(), + count: Joi.string().pattern(/^\d+$/).required(), + }) + .required(); + +const mainCheckFn = getMainCheckFn(endpoint); + +describe('POST /databases/:instanceId/array/get-count', () => { + requirements('rte.version>=8.8'); + beforeEach(async () => rte.data.truncate()); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Main', () => { + it('Should return count equal to length for a dense array', async () => { + const keyName = constants.getRandomString(); + await rte.client.call('ARSET', keyName, '0', 'a', 'b', 'c'); + + await validateApiCall({ + endpoint, + data: { keyName }, + responseSchema, + responseBody: { keyName, count: '3' }, + }); + }); + + it('Should diverge from length for a sparse array', async () => { + const keyName = constants.getRandomString(); + // Sparse: indexes 0,1,5 populated → count=3, length=6. The point of + // ARCOUNT is that it stays cheap even when length grows; pinning the + // divergence locks in that the two commands are surfaced independently. + await rte.client.call( + 'ARMSET', + keyName, + '0', + '20.1', + '1', + '20.4', + '5', + '21.4', + ); + + await validateApiCall({ + endpoint, + data: { keyName }, + responseSchema, + responseBody: { keyName, count: '3' }, + }); + }); + + [ + { + name: 'Should return BadRequest if key holds a non-array type', + data: { keyName: constants.TEST_STRING_KEY_1 }, + statusCode: 400, + before: () => rte.data.generateKeys(true), + }, + { + name: 'Should return NotFound if key does not exist', + data: { keyName: constants.getRandomString() }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return NotFound if instance id does not exist', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { keyName: constants.getRandomString() }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + const aclEndpoint = () => endpoint(constants.TEST_INSTANCE_ACL_ID); + const aclKey = constants.getRandomString(); + + [ + { + name: 'Should return count for an authorised user', + endpoint: aclEndpoint, + data: { keyName: aclKey }, + responseSchema, + before: async () => { + await rte.data.setAclUserRules('~* +@all'); + await rte.client.call('ARSET', aclKey, '0', 'x'); + }, + }, + { + name: 'Should throw error if no permissions for "arcount" command', + endpoint: aclEndpoint, + data: { keyName: aclKey }, + statusCode: 403, + responseBody: { statusCode: 403, error: 'Forbidden' }, + // beforeEach() wipes the key between tests; reseed via the root + // client (ACL rules below only affect the API request). + before: async () => { + await rte.client.call('ARSET', aclKey, '0', 'x'); + await rte.data.setAclUserRules('~* +@all -arcount'); + }, + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/array/POST-databases-id-array-get_element.test.ts b/redisinsight/api/test/api/array/POST-databases-id-array-get_element.test.ts new file mode 100644 index 0000000000..cb5b08ce13 --- /dev/null +++ b/redisinsight/api/test/api/array/POST-databases-id-array-get_element.test.ts @@ -0,0 +1,261 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall, + getMainCheckFn, + JoiRedisString, +} from '../deps'; + +const { server, request, constants } = deps; +const rte = deps.rte as any; + +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post( + `/${constants.API.DATABASES}/${instanceId}/array/get-element`, + ); + +// `index` is validated by @IsArrayIndex on the API side, which emits a single +// combined message (" must be an integer string between 0 and +// 18446744073709551614") for any non-canonical input. Override the per-rule +// Joi messages with a label-less substring of the API output so the harness's +// substring-contains check passes for undefined / null / number / boolean / +// object / array cases. +const ARRAY_INDEX_MSG = 'must be an integer string between'; + +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + index: Joi.string().required().messages({ + 'string.base': ARRAY_INDEX_MSG, + 'any.required': ARRAY_INDEX_MSG, + }), +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + index: '0', +}; + +// Empty slots and out-of-range indexes return value: null (key still exists); +// only a missing key turns into a 404. +const responseSchema = Joi.object() + .keys({ + keyName: JoiRedisString.required(), + value: JoiRedisString.allow(null).required(), + }) + .required(); + +const mainCheckFn = getMainCheckFn(endpoint); + +// Seed shape mirrors the canonical `readings` fixture documented in the Bruno +// presets — indexes 0,1,5 populated, gaps at 2,3,4. +const seedSparse = (key: string) => + rte.client.call('ARMSET', key, '0', '20.1', '1', '20.4', '5', '21.4'); + +describe('POST /databases/:instanceId/array/get-element', () => { + requirements('rte.version>=8.8'); + beforeEach(async () => rte.data.truncate()); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + + [ + { + name: 'Should reject a non-decimal index', + data: { keyName: constants.getRandomString(), index: 'abc' }, + statusCode: 400, + }, + { + name: 'Should reject a non-canonical index with leading zero', + data: { keyName: constants.getRandomString(), index: '007' }, + statusCode: 400, + }, + { + name: 'Should reject an index outside u64', + data: { + keyName: constants.getRandomString(), + index: '18446744073709551616', + }, + statusCode: 400, + }, + { + // 2^64-1 (18446744073709551615) is technically within u64 but Redis + // reserves it as the "no-index" sentinel (ARSET / ARMSET reject it + // server-side). Match that boundary at the API validator. + name: 'Should reject the reserved 2^64-1 sentinel index', + data: { + keyName: constants.getRandomString(), + index: '18446744073709551615', + }, + statusCode: 400, + }, + ].map(mainCheckFn); + }); + + describe('Main', () => { + it('Should return value for a populated index', async () => { + const keyName = constants.getRandomString(); + await seedSparse(keyName); + + await validateApiCall({ + endpoint, + data: { keyName, index: '1' }, + responseSchema, + responseBody: { keyName, value: '20.4' }, + }); + }); + + it('Should return null for an empty slot within the array', async () => { + const keyName = constants.getRandomString(); + await seedSparse(keyName); + + // Index 3 sits inside the array's logical length (0..5) but is unset. + // Contract: 200 OK with value:null, NOT 404 — key exists, slot is empty. + await validateApiCall({ + endpoint, + data: { keyName, index: '3' }, + responseSchema, + responseBody: { keyName, value: null }, + }); + }); + + it('Should return null for an index past the array length', async () => { + const keyName = constants.getRandomString(); + await seedSparse(keyName); + + // Index 999 is past length=6. ARGET still returns nil rather than + // erroring out — same contract as an empty slot inside the array. + await validateApiCall({ + endpoint, + data: { keyName, index: '999' }, + responseSchema, + responseBody: { keyName, value: null }, + }); + }); + + it('Should round-trip a value stored at the maximum valid index (2^64-2)', async () => { + const keyName = constants.getRandomString(); + const maxIndex = '18446744073709551614'; + + // ARSET at the boundary the API validator permits, then read it back. + // Guards both the validator accepting 2^64-2 and the bulk-reply path. + await rte.client.call('ARSET', keyName, maxIndex, 'edge'); + + await validateApiCall({ + endpoint, + data: { keyName, index: maxIndex }, + responseSchema, + responseBody: { keyName, value: 'edge' }, + }); + }); + + it('Should return a Buffer object when encoding=buffer on a populated slot', async () => { + const keyName = constants.getRandomString(); + await seedSparse(keyName); + + await validateApiCall({ + endpoint, + query: { encoding: 'buffer' }, + data: { keyName, index: '0' }, + responseSchema, + checkFn: ({ body }: any) => { + // Buffer-mode populated value comes back as the {type, data} shape + // produced by JSON-serializing a Node Buffer. + expect(body.value).to.eql({ + type: 'Buffer', + data: [...Buffer.from('20.1')], + }); + }, + }); + }); + + it('Should keep an empty slot as JSON null even when encoding=buffer', async () => { + const keyName = constants.getRandomString(); + await seedSparse(keyName); + + // Locks in the RedisStringToBufferTransformer null-passthrough fix: + // a nil ARGET reply must not be coerced into a zero-length Buffer. + await validateApiCall({ + endpoint, + query: { encoding: 'buffer' }, + data: { keyName, index: '3' }, + responseSchema, + checkFn: ({ body }: any) => { + expect(body.value).to.eql(null); + }, + }); + }); + + [ + { + name: 'Should return BadRequest if key holds a non-array type', + data: { keyName: constants.TEST_STRING_KEY_1, index: '0' }, + statusCode: 400, + before: () => rte.data.generateKeys(true), + }, + { + name: 'Should return NotFound if key does not exist', + data: { keyName: constants.getRandomString(), index: '0' }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return NotFound if instance id does not exist', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { keyName: constants.getRandomString(), index: '0' }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + const aclEndpoint = () => endpoint(constants.TEST_INSTANCE_ACL_ID); + const aclKey = constants.getRandomString(); + + [ + { + name: 'Should return value for an authorised user', + endpoint: aclEndpoint, + data: { keyName: aclKey, index: '0' }, + responseSchema, + before: async () => { + await rte.data.setAclUserRules('~* +@all'); + await rte.client.call('ARSET', aclKey, '0', 'x'); + }, + }, + { + name: 'Should throw error if no permissions for "arget" command', + endpoint: aclEndpoint, + data: { keyName: aclKey, index: '0' }, + statusCode: 403, + responseBody: { statusCode: 403, error: 'Forbidden' }, + // beforeEach() wipes the key between tests; reseed via the root + // client (ACL rules below only affect the API request). + before: async () => { + await rte.client.call('ARSET', aclKey, '0', 'x'); + await rte.data.setAclUserRules('~* +@all -arget'); + }, + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/array/POST-databases-id-array-get_elements.test.ts b/redisinsight/api/test/api/array/POST-databases-id-array-get_elements.test.ts new file mode 100644 index 0000000000..69ed6c3311 --- /dev/null +++ b/redisinsight/api/test/api/array/POST-databases-id-array-get_elements.test.ts @@ -0,0 +1,225 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall, + getMainCheckFn, + JoiRedisString, +} from '../deps'; + +const { server, request, constants } = deps; +const rte = deps.rte as any; + +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post( + `/${constants.API.DATABASES}/${instanceId}/array/get-elements`, + ); + +// Per-item indexes are validated by @IsArrayIndex({ each: true }), which emits +// a single combined message (" must be an integer string between 0 +// and 18446744073709551614") for any non-canonical item — and drops the array +// index marker (so an `indexes[0]` failure still surfaces as `indexes must be +// …`). Override the per-rule Joi messages with a label-less substring of the +// API output so the harness's substring-contains check passes. +const ARRAY_INDEX_MSG = 'must be an integer string between'; + +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + indexes: Joi.array() + .items(Joi.string().messages({ 'string.base': ARRAY_INDEX_MSG })) + .min(1) + .required() + .messages({ 'any.required': ARRAY_INDEX_MSG }), +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + indexes: ['0'], +}; + +// Gap-preserving response: items can be string|null in request order. +const responseSchema = Joi.object() + .keys({ + keyName: JoiRedisString.required(), + elements: Joi.array().items(JoiRedisString.allow(null)).required(), + }) + .required(); + +const mainCheckFn = getMainCheckFn(endpoint); + +const seedSparse = (key: string) => + rte.client.call('ARMSET', key, '0', '20.1', '1', '20.4', '5', '21.4'); + +describe('POST /databases/:instanceId/array/get-elements', () => { + requirements('rte.version>=8.8'); + beforeEach(async () => rte.data.truncate()); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + + [ + { + name: 'Should reject an empty indexes array (ArrayMinSize)', + data: { keyName: constants.getRandomString(), indexes: [] }, + statusCode: 400, + }, + { + name: 'Should reject a non-decimal index in the list', + data: { + keyName: constants.getRandomString(), + indexes: ['0', 'abc', '2'], + }, + statusCode: 400, + }, + { + name: 'Should reject an index outside u64 in the list', + data: { + keyName: constants.getRandomString(), + indexes: ['0', '18446744073709551616'], + }, + statusCode: 400, + }, + { + // 2^64-1 is reserved by Redis as the "no-index" sentinel; a single + // bad item must invalidate the whole request (per-item @IsArrayIndex). + name: 'Should reject when any index equals the reserved 2^64-1 sentinel', + data: { + keyName: constants.getRandomString(), + indexes: ['0', '18446744073709551615', '5'], + }, + statusCode: 400, + }, + ].map(mainCheckFn); + }); + + describe('Main', () => { + it('Should return values in request order with null for empty / past-end slots', async () => { + const keyName = constants.getRandomString(); + await seedSparse(keyName); + + // Request: 0 (populated), 2 (gap), 5 (populated), 999 (past end). + // Contract: response.elements lines up 1:1 with request order; + // empty slot and out-of-range index both surface as JSON null. + await validateApiCall({ + endpoint, + data: { keyName, indexes: ['0', '2', '5', '999'] }, + responseSchema, + responseBody: { + keyName, + elements: ['20.1', null, '21.4', null], + }, + }); + }); + + it('Should return the same value twice when an index is repeated', async () => { + const keyName = constants.getRandomString(); + await seedSparse(keyName); + + // ARMGET preserves request order, including duplicates — important for + // consumers that lean on positional alignment with their own list. + await validateApiCall({ + endpoint, + data: { keyName, indexes: ['1', '1', '0'] }, + responseSchema, + responseBody: { + keyName, + elements: ['20.4', '20.4', '20.1'], + }, + }); + }); + + it('Should return Buffer objects with null preserved when encoding=buffer', async () => { + const keyName = constants.getRandomString(); + await seedSparse(keyName); + + // Locks in the RedisStringToBufferTransformer null-passthrough fix at + // the array-fill level: populated slots become {type:'Buffer', data}, + // empty slots stay as JSON null (no zero-length Buffer coercion). + await validateApiCall({ + endpoint, + query: { encoding: 'buffer' }, + data: { keyName, indexes: ['0', '2', '5'] }, + responseSchema, + checkFn: ({ body }: any) => { + expect(body.elements).to.eql([ + { type: 'Buffer', data: [...Buffer.from('20.1')] }, + null, + { type: 'Buffer', data: [...Buffer.from('21.4')] }, + ]); + }, + }); + }); + + [ + { + name: 'Should return BadRequest if key holds a non-array type', + data: { keyName: constants.TEST_STRING_KEY_1, indexes: ['0'] }, + statusCode: 400, + before: () => rte.data.generateKeys(true), + }, + { + name: 'Should return NotFound if key does not exist', + data: { keyName: constants.getRandomString(), indexes: ['0'] }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return NotFound if instance id does not exist', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { keyName: constants.getRandomString(), indexes: ['0'] }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + const aclEndpoint = () => endpoint(constants.TEST_INSTANCE_ACL_ID); + const aclKey = constants.getRandomString(); + + [ + { + name: 'Should return elements for an authorised user', + endpoint: aclEndpoint, + data: { keyName: aclKey, indexes: ['0'] }, + responseSchema, + before: async () => { + await rte.data.setAclUserRules('~* +@all'); + await rte.client.call('ARSET', aclKey, '0', 'x'); + }, + }, + { + name: 'Should throw error if no permissions for "armget" command', + endpoint: aclEndpoint, + data: { keyName: aclKey, indexes: ['0'] }, + statusCode: 403, + responseBody: { statusCode: 403, error: 'Forbidden' }, + // beforeEach() wipes the key between tests; reseed via the root + // client (ACL rules below only affect the API request). + before: async () => { + await rte.client.call('ARSET', aclKey, '0', 'x'); + await rte.data.setAclUserRules('~* +@all -armget'); + }, + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/array/POST-databases-id-array-get_length.test.ts b/redisinsight/api/test/api/array/POST-databases-id-array-get_length.test.ts new file mode 100644 index 0000000000..b64016824a --- /dev/null +++ b/redisinsight/api/test/api/array/POST-databases-id-array-get_length.test.ts @@ -0,0 +1,177 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall, + getMainCheckFn, + JoiRedisString, +} from '../deps'; + +const { server, request, constants } = deps; +// The harness types deps.rte as null until initRTE runs; the existing array +// create test casts to any for the same reason. +const rte = deps.rte as any; + +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post( + `/${constants.API.DATABASES}/${instanceId}/array/get-length`, + ); + +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), +}; + +// length is a decimal-string contract (u64), not a number — pin it tight. +const responseSchema = Joi.object() + .keys({ + keyName: JoiRedisString.required(), + length: Joi.string().pattern(/^\d+$/).required(), + }) + .required(); + +const mainCheckFn = getMainCheckFn(endpoint); + +describe('POST /databases/:instanceId/array/get-length', () => { + // Array is a Redis 8.8 preview type; skip where the server lacks ARLEN. + requirements('rte.version>=8.8'); + beforeEach(async () => rte.data.truncate()); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Main', () => { + it('Should return length for a dense array', async () => { + const keyName = constants.getRandomString(); + await rte.client.call('ARSET', keyName, '0', 'a', 'b', 'c'); + + await validateApiCall({ + endpoint, + data: { keyName }, + responseSchema, + responseBody: { keyName, length: '3' }, + }); + }); + + it('Should return length spanning gaps for a sparse array', async () => { + const keyName = constants.getRandomString(); + // Highest set index is 5, so length is 6 even though only 3 slots are populated. + await rte.client.call( + 'ARMSET', + keyName, + '0', + '20.1', + '1', + '20.4', + '5', + '21.4', + ); + + await validateApiCall({ + endpoint, + data: { keyName }, + responseSchema, + responseBody: { keyName, length: '6' }, + }); + }); + + it('Should preserve u64 precision (length above MAX_SAFE_INTEGER)', async () => { + const keyName = constants.getRandomString(); + // Highest index 2^63 + 10 — well above Number.MAX_SAFE_INTEGER; the + // BE must serialize as a decimal string or precision is silently lost. + const hugeIndex = '9223372036854775818'; + const expectedLength = '9223372036854775819'; + await rte.client.call('ARMSET', keyName, hugeIndex, 'x'); + + await validateApiCall({ + endpoint, + data: { keyName }, + responseSchema, + responseBody: { keyName, length: expectedLength }, + checkFn: ({ body }: any) => { + // Lock the contract: this must arrive as a string, not a number. + expect(typeof body.length).to.eql('string'); + expect(body.length).to.eql(expectedLength); + }, + }); + }); + + [ + { + name: 'Should return BadRequest if key holds a non-array type', + data: { + keyName: constants.TEST_STRING_KEY_1, + }, + statusCode: 400, + before: () => rte.data.generateKeys(true), + }, + { + name: 'Should return NotFound if key does not exist', + data: { keyName: constants.getRandomString() }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return NotFound if instance id does not exist', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { keyName: constants.getRandomString() }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + const aclEndpoint = () => endpoint(constants.TEST_INSTANCE_ACL_ID); + const aclKey = constants.getRandomString(); + + [ + { + name: 'Should return length for an authorised user', + endpoint: aclEndpoint, + data: { keyName: aclKey }, + responseSchema, + before: async () => { + await rte.data.setAclUserRules('~* +@all'); + await rte.client.call('ARSET', aclKey, '0', 'x'); + }, + }, + { + name: 'Should throw error if no permissions for "arlen" command', + endpoint: aclEndpoint, + data: { keyName: aclKey }, + statusCode: 403, + responseBody: { statusCode: 403, error: 'Forbidden' }, + // beforeEach() wipes the key between tests; reseed via the root + // client (ACL rules below only affect the API request). + before: async () => { + await rte.client.call('ARSET', aclKey, '0', 'x'); + await rte.data.setAclUserRules('~* +@all -arlen'); + }, + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/array/POST-databases-id-array-get_next_index.test.ts b/redisinsight/api/test/api/array/POST-databases-id-array-get_next_index.test.ts new file mode 100644 index 0000000000..e65f31f764 --- /dev/null +++ b/redisinsight/api/test/api/array/POST-databases-id-array-get_next_index.test.ts @@ -0,0 +1,183 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall, + getMainCheckFn, + JoiRedisString, +} from '../deps'; + +const { server, request, constants } = deps; +const rte = deps.rte as any; + +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post( + `/${constants.API.DATABASES}/${instanceId}/array/get-next-index`, + ); + +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), +}; + +// Service issues ARNEXT (the ARINSERT cursor — a piece of array-level state +// that ARINSERT advances and ARSEEK repositions, independent of which slots +// ARSET/ARMSET have populated) and maps the reply through toIndexString, so +// `index` is the cursor as a decimal string — or null when Redis reports +// the cursor as exhausted (no further insertion possible). +const responseSchema = Joi.object() + .keys({ + keyName: JoiRedisString.required(), + index: Joi.string().pattern(/^\d+$/).allow(null).required(), + }) + .required(); + +const mainCheckFn = getMainCheckFn(endpoint); + +describe('POST /databases/:instanceId/array/get-next-index', () => { + requirements('rte.version>=8.8'); + beforeEach(async () => rte.data.truncate()); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + }); + + describe('Main', () => { + it('Should return cursor 0 for a key populated only via ARSET', async () => { + const keyName = constants.getRandomString(); + // ARSET writes a contiguous run from startIndex but does NOT advance + // the insertion cursor — that surface is reserved for ARINSERT. Pins + // the semantic boundary: this endpoint exposes the ARINSERT cursor, + // not the array length (use /array/get-length for that). + await rte.client.call('ARSET', keyName, '0', 'a', 'b', 'c'); + + await validateApiCall({ + endpoint, + data: { keyName }, + responseSchema, + responseBody: { keyName, index: '0' }, + checkFn: ({ body }: any) => { + // String contract — guards against any future numeric-coercion regression. + expect(typeof body.index).to.eql('string'); + }, + }); + }); + + it('Should advance the cursor by one per value inserted via ARINSERT', async () => { + const keyName = constants.getRandomString(); + // ARINSERT writes at the cursor position and advances the cursor by + // one per value, so after three values the cursor sits at 3 — exactly + // what ARNEXT must report. + await rte.client.call('ARINSERT', keyName, 'a', 'b', 'c'); + + await validateApiCall({ + endpoint, + data: { keyName }, + responseSchema, + responseBody: { keyName, index: '3' }, + }); + }); + + it('Should reflect an explicit cursor reposition via ARSEEK', async () => { + const keyName = constants.getRandomString(); + // ARINSERT to create the array and advance the cursor to 2; ARSEEK + // then jumps it to 100. ARNEXT must mirror the moved cursor — locks + // in that the response tracks state, not the inserted-value count. + await rte.client.call('ARINSERT', keyName, 'a', 'b'); + await rte.client.call('ARSEEK', keyName, '100'); + + await validateApiCall({ + endpoint, + data: { keyName }, + responseSchema, + responseBody: { keyName, index: '100' }, + }); + }); + + // The "exhausted cursor returns null" path is verified end-to-end via + // the toIndexString unit test (nil reply -> null); reproducing it here + // would require driving ARSEEK / ARINSERT to the u64 sentinel boundary, + // whose semantics on Redis 8.8 are not documented well enough to keep + // the assertion stable across patch releases. + // + // The u64-cursor precision regression is already pinned by the skipped + // ARSCAN canary (Should preserve u64 precision when scanning at indexes + // above MAX_SAFE_INTEGER) — re-asserting it here would duplicate that + // coverage without adding a new wire path. + + [ + { + name: 'Should return BadRequest if key holds a non-array type', + data: { keyName: constants.TEST_STRING_KEY_1 }, + statusCode: 400, + before: () => rte.data.generateKeys(true), + }, + { + name: 'Should return NotFound if key does not exist', + data: { keyName: constants.getRandomString() }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return NotFound if instance id does not exist', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { keyName: constants.getRandomString() }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + const aclEndpoint = () => endpoint(constants.TEST_INSTANCE_ACL_ID); + const aclKey = constants.getRandomString(); + + [ + { + name: 'Should return next index for an authorised user', + endpoint: aclEndpoint, + data: { keyName: aclKey }, + responseSchema, + before: async () => { + await rte.data.setAclUserRules('~* +@all'); + await rte.client.call('ARSET', aclKey, '0', 'x'); + }, + }, + { + name: 'Should throw error if no permissions for "arnext" command', + endpoint: aclEndpoint, + data: { keyName: aclKey }, + statusCode: 403, + responseBody: { statusCode: 403, error: 'Forbidden' }, + // beforeEach() wipes the key between tests; reseed via the root + // client (ACL rules below only affect the API request). + before: async () => { + await rte.client.call('ARSET', aclKey, '0', 'x'); + await rte.data.setAclUserRules('~* +@all -arnext'); + }, + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/array/POST-databases-id-array-get_range.test.ts b/redisinsight/api/test/api/array/POST-databases-id-array-get_range.test.ts new file mode 100644 index 0000000000..62cc9b01d2 --- /dev/null +++ b/redisinsight/api/test/api/array/POST-databases-id-array-get_range.test.ts @@ -0,0 +1,323 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall, + getMainCheckFn, + JoiRedisString, +} from '../deps'; + +const { server, request, constants } = deps; +const rte = deps.rte as any; + +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post( + `/${constants.API.DATABASES}/${instanceId}/array/get-range`, + ); + +// `start` and `end` are validated by @IsArrayIndex on the API side, which +// emits a single combined message (" must be an integer string between +// 0 and 18446744073709551614") for any non-canonical input. Override the +// per-rule Joi messages with a label-less substring of the API output so the +// harness's substring-contains check passes. +const ARRAY_INDEX_MSG = 'must be an integer string between'; + +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + start: Joi.string().required().messages({ + 'string.base': ARRAY_INDEX_MSG, + 'any.required': ARRAY_INDEX_MSG, + }), + end: Joi.string().required().messages({ + 'string.base': ARRAY_INDEX_MSG, + 'any.required': ARRAY_INDEX_MSG, + }), +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + start: '0', + end: '5', +}; + +const responseSchema = Joi.object() + .keys({ + keyName: JoiRedisString.required(), + elements: Joi.array().items(JoiRedisString.allow(null)).required(), + }) + .required(); + +const mainCheckFn = getMainCheckFn(endpoint); + +const seedSparse = (key: string) => + rte.client.call('ARMSET', key, '0', '20.1', '1', '20.4', '5', '21.4'); + +describe('POST /databases/:instanceId/array/get-range', () => { + requirements('rte.version>=8.8'); + beforeEach(async () => rte.data.truncate()); + + describe('Validation', () => { + generateInvalidDataTestCases(dataSchema, validInputData).map( + validateInvalidDataTestCase(endpoint, dataSchema), + ); + + [ + { + name: 'Should reject a non-decimal start index', + data: { keyName: constants.getRandomString(), start: 'abc', end: '5' }, + statusCode: 400, + }, + { + name: 'Should reject a non-decimal end index', + data: { keyName: constants.getRandomString(), start: '0', end: 'abc' }, + statusCode: 400, + }, + { + name: 'Should reject a range exceeding 1,000,000 elements', + // span = end - start + 1 = 1_000_001 → just over the hard cap. + data: { + keyName: constants.getRandomString(), + start: '0', + end: '1000000', + }, + statusCode: 400, + checkFn: ({ body }: any) => { + expect(body.message).to.have.string('1 000 000'); + }, + }, + { + name: 'Should reject a reversed range exceeding the 1,000,000 cap', + // |end - start| + 1 = 1_000_001 → cap is direction-agnostic. + data: { + keyName: constants.getRandomString(), + start: '1000000', + end: '0', + }, + statusCode: 400, + checkFn: ({ body }: any) => { + expect(body.message).to.have.string('1 000 000'); + }, + }, + { + // 2^64-1 is reserved by Redis as the "no-index" sentinel; the API + // validator rejects it before the request reaches Redis. + name: 'Should reject when end equals the reserved 2^64-1 sentinel', + data: { + keyName: constants.getRandomString(), + start: '0', + end: '18446744073709551615', + }, + statusCode: 400, + }, + ].map(mainCheckFn); + }); + + describe('Main', () => { + it('Should return dense values in order with no nulls', async () => { + const keyName = constants.getRandomString(); + await rte.client.call('ARSET', keyName, '0', 'a', 'b', 'c'); + + await validateApiCall({ + endpoint, + data: { keyName, start: '0', end: '2' }, + responseSchema, + responseBody: { keyName, elements: ['a', 'b', 'c'] }, + }); + }); + + it('Should preserve gaps as null across a sparse range', async () => { + const keyName = constants.getRandomString(); + await seedSparse(keyName); + + // Indexes 0,1,5 populated; 2,3,4 empty. Response must keep position. + await validateApiCall({ + endpoint, + data: { keyName, start: '0', end: '5' }, + responseSchema, + responseBody: { + keyName, + elements: ['20.1', '20.4', null, null, null, '21.4'], + }, + }); + }); + + it('Should return elements in reverse index order when start > end', async () => { + const keyName = constants.getRandomString(); + await seedSparse(keyName); + + // Same sparse fixture as above; reversing the bounds reverses the + // returned positions including the nulls for empty slots. + await validateApiCall({ + endpoint, + data: { keyName, start: '5', end: '0' }, + responseSchema, + responseBody: { + keyName, + elements: ['21.4', null, null, null, '20.4', '20.1'], + }, + }); + }); + + it('Should return a single element when start equals end', async () => { + const keyName = constants.getRandomString(); + await seedSparse(keyName); + + await validateApiCall({ + endpoint, + data: { keyName, start: '1', end: '1' }, + responseSchema, + responseBody: { keyName, elements: ['20.4'] }, + }); + }); + + it('Should accept a span of exactly 1,000,000 elements', async () => { + const keyName = constants.getRandomString(); + await rte.client.call('ARSET', keyName, '0', 'x'); + + // Boundary case: span = end - start + 1 = 1_000_000 → just within cap. + await validateApiCall({ + endpoint, + data: { keyName, start: '0', end: '999999' }, + responseSchema, + checkFn: ({ body }: any) => { + expect(body.elements).to.have.length(1_000_000); + expect(body.elements[0]).to.eql('x'); + expect(body.elements[999_999]).to.eql(null); + }, + }); + }); + + it('Should return a one-element window at the maximum valid index (2^64-2)', async () => { + const keyName = constants.getRandomString(); + const maxIndex = '18446744073709551614'; + + // Pre-seed at the boundary; ARGETRANGE [max, max] is span = 1 (within + // cap) and proves the validator allows the upper edge of u64. + await rte.client.call('ARSET', keyName, maxIndex, 'edge'); + + await validateApiCall({ + endpoint, + data: { keyName, start: maxIndex, end: maxIndex }, + responseSchema, + responseBody: { keyName, elements: ['edge'] }, + }); + }); + + it('Should fill an entirely-past-end range with nulls', async () => { + const keyName = constants.getRandomString(); + await seedSparse(keyName); + + // Sparse length is 6; range 10..12 is entirely past the end. ARGETRANGE + // returns a fully-null window rather than 404 — the key still exists. + await validateApiCall({ + endpoint, + data: { keyName, start: '10', end: '12' }, + responseSchema, + responseBody: { keyName, elements: [null, null, null] }, + }); + }); + + it('Should serialize populated values as Buffer objects with null preserved when encoding=buffer', async () => { + const keyName = constants.getRandomString(); + await seedSparse(keyName); + + // Locks in the RedisStringToBufferTransformer null-passthrough fix: + // populated → {type:'Buffer', data}; gap → JSON null. + await validateApiCall({ + endpoint, + query: { encoding: 'buffer' }, + data: { keyName, start: '0', end: '2' }, + responseSchema, + checkFn: ({ body }: any) => { + expect(body.elements).to.eql([ + { type: 'Buffer', data: [...Buffer.from('20.1')] }, + { type: 'Buffer', data: [...Buffer.from('20.4')] }, + null, + ]); + }, + }); + }); + + [ + { + name: 'Should return BadRequest if key holds a non-array type', + data: { + keyName: constants.TEST_STRING_KEY_1, + start: '0', + end: '5', + }, + statusCode: 400, + before: () => rte.data.generateKeys(true), + }, + { + name: 'Should return NotFound if key does not exist', + data: { + keyName: constants.getRandomString(), + start: '0', + end: '5', + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return NotFound if instance id does not exist', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.getRandomString(), + start: '0', + end: '5', + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + const aclEndpoint = () => endpoint(constants.TEST_INSTANCE_ACL_ID); + const aclKey = constants.getRandomString(); + + [ + { + name: 'Should return range for an authorised user', + endpoint: aclEndpoint, + data: { keyName: aclKey, start: '0', end: '0' }, + responseSchema, + before: async () => { + await rte.data.setAclUserRules('~* +@all'); + await rte.client.call('ARSET', aclKey, '0', 'x'); + }, + }, + { + name: 'Should throw error if no permissions for "argetrange" command', + endpoint: aclEndpoint, + data: { keyName: aclKey, start: '0', end: '0' }, + statusCode: 403, + responseBody: { statusCode: 403, error: 'Forbidden' }, + // beforeEach() wipes the key between tests; reseed via the root + // client (ACL rules below only affect the API request). + before: async () => { + await rte.client.call('ARSET', aclKey, '0', 'x'); + await rte.data.setAclUserRules('~* +@all -argetrange'); + }, + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/array/POST-databases-id-array-scan.test.ts b/redisinsight/api/test/api/array/POST-databases-id-array-scan.test.ts new file mode 100644 index 0000000000..12ed840dfe --- /dev/null +++ b/redisinsight/api/test/api/array/POST-databases-id-array-scan.test.ts @@ -0,0 +1,351 @@ +import { + expect, + describe, + it, + before, + deps, + Joi, + requirements, + generateInvalidDataTestCases, + validateInvalidDataTestCase, + validateApiCall, + getMainCheckFn, + JoiRedisString, +} from '../deps'; + +const { server, request, constants } = deps; +const rte = deps.rte as any; + +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post(`/${constants.API.DATABASES}/${instanceId}/array/scan`); + +// `start` and `end` are validated by @IsArrayIndex on the API side, which +// emits a single combined message (" must be an integer string between +// 0 and 18446744073709551614") for any non-canonical input. Override the +// per-rule Joi messages with a label-less substring of the API output so the +// harness's substring-contains check passes. +const ARRAY_INDEX_MSG = 'must be an integer string between'; + +const dataSchema = Joi.object({ + keyName: Joi.string().allow('').required(), + start: Joi.string().required().messages({ + 'string.base': ARRAY_INDEX_MSG, + 'any.required': ARRAY_INDEX_MSG, + }), + end: Joi.string().required().messages({ + 'string.base': ARRAY_INDEX_MSG, + 'any.required': ARRAY_INDEX_MSG, + }), + // BE accepts an explicit null (treated as omitted), so model that here so + // generateInvalidDataTestCases doesn't synthesise a false-positive case. + limit: Joi.number().integer().min(1).allow(null).optional(), +}).strict(); + +const validInputData = { + keyName: constants.getRandomString(), + start: '0', + end: '5', +}; + +// Each pair: index is a decimal u64-as-string; value is a redis-string fill. +const elementSchema = Joi.object().keys({ + index: Joi.string().pattern(/^\d+$/).required(), + value: JoiRedisString.required(), +}); + +const responseSchema = Joi.object() + .keys({ + keyName: JoiRedisString.required(), + elements: Joi.array().items(elementSchema).required(), + }) + .required(); + +const mainCheckFn = getMainCheckFn(endpoint); + +const seedSparse = (key: string) => + rte.client.call('ARMSET', key, '0', '20.1', '1', '20.4', '5', '21.4'); + +describe('POST /databases/:instanceId/array/scan', () => { + requirements('rte.version>=8.8'); + beforeEach(async () => rte.data.truncate()); + + describe('Validation', () => { + // `limit: true` is dropped: class-transformer coerces booleans through + // `@Type(() => Number)` (true -> 1), so it bypasses @IsInt/@Min and the + // request reaches the controller as a valid limit=1 instead of failing + // with 400. Tracked as a separate DTO hardening task. + generateInvalidDataTestCases(dataSchema, validInputData) + .filter((c) => !(c.data?.limit === true)) + .map(validateInvalidDataTestCase(endpoint, dataSchema)); + + [ + { + // 2^64-1 is reserved by Redis as the "no-index" sentinel; the API + // validator rejects it before the request reaches Redis. + name: 'Should reject when end equals the reserved 2^64-1 sentinel', + data: { + keyName: constants.getRandomString(), + start: '0', + end: '18446744073709551615', + }, + statusCode: 400, + }, + { + // DTO contract: @IsOptional + @IsInt + @Min(1). limit:0 must fail + // validation before it can become a no-op `LIMIT 0` at Redis. + name: 'Should reject limit: 0 (below @Min(1))', + data: { + keyName: constants.getRandomString(), + start: '0', + end: '5', + limit: 0, + }, + statusCode: 400, + }, + { + // DTO contract: @Max(ARRAY_RANGE_MAX_ELEMENTS). The DTO caps the + // result-set size on the LIMIT param (not on the |end-start| span) + // — the span itself is intentionally uncapped so sparse scans over + // huge index gaps still work. + name: 'Should reject limit exceeding 1,000,000 (above @Max)', + data: { + keyName: constants.getRandomString(), + start: '0', + end: '5', + limit: 1000001, + }, + statusCode: 400, + }, + ].map(mainCheckFn); + }); + + describe('Main', () => { + it('Should return only populated {index, value} pairs in ascending order', async () => { + const keyName = constants.getRandomString(); + await seedSparse(keyName); + + // Sparse fixture has indexes 0,1,5 populated; gaps must be skipped. + // Indexes arrive as decimal strings (u64 contract). + await validateApiCall({ + endpoint, + data: { keyName, start: '0', end: '6' }, + responseSchema, + responseBody: { + keyName, + elements: [ + { index: '0', value: '20.1' }, + { index: '1', value: '20.4' }, + { index: '5', value: '21.4' }, + ], + }, + }); + }); + + it('Should return pairs in reverse index order when start > end', async () => { + const keyName = constants.getRandomString(); + await seedSparse(keyName); + + await validateApiCall({ + endpoint, + data: { keyName, start: '6', end: '0' }, + responseSchema, + responseBody: { + keyName, + elements: [ + { index: '5', value: '21.4' }, + { index: '1', value: '20.4' }, + { index: '0', value: '20.1' }, + ], + }, + }); + }); + + it('Should round-trip the index when scanning at the maximum valid index (2^64-2)', async () => { + const keyName = constants.getRandomString(); + const maxIndex = '18446744073709551614'; + + // Redis 8.8 returns u64 values ≥ 2^63 as RESP bulk strings (not RESP + // integers), so ARSCAN's `index` field survives the wire intact for + // values in this range. Locks in the contract that the API surfaces + // the index as a decimal string even at the upper edge of u64. + await rte.client.call('ARSET', keyName, maxIndex, 'edge'); + + await validateApiCall({ + endpoint, + data: { keyName, start: maxIndex, end: maxIndex }, + responseSchema, + responseBody: { + keyName, + elements: [{ index: maxIndex, value: 'edge' }], + }, + }); + }); + + it('Should accept explicit limit:null as if it were omitted', async () => { + const keyName = constants.getRandomString(); + await seedSparse(keyName); + + // Regression guard for the DTO's @IsOptional() + service-level + // `typeof limit === 'number'` check: a JSON null in the body must + // NOT be forwarded as `LIMIT null` (would surface as 500). + await validateApiCall({ + endpoint, + data: { keyName, start: '0', end: '6', limit: null }, + responseSchema, + checkFn: ({ body }: any) => { + expect(body.elements).to.have.length(3); + }, + }); + }); + + it('Should cap the result set when limit is provided', async () => { + const keyName = constants.getRandomString(); + await seedSparse(keyName); + + await validateApiCall({ + endpoint, + data: { keyName, start: '0', end: '6', limit: 2 }, + responseSchema, + responseBody: { + keyName, + elements: [ + { index: '0', value: '20.1' }, + { index: '1', value: '20.4' }, + ], + }, + }); + }); + + it('Should accept a sparse scan over a span far larger than 1,000,000', async () => { + const keyName = constants.getRandomString(); + // Two values placed 10M indexes apart: this would fail under any + // |end-start| cap, but ARSCAN is intentionally exempt — it skips + // empty slots server-side and LIMIT (capped on the DTO) is the + // result-set backpressure. Pins that no span cap regressed into + // scan(); ARGETRANGE retains its dense-reply cap. + await rte.client.call('ARMSET', keyName, '0', 'lo', '10000000', 'hi'); + + await validateApiCall({ + endpoint, + data: { keyName, start: '0', end: '10000000' }, + responseSchema, + responseBody: { + keyName, + elements: [ + { index: '0', value: 'lo' }, + { index: '10000000', value: 'hi' }, + ], + }, + }); + }); + + it('Should return an empty list when the range covers only empty slots', async () => { + const keyName = constants.getRandomString(); + await seedSparse(keyName); + + // Indexes 2..4 are all gaps in the seeded fixture. + await validateApiCall({ + endpoint, + data: { keyName, start: '2', end: '4' }, + responseSchema, + responseBody: { keyName, elements: [] }, + }); + }); + + it('Should serialize values as Buffer objects when encoding=buffer', async () => { + const keyName = constants.getRandomString(); + await seedSparse(keyName); + + await validateApiCall({ + endpoint, + query: { encoding: 'buffer' }, + data: { keyName, start: '0', end: '1' }, + responseSchema, + checkFn: ({ body }: any) => { + expect(body.elements).to.have.length(2); + expect(body.elements[0].index).to.eql('0'); + expect(body.elements[0].value).to.eql({ + type: 'Buffer', + data: [...Buffer.from('20.1')], + }); + }, + }); + }); + + [ + { + name: 'Should return BadRequest if key holds a non-array type', + data: { + keyName: constants.TEST_STRING_KEY_1, + start: '0', + end: '5', + }, + statusCode: 400, + before: () => rte.data.generateKeys(true), + }, + { + name: 'Should return NotFound if key does not exist', + data: { + keyName: constants.getRandomString(), + start: '0', + end: '5', + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Key with this name does not exist.', + }, + }, + { + name: 'Should return NotFound if instance id does not exist', + endpoint: () => endpoint(constants.TEST_NOT_EXISTED_INSTANCE_ID), + data: { + keyName: constants.getRandomString(), + start: '0', + end: '5', + }, + statusCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: 'Invalid database instance id.', + }, + }, + ].map(mainCheckFn); + }); + + describe('ACL', () => { + requirements('rte.acl'); + before(async () => rte.data.setAclUserRules('~* +@all')); + + const aclEndpoint = () => endpoint(constants.TEST_INSTANCE_ACL_ID); + const aclKey = constants.getRandomString(); + + [ + { + name: 'Should return scan results for an authorised user', + endpoint: aclEndpoint, + data: { keyName: aclKey, start: '0', end: '0' }, + responseSchema, + before: async () => { + await rte.data.setAclUserRules('~* +@all'); + await rte.client.call('ARSET', aclKey, '0', 'x'); + }, + }, + { + name: 'Should throw error if no permissions for "arscan" command', + endpoint: aclEndpoint, + data: { keyName: aclKey, start: '0', end: '0' }, + statusCode: 403, + responseBody: { statusCode: 403, error: 'Forbidden' }, + // beforeEach() wipes the key between tests; reseed via the root + // client (ACL rules below only affect the API request). + before: async () => { + await rte.client.call('ARSET', aclKey, '0', 'x'); + await rte.data.setAclUserRules('~* +@all -arscan'); + }, + }, + ].map(mainCheckFn); + }); +}); diff --git a/redisinsight/api/test/api/array/POST-databases-id-array.test.ts b/redisinsight/api/test/api/array/POST-databases-id-array.test.ts index 3eed1a53dd..220d593318 100644 --- a/redisinsight/api/test/api/array/POST-databases-id-array.test.ts +++ b/redisinsight/api/test/api/array/POST-databases-id-array.test.ts @@ -120,6 +120,7 @@ describe('POST /databases/:id/array', () => { describe('Sparse (ARMSET)', () => { const sparseKey = constants.getRandomString(); const sparseTtlKey = constants.getRandomString(); + const dupIndexKey = constants.getRandomString(); [ { @@ -157,6 +158,25 @@ describe('POST /databases/:id/array', () => { expect(await rte.client.ttl(sparseTtlKey)).to.gte(95); }, }, + { + // Pin "last write wins" for duplicate indexes — ARMSET accepts the + // duplicate server-side and the trailing value overwrites the earlier + // one. Count stays 1 because only one slot ends up populated. + name: 'Should accept a duplicate index and keep the last value (sparse, last wins)', + data: { + keyName: dupIndexKey, + mode: ArrayCreationMode.Sparse, + elements: [ + { index: '5', value: 'first' }, + { index: '5', value: 'second' }, + ], + }, + statusCode: 201, + after: async () => { + expect(await arget(dupIndexKey, '5')).to.eql('second'); + expect(await arcount(dupIndexKey)).to.eql(1); + }, + }, ].map(createCheckFn); }); @@ -181,6 +201,17 @@ describe('POST /databases/:id/array', () => { }, statusCode: 400, }, + { + // 2^64-1 is reserved by Redis as the "no-index" sentinel — ARSET / + // ARMSET reject it server-side, so the API validator must too. + name: 'Should reject the reserved 2^64-1 sentinel index', + data: { + keyName: constants.getRandomString(), + mode: ArrayCreationMode.Sparse, + elements: [{ index: '18446744073709551615', value: 'x' }], + }, + statusCode: 400, + }, { name: 'Should reject contiguous mode with empty values', data: { @@ -191,6 +222,17 @@ describe('POST /databases/:id/array', () => { }, statusCode: 400, }, + { + // Symmetric @ArrayMinSize(1) guard for the sparse path — without it, + // we'd issue ARMSET with no pairs and surface the server error. + name: 'Should reject sparse mode with an empty elements array', + data: { + keyName: constants.getRandomString(), + mode: ArrayCreationMode.Sparse, + elements: [], + }, + statusCode: 400, + }, { name: 'Should reject an unknown mode', data: { diff --git a/redisinsight/api/test/api/array/POST-databases-id-keys-get_info-array.test.ts b/redisinsight/api/test/api/array/POST-databases-id-keys-get_info-array.test.ts new file mode 100644 index 0000000000..2c95a0197a --- /dev/null +++ b/redisinsight/api/test/api/array/POST-databases-id-keys-get_info-array.test.ts @@ -0,0 +1,77 @@ +import { + describe, + before, + deps, + Joi, + requirements, + getMainCheckFn, + JoiRedisString, +} from '../deps'; + +const { server, request, constants } = deps; +const rte = deps.rte as any; + +// Targets the shared /keys/get-info endpoint but lives in test/api/array so +// the new oss-st-8 RTE (TEST_TAGS=array) loads it. The matching Array branch +// in test/api/keys/POST-databases-id-keys-get_info.test.ts was removed to +// avoid duplicate runs on untagged RTEs. +const endpoint = (instanceId = constants.TEST_INSTANCE_ID) => + request(server).post( + `/${constants.API.DATABASES}/${instanceId}/keys/get-info`, + ); + +// Decimal-string contract: ARLEN / ARCOUNT can exceed Number.MAX_SAFE_INTEGER +// for sparse arrays, so the response surfaces them as strings — never numbers. +const responseSchema = Joi.object() + .keys({ + name: JoiRedisString.required(), + type: Joi.string().valid('array').required(), + ttl: Joi.number().integer().allow(null).optional(), + size: Joi.number().integer().allow(null).optional(), + length: Joi.string().pattern(/^\d+$/).required(), + count: Joi.string().pattern(/^\d+$/).required(), + }) + .required(); + +const mainCheckFn = getMainCheckFn(endpoint); + +// Exercises the GetArrayKeyInfoResponse branch of the keys/get-info `oneOf` +// response and the ArrayKeyInfoStrategy. The dense vs sparse divergence is +// the whole reason the strategy issues both ARLEN and ARCOUNT. +describe('POST /databases/:instanceId/keys/get-info (Array)', () => { + requirements('rte.version>=8.8'); + + const denseKey = constants.getRandomString(); + const sparseKey = constants.getRandomString(); + + before(async () => { + await rte.client.call('ARSET', denseKey, '0', 'a', 'b', 'c'); + // Sparse: indexes 0,5 populated → length=6, count=2. + await rte.client.call('ARMSET', sparseKey, '0', 'v0', '5', 'v5'); + }); + + [ + { + name: 'Should return array info with length === count for a dense array', + data: { keyName: denseKey }, + responseSchema, + responseBody: { + name: denseKey, + type: 'array', + length: '3', + count: '3', + }, + }, + { + name: 'Should return array info with length !== count for a sparse array', + data: { keyName: sparseKey }, + responseSchema, + responseBody: { + name: sparseKey, + type: 'array', + length: '6', + count: '2', + }, + }, + ].map(mainCheckFn); +}); diff --git a/redisinsight/api/test/api/keys/POST-databases-id-keys-get_info.test.ts b/redisinsight/api/test/api/keys/POST-databases-id-keys-get_info.test.ts index 075f409e1e..418b9e5af4 100644 --- a/redisinsight/api/test/api/keys/POST-databases-id-keys-get_info.test.ts +++ b/redisinsight/api/test/api/keys/POST-databases-id-keys-get_info.test.ts @@ -226,6 +226,10 @@ describe('POST /databases/:instanceId/keys/get-info', () => { }, ].map(mainCheckFn); }); + + // Array coverage for this endpoint lives in + // test/api/array/POST-databases-id-keys-get_info-array.test.ts so it is + // picked up by the array-tagged spec set on the Redis 8.8 RTE. }); describe('ACL', () => { From 6704543126dd0731a8bfa2354bc06d20d6781aba Mon Sep 17 00:00:00 2001 From: Pavel Angelov Date: Thu, 18 Jun 2026 13:35:21 +0300 Subject: [PATCH 2/2] remove leftover --- .../api/test/api/keys/POST-databases-id-keys-get_info.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/redisinsight/api/test/api/keys/POST-databases-id-keys-get_info.test.ts b/redisinsight/api/test/api/keys/POST-databases-id-keys-get_info.test.ts index 418b9e5af4..075f409e1e 100644 --- a/redisinsight/api/test/api/keys/POST-databases-id-keys-get_info.test.ts +++ b/redisinsight/api/test/api/keys/POST-databases-id-keys-get_info.test.ts @@ -226,10 +226,6 @@ describe('POST /databases/:instanceId/keys/get-info', () => { }, ].map(mainCheckFn); }); - - // Array coverage for this endpoint lives in - // test/api/array/POST-databases-id-keys-get_info-array.test.ts so it is - // picked up by the array-tagged spec set on the Redis 8.8 RTE. }); describe('ACL', () => {