diff --git a/foreign/node/src/e2e/tcp.user.e2e.ts b/foreign/node/src/e2e/tcp.user.e2e.ts index 67654bcb69..e7268e8d16 100644 --- a/foreign/node/src/e2e/tcp.user.e2e.ts +++ b/foreign/node/src/e2e/tcp.user.e2e.ts @@ -41,7 +41,30 @@ describe('e2e -> user', async () => { PollMessages: true, SendMessages: true }, - streams: [] + streams: [ + { + streamId: 1, + permissions: { + manageStream: true, + readStream: true, + manageTopics: true, + readTopics: true, + pollMessages: true, + sendMessages: true + }, + topics: [ + { + topicId: 1, + permissions: { + manage: true, + read: true, + pollMessages: true, + sendMessages: true + } + } + ] + } + ] }; const cUser = { userId, username, password, status, permissions }; @@ -49,6 +72,7 @@ describe('e2e -> user', async () => { it('e2e -> user::create', async () => { const user = await c.user.create(cUser); assert.ok(user); + assert.deepEqual(user.permissions, permissions); }); it('e2e -> user::list', async () => { @@ -89,12 +113,20 @@ describe('e2e -> user', async () => { it('e2e -> user::updatePermissions', async () => { const user = await c.user.get({ userId: username }); assert.ok(user); - const perms2 = { ...permissions }; - perms2.global.ReadServers = true; + const perms2 = { + ...permissions, + global: { + ...permissions.global, + ReadServers: true + } + }; const u2 = await c.user.updatePermissions({ userId: user.id, permissions: perms2 }); assert.ok(u2); + + const updatedUser = await c.user.get({ userId: user.id }); + assert.deepEqual(updatedUser?.permissions, perms2); }); it('e2e -> user::delete', async () => { diff --git a/foreign/node/src/wire/user/create-user.command.test.ts b/foreign/node/src/wire/user/create-user.command.test.ts index d135fb93b7..19b94df8a1 100644 --- a/foreign/node/src/wire/user/create-user.command.test.ts +++ b/foreign/node/src/wire/user/create-user.command.test.ts @@ -19,24 +19,49 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { CREATE_USER } from './create-user.command.js'; +import type { UserPermissions } from './permissions.utils.js'; describe('CreateUser', () => { describe('serialize', () => { const u1 = { - id: 1, username: 'test-user', password: 'test-pwd', status: 1, // Active, - // perms: undefined // @TODO }; - it('serialize username, password, status, permissions into buffer', () => { - assert.deepEqual( - CREATE_USER.serialize(u1).length, - 1 + u1.username.length + 1 + u1.password.length + 1 + 1 + 4 + 1 - ); + const baseLength = 1 + Buffer.byteLength(u1.username) + + 1 + Buffer.byteLength(u1.password) + 1; + + it('serialize without permissions', () => { + const serialized = CREATE_USER.serialize(u1); + assert.equal(serialized.length, baseLength + 1); + assert.equal(serialized.readUInt8(baseLength), 0); + }); + + it('serialize with permissions', () => { + const permissions: UserPermissions = { + global: { + ManageServers: false, + ReadServers: false, + ManageUsers: false, + ReadUsers: false, + ManageStreams: false, + ReadStreams: false, + ManageTopics: false, + ReadTopics: false, + PollMessages: false, + SendMessages: false + }, + streams: [] + }; + const serialized = CREATE_USER.serialize({ ...u1, permissions }); + + assert.equal(serialized.length, baseLength + 1 + 4 + 11); + assert.equal(serialized.readUInt8(baseLength), 1); + assert.equal(serialized.readUInt32LE(baseLength + 1), 11); + assert.deepEqual(serialized.subarray(baseLength + 5), Buffer.alloc(11)); }); it('throw on username < 1', () => { @@ -83,4 +108,3 @@ describe('CreateUser', () => { }); }); - diff --git a/foreign/node/src/wire/user/create-user.command.ts b/foreign/node/src/wire/user/create-user.command.ts index bdade00db3..fb907170d6 100644 --- a/foreign/node/src/wire/user/create-user.command.ts +++ b/foreign/node/src/wire/user/create-user.command.ts @@ -55,15 +55,21 @@ export const CREATE_USER = { if (bPassword.length < 1 || bPassword.length > 255) throw new Error('User password should be between 1 and 255 bytes'); - const bPermissions = serializePermissions(permissions); - - return Buffer.concat([ + const bUser = Buffer.concat([ uint8ToBuf(bUsername.length), bUsername, uint8ToBuf(bPassword.length), bPassword, uint8ToBuf(status), - boolToBuf(!!permissions), + ]); + + if (!permissions) + return Buffer.concat([bUser, boolToBuf(false)]); + + const bPermissions = serializePermissions(permissions); + return Buffer.concat([ + bUser, + boolToBuf(true), uint32ToBuf(bPermissions.length), bPermissions ]); diff --git a/foreign/node/src/wire/user/permissions.utils.test.ts b/foreign/node/src/wire/user/permissions.utils.test.ts index b30dc7a23a..d0eea0948b 100644 --- a/foreign/node/src/wire/user/permissions.utils.test.ts +++ b/foreign/node/src/wire/user/permissions.utils.test.ts @@ -18,30 +18,110 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { serializePermissions, deserializePermissions } from './permissions.utils.js'; +import { + deserializePermissions, + serializePermissions, + type UserPermissions +} from './permissions.utils.js'; describe('Permissions', () => { - const permissions = { - global: { - ManageServers: true, - ReadServers: true, - ManageUsers: true, - ReadUsers: true, - ManageStreams: true, - ReadStreams: true, - ManageTopics: true, - ReadTopics: true, - PollMessages: true, - SendMessages: true - }, - streams: [] + const globalPermissions = { + ManageServers: true, + ReadServers: true, + ManageUsers: true, + ReadUsers: true, + ManageStreams: true, + ReadStreams: true, + ManageTopics: true, + ReadTopics: true, + PollMessages: true, + SendMessages: true }; - it('serialize/deserialize', () => { - const s = serializePermissions(permissions); - const d = deserializePermissions(s); - assert.deepEqual(permissions, d); + const globalOnlyPermissions = { + global: globalPermissions, + streams: [] + } satisfies UserPermissions; + + const scopedPermissions = { + global: globalPermissions, + streams: [ + { + streamId: 1, + permissions: { + manageStream: true, + readStream: false, + manageTopics: true, + readTopics: false, + pollMessages: true, + sendMessages: false + }, + topics: [ + { + topicId: 10, + permissions: { + manage: true, + read: false, + pollMessages: true, + sendMessages: false + } + }, + { + topicId: 20, + permissions: { + manage: false, + read: true, + pollMessages: false, + sendMessages: true + } + } + ] + }, + { + streamId: 2, + permissions: { + manageStream: false, + readStream: true, + manageTopics: false, + readTopics: true, + pollMessages: false, + sendMessages: true + }, + topics: [] + } + ] + } satisfies UserPermissions; + + const serializedScopedPermissions = Buffer.from([ + // Global permissions and has_streams. + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + // Stream 1, its permissions, and has_topics. + 1, 0, 0, 0, 1, 0, 1, 0, 1, 0, 1, + // Topic 10 and has_next_topic. + 10, 0, 0, 0, 1, 0, 1, 0, 1, + // Topic 20 and has_next_topic. + 20, 0, 0, 0, 0, 1, 0, 1, 0, + // has_next_stream. + 1, + // Stream 2, its permissions, has_topics, and has_next_stream. + 2, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0 + ]); + + it('round-trips global permissions', () => { + const serialized = serializePermissions(globalOnlyPermissions); + const deserialized = deserializePermissions(serialized); + assert.deepEqual(deserialized, globalOnlyPermissions); + }); + + it('serializes stream and topic continuation markers', () => { + const serialized = serializePermissions(scopedPermissions); + assert.deepEqual(serialized, serializedScopedPermissions); + }); + + it('round-trips multiple streams and topics', () => { + const deserialized = deserializePermissions(serializedScopedPermissions); + assert.deepEqual(deserialized, scopedPermissions); }); }); diff --git a/foreign/node/src/wire/user/permissions.utils.ts b/foreign/node/src/wire/user/permissions.utils.ts index 0fd288dc3f..472f85f7e7 100644 --- a/foreign/node/src/wire/user/permissions.utils.ts +++ b/foreign/node/src/wire/user/permissions.utils.ts @@ -250,19 +250,18 @@ export const deserializeStreamPermissions = } pos += 10; - const topics = []; + const topics: TopicPerms[] = []; const hasTopics = toBool(p.readUInt8(pos)); + pos += 1; if (hasTopics) { - let read = true; - pos += 1; - while (read) { + let hasNextTopic = true; + while (hasNextTopic) { const { bytesRead, topicId, permissions } = deserializeTopicPermissions(p, pos); pos += bytesRead; topics.push({ topicId, permissions }); - - if (p.readUInt8(pos) === 0) - read = false; // break + hasNextTopic = toBool(p.readUInt8(pos)); + pos += 1; } } @@ -292,17 +291,20 @@ export const serializeStreamPermissions = (p: StreamPerms) => { uint8ToBuf(boolToByte(p.permissions.sendMessages)), ]); - const hasTopic = p.topics.length > 0; - const bHasTopic = uint8ToBuf(boolToByte(hasTopic)); - const bHead = Buffer.concat([bStream, bHasTopic]); + const hasTopics = p.topics.length > 0; + const bHasTopics = uint8ToBuf(boolToByte(hasTopics)); + const bHead = Buffer.concat([bStream, bHasTopics]); - if (!hasTopic) + if (!hasTopics) return bHead; - return p.topics.reduce((ac, c) => Buffer.concat([ - ac, serializeTopicPermissions(c) - ]), bHead); -} + const bTopics = p.topics.map((topic, index) => Buffer.concat([ + serializeTopicPermissions(topic), + uint8ToBuf(boolToByte(index < p.topics.length - 1)) + ])); + + return Buffer.concat([bHead, ...bTopics]); +}; /** * Deserializes complete user permissions from a buffer. @@ -316,20 +318,20 @@ export const deserializePermissions = (p: Buffer, pos = 0): UserPermissions => { const { bytesRead, data } = deserializeGlobalPermissions(p, pos); pos += bytesRead; - const streams = []; - const hasStream = toBool(p.readUInt8(pos)); + const streams: StreamPerms[] = []; + const hasStreams = toBool(p.readUInt8(pos)); + pos += 1; - if (hasStream) { - let readStream = true; - pos += 1; - while (readStream) { + if (hasStreams) { + let hasNextStream = true; + while (hasNextStream) { const { bytesRead, streamId, permissions, topics } = deserializeStreamPermissions(p, pos); streams.push({ streamId, permissions, topics }); pos += bytesRead; - if (p.readUInt8(pos) === 0) - readStream = false; // break + hasNextStream = toBool(p.readUInt8(pos)); + pos += 1; } } return { @@ -340,24 +342,23 @@ export const deserializePermissions = (p: Buffer, pos = 0): UserPermissions => { /** * Serializes user permissions to a buffer. - * Returns a single zero byte if no permissions provided. * - * @param p - Optional user permissions to serialize + * @param p - User permissions to serialize * @returns Serialized permissions buffer */ -export const serializePermissions = (p?: UserPermissions) => { - if (!p) - return uint8ToBuf(0); - +export const serializePermissions = (p: UserPermissions) => { const bGlobal = serializeGlobalPermission(p.global); - const hasStream = p.streams.length > 0; - const bHasStream = uint8ToBuf(boolToByte(hasStream)); - const bHead = Buffer.concat([bGlobal, bHasStream]); + const hasStreams = p.streams.length > 0; + const bHasStreams = uint8ToBuf(boolToByte(hasStreams)); + const bHead = Buffer.concat([bGlobal, bHasStreams]); - if (!hasStream) + if (!hasStreams) return bHead; - return p.streams.reduce((ac, c) => Buffer.concat([ - ac, serializeStreamPermissions(c) - ]), bHead); + const bStreams = p.streams.map((stream, index) => Buffer.concat([ + serializeStreamPermissions(stream), + uint8ToBuf(boolToByte(index < p.streams.length - 1)) + ])); + + return Buffer.concat([bHead, ...bStreams]); };