From cc1714d96168a5c25aeb2228f6fabc0bb63c6625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gergely=20B=C3=A9k=C3=A9si?= Date: Wed, 27 May 2026 14:57:15 +0200 Subject: [PATCH] feat: add grant command for access group --- src/command/access/grant.ts | 70 +++++++++++++++++++++++++++++++++++++ src/command/access/index.ts | 3 +- src/service/access/index.ts | 8 +++-- src/utils/text.ts | 4 +++ test/command/access.spec.ts | 70 +++++++++++++++++++++++++++++++++++-- 5 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 src/command/access/grant.ts diff --git a/src/command/access/grant.ts b/src/command/access/grant.ts new file mode 100644 index 00000000..3aabc5b5 --- /dev/null +++ b/src/command/access/grant.ts @@ -0,0 +1,70 @@ +import { LeafCommand, Option } from 'furious-commander' +import { exit } from 'process' +import { AccessHistory } from '../../service/access' +import { AccessHistoryOperation } from '../../service/access/types/history-event' +import { createKeyValue, errorText, successText } from '../../utils/text' +import { AccessCommand } from './access-command' + +export class Grant extends AccessCommand implements LeafCommand { + public readonly name = 'grant' + + public readonly description = 'Add grantees to an existing grantee list' + + @Option({ + key: 'list-name', + alias: 'n', + description: 'Name of the grantee list', + required: true, + type: 'string', + }) + public listName!: string + + @Option({ + key: 'grantee', + alias: 'g', + description: 'Public address of the grantee(s)', + type: 'string', + array: true, + }) + public grantees!: string[] + + public async run(): Promise { + super.init() + + if (this.grantees.length === 0) { + this.console.error(errorText('At least one grantee must be specified!')) + + exit(1) + } + + const accessHistory = new AccessHistory(this.commandConfig, this.console) + const lastHistoryEvent = accessHistory.getEvents(this.listName).sort((a, b) => b.createdAt - a.createdAt)[0] + + if (!lastHistoryEvent) { + this.console.error(errorText(`Grantee list with name '${this.listName}' does not exist!`)) + + exit(1) + } + const stampId = lastHistoryEvent.stampId + const granteeListRef = lastHistoryEvent.granteeListRef + const historyAddress = lastHistoryEvent.historyAddress + const response = await this.bee.patchGrantees(stampId, granteeListRef, historyAddress, { add: this.grantees }) + + if (response.status === 200) { + this.console.log(successText(`Access granted to ${this.grantees.join(', ')}!`)) + } + + accessHistory.addEvent(this.listName, { + stampId: stampId, + historyAddress: response.historyref.toHex(), + granteeListRef: response.ref.toHex(), + operation: AccessHistoryOperation.Grant, + createdAt: Date.now(), + }) + + if (this.verbose) { + this.console.log(createKeyValue('Grantee list reference', response.ref.toHex())) + this.console.log(createKeyValue('History address', response.historyref.toHex())) + } + } +} diff --git a/src/command/access/index.ts b/src/command/access/index.ts index b02d7d82..50abfde3 100644 --- a/src/command/access/index.ts +++ b/src/command/access/index.ts @@ -1,4 +1,5 @@ import { GroupCommand } from 'furious-commander' +import { Grant } from './grant' import { Init } from './init' export class Access implements GroupCommand { @@ -6,5 +7,5 @@ export class Access implements GroupCommand { public readonly description = 'Share access to your uploaded files/folders' - public subCommandClasses = [Init] + public subCommandClasses = [Init, Grant] } diff --git a/src/service/access/index.ts b/src/service/access/index.ts index 63d66a90..f68e6a99 100644 --- a/src/service/access/index.ts +++ b/src/service/access/index.ts @@ -31,14 +31,18 @@ export class AccessHistory { } } - public getEventsByType(granteeListName: string, eventType: AccessHistoryOperation): AccessHistoryEvent[] { + public getEvents(granteeListName: string): AccessHistoryEvent[] { const history = this.getHistory() if (!history[granteeListName]) { return [] } - return history[granteeListName].filter(event => event.operation === eventType) + return history[granteeListName] + } + + public getEventsByType(granteeListName: string, eventType: AccessHistoryOperation): AccessHistoryEvent[] { + return this.getEvents(granteeListName).filter(event => event.operation === eventType) } public addEvent(granteeListName: string, event: AccessHistoryEvent) { diff --git a/src/utils/text.ts b/src/utils/text.ts index 5299cb39..a7f474cd 100644 --- a/src/utils/text.ts +++ b/src/utils/text.ts @@ -9,6 +9,10 @@ function deleteWholeRow(): string { return '\u001b[2K' } +export function successText(string: string): string { + return chalk.green('✔ ' + string) +} + export function warningSymbol(): string { return chalk.yellow.bold('⚠️ Warning!') } diff --git a/test/command/access.spec.ts b/test/command/access.spec.ts index 940f18fe..4b00ac30 100644 --- a/test/command/access.spec.ts +++ b/test/command/access.spec.ts @@ -1,13 +1,18 @@ -import { unlinkSync } from 'fs' +import { System } from 'cafe-utility' +import { existsSync, unlinkSync } from 'fs' import { describeCommand, invokeTestCli } from '../utility' import { getPssAddress } from '../utility/address' import { getStampOption } from '../utility/stamp' describeCommand( 'Test Access command', - ({ consoleMessages, getLastMessage }) => { + ({ consoleMessages, getNthLastMessage, getLastMessage }) => { afterEach(() => { - unlinkSync(`${__dirname}/../testconfig/access-access-history.json`) + const historyFilePath = `${__dirname}/../testconfig/access-access-history.json` + + if (existsSync(historyFilePath)) { + unlinkSync(historyFilePath) + } }) describe('init', () => { it('should initialize access with pss address as grantee', async () => { @@ -32,6 +37,65 @@ describeCommand( expect(consoleMessages[2]).toContain('process.exit() was called with code 1') }) }) + + describe('when verbose option is used', () => { + it('should show grantee list reference and history address', async () => { + await invokeTestCli(['access', 'init', ...getStampOption(), '-n', 'test-access', '--verbose']) + expect(getNthLastMessage(2)).toContain('Grantee list reference') + expect(getNthLastMessage(2)).toMatch(/[a-f0-9]{64}/g) + expect(getLastMessage()).toContain('History address') + expect(getLastMessage()).toMatch(/[a-f0-9]{64}/g) + }) + }) + }) + + describe('grant', () => { + it('should grant access to a new grantee', async () => { + await invokeTestCli(['access', 'init', ...getStampOption(), '-n', 'test-access']) + await System.sleepMillis(1000) + const pssAddress = await getPssAddress('http://localhost:21633') + await invokeTestCli(['access', 'grant', '--list-name', 'test-access', '--grantee', pssAddress.toHex()]) + expect(getLastMessage()).toContain(`Access granted to ${pssAddress.toHex()}!`) + }) + + describe('when grantee list does not exist', () => { + it('should show error message', async () => { + await invokeTestCli(['access', 'grant', '-n', 'nonexistent-list', '-g', '0x123']) + expect(consoleMessages[0]).toContain("Grantee list with name 'nonexistent-list' does not exist!") + expect(consoleMessages[1]).toContain('process.exit() was called with code 1') + }) + }) + + describe('when no grantees are specified', () => { + it('should show error message', async () => { + await invokeTestCli(['access', 'init', ...getStampOption(), '-n', 'test-access']) + await System.sleepMillis(1000) + await invokeTestCli(['access', 'grant', '--list-name', 'test-access']) + expect(consoleMessages[1]).toContain('At least one grantee must be specified!') + expect(consoleMessages[2]).toContain('process.exit() was called with code 1') + }) + }) + + describe('when verbose option is used', () => { + it('should show grantee list reference and history address', async () => { + await invokeTestCli(['access', 'init', ...getStampOption(), '-n', 'test-access']) + await System.sleepMillis(1000) + const pssAddress = await getPssAddress('http://localhost:21633') + await invokeTestCli([ + 'access', + 'grant', + '--list-name', + 'test-access', + '--grantee', + pssAddress.toHex(), + '--verbose', + ]) + expect(getNthLastMessage(2)).toContain('Grantee list reference') + expect(getNthLastMessage(2)).toMatch(/[a-f0-9]{64}/g) + expect(getLastMessage()).toContain('History address') + expect(getLastMessage()).toMatch(/[a-f0-9]{64}/g) + }) + }) }) }, { configFileName: 'access' },