@bsv/wallet-toolbox
Version:
BRC100 conforming wallet, wallet storage and wallet signer components
415 lines • 22.8 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const globals_1 = require("@jest/globals");
const WalletPermissionsManager_fixtures_1 = require("./WalletPermissionsManager.fixtures");
const WalletPermissionsManager_1 = require("../WalletPermissionsManager");
// Re-mock @bsv/sdk with our fixture classes (MockTransaction, MockLockingScript, etc.)
globals_1.jest.mock('@bsv/sdk', () => WalletPermissionsManager_fixtures_1.MockedBSV_SDK);
(0, globals_1.describe)('WalletPermissionsManager - On-Chain Token Creation, Renewal & Revocation', () => {
let underlying;
let manager;
(0, globals_1.beforeEach)(() => {
// Fresh mock wallet before each test
underlying = (0, WalletPermissionsManager_fixtures_1.mockUnderlyingWallet)();
manager = new WalletPermissionsManager_1.WalletPermissionsManager(underlying, 'admin.domain.com');
});
(0, globals_1.afterEach)(() => {
globals_1.jest.clearAllMocks();
});
/* ------------------------------------------------------------------------
* 1) UNIT TESTS: buildPushdropFields() correctness
* ------------------------------------------------------------------------
* We directly call the manager’s internal buildPushdropFields(...) via
* a cast to "any" so we can test each permission type’s field ordering,
* encryption calls, and final arrays.
* ------------------------------------------------------------------------
*/
(0, globals_1.describe)('buildPushdropFields() - unit tests for each permission type', () => {
// We’ll cast the manager to `any` to access the private method.
const privateManager = () => manager;
(0, globals_1.it)('should build correct fields for a protocol token (DPACP)', async () => {
const request = {
type: 'protocol',
originator: 'some-app.com',
privileged: true,
protocolID: [2, 'myProto'],
counterparty: 'some-other-pubkey',
reason: 'test-protocol-creation'
};
const expiry = 1234567890;
// Because manager.encryptPermissionTokenField calls underlying.encrypt,
// we can observe how many times it's called & with what plaintext.
underlying.encrypt.mockClear();
const fields = await privateManager().buildPushdropFields(request, expiry);
// We expect 6 encryption calls (domain, expiry, privileged, secLevel, protoName, cpty).
(0, globals_1.expect)(underlying.encrypt).toHaveBeenCalledTimes(6);
// The final array must have length=6
(0, globals_1.expect)(fields).toHaveLength(6);
// Confirm the 1st call was the domain
(0, globals_1.expect)(underlying.encrypt.mock.calls[0][0].plaintext).toEqual(globals_1.expect.arrayContaining([...'some-app.com'].map(c => c.charCodeAt(0))));
// Confirm the 2nd call was the expiry, as a string
(0, globals_1.expect)(underlying.encrypt.mock.calls[1][0].plaintext).toEqual(globals_1.expect.arrayContaining([...'1234567890'].map(c => c.charCodeAt(0))));
// 3rd => privileged? 'true'
(0, globals_1.expect)(underlying.encrypt.mock.calls[2][0].plaintext).toEqual(globals_1.expect.arrayContaining([...'true'].map(c => c.charCodeAt(0))));
// 4th => security level => '2'
(0, globals_1.expect)(underlying.encrypt.mock.calls[3][0].plaintext).toEqual(globals_1.expect.arrayContaining([...'2'].map(c => c.charCodeAt(0))));
// 5th => protoName => 'myProto'
(0, globals_1.expect)(underlying.encrypt.mock.calls[4][0].plaintext).toEqual(globals_1.expect.arrayContaining([...'myProto'].map(c => c.charCodeAt(0))));
// 6th => counterparty => 'some-other-pubkey'
(0, globals_1.expect)(underlying.encrypt.mock.calls[5][0].plaintext).toEqual(globals_1.expect.arrayContaining([...'some-other-pubkey'].map(c => c.charCodeAt(0))));
});
(0, globals_1.it)('should build correct fields for a basket token (DBAP)', async () => {
const request = {
type: 'basket',
originator: 'origin.example',
basket: 'someBasket',
reason: 'basket usage'
};
const expiry = 999999999;
underlying.encrypt.mockClear();
const fields = await privateManager().buildPushdropFields(request, expiry);
// We expect 3 encryption calls: domain, expiry, basket
(0, globals_1.expect)(underlying.encrypt).toHaveBeenCalledTimes(3);
(0, globals_1.expect)(fields).toHaveLength(3);
});
(0, globals_1.it)('should build correct fields for a certificate token (DCAP)', async () => {
const request = {
type: 'certificate',
originator: 'cert-user.org',
privileged: false,
certificate: {
verifier: '02abcdef...',
certType: 'KYC',
fields: ['name', 'dob']
},
reason: 'certificate usage'
};
const expiry = 2222222222;
underlying.encrypt.mockClear();
const fields = await privateManager().buildPushdropFields(request, expiry);
// DP = domain, expiry, privileged, certType, fieldsJson, verifier
(0, globals_1.expect)(underlying.encrypt).toHaveBeenCalledTimes(6);
(0, globals_1.expect)(fields).toHaveLength(6);
// 5th encryption call is the fields JSON => ["name","dob"]
const fifthCallPlaintext = underlying.encrypt.mock.calls[4][0].plaintext;
const str = String.fromCharCode(...fifthCallPlaintext);
(0, globals_1.expect)(str).toContain('"name"');
(0, globals_1.expect)(str).toContain('"dob"');
});
(0, globals_1.it)('should build correct fields for a spending token (DSAP)', async () => {
const request = {
type: 'spending',
originator: 'money-spender.com',
spending: { satoshis: 5000 },
reason: 'monthly spending'
};
const expiry = 0; // DSAP typically not time-limited, but manager can pass 0.
underlying.encrypt.mockClear();
const fields = await privateManager().buildPushdropFields(request, expiry, /*amount=*/ 10000);
// For DSAP: domain + authorizedAmount (2 fields)
(0, globals_1.expect)(underlying.encrypt).toHaveBeenCalledTimes(2);
(0, globals_1.expect)(fields).toHaveLength(2);
// The second encryption call is '10000'
const secondPlaintext = underlying.encrypt.mock.calls[1][0].plaintext;
const asString = String.fromCharCode(...secondPlaintext);
(0, globals_1.expect)(asString).toBe('10000');
});
});
/* ------------------------------------------------------------------------
* 2) INTEGRATION TESTS: Token Creation
* ------------------------------------------------------------------------
* We'll simulate a user request flow, then call `grantPermission` with
* ephemeral=false to see if createAction is called with the correct script,
* basket name, tags, etc. We also decode the script to confirm it has the
* correct (encrypted) fields.
* ------------------------------------------------------------------------
*/
(0, globals_1.describe)('Token Creation - integration tests', () => {
(0, globals_1.it)('should create a new protocol token with the correct basket, script, and tags', async () => {
// 1) Simulate the manager having an active request for a protocol token.
const request = {
type: 'protocol',
originator: 'app.example',
privileged: false,
protocolID: [1, 'testProto'],
counterparty: 'self',
reason: 'Need protocol usage'
};
// We'll emulate that the manager queued it:
const key = manager.buildRequestKey(request);
manager.activeRequests.set(key, {
request,
pending: [{ resolve: () => { }, reject: () => { } }]
});
// 2) Grant the permission with ephemeral=false => must create the token
underlying.createAction.mockClear();
await manager.grantPermission({
requestID: key,
expiry: 999999, // set some expiry
ephemeral: false
});
// 3) Expect createAction to have been called once with a single output
(0, globals_1.expect)(underlying.createAction).toHaveBeenCalledTimes(1);
const actionArgs = underlying.createAction.mock.calls[0][0];
(0, globals_1.expect)(actionArgs.outputs).toHaveLength(1);
// The basket name must be "admin protocol-permission" as per BASKET_MAP
(0, globals_1.expect)(actionArgs.outputs[0].basket).toBe('admin protocol-permission');
// The tags must contain e.g. "originator app.example", "protocolName testProto", etc.
const outputTags = actionArgs.outputs[0].tags;
(0, globals_1.expect)(outputTags).toEqual(globals_1.expect.arrayContaining([
'originator app.example',
'privileged false',
'protocolName testProto',
'protocolSecurityLevel 1'
]));
// The lockingScript is built by "PushDrop.lock(...)" with 6 fields
const lockingScriptHex = actionArgs.outputs[0].lockingScript;
(0, globals_1.expect)(lockingScriptHex).toBeTruthy();
// Because we’re using our mock pushdrop, we might see an empty decode.
// In a real environment, you would decode and confirm the fields. Here we just confirm
// that the manager called the underlying encrypt 6 times, plus the script creation.
// Two more encrypt calls should have been made within createAction (metadata encryption
// of the top-level Action description, and the output's description) for a total of 8.
(0, globals_1.expect)(underlying.encrypt).toHaveBeenCalledTimes(8);
});
(0, globals_1.it)('should create a new basket token (DBAP)', async () => {
const request = {
type: 'basket',
originator: 'shopper.com',
basket: 'myBasket',
reason: 'I want to store items'
};
const key = manager.buildRequestKey(request);
manager.activeRequests.set(key, {
request,
pending: [{ resolve() { }, reject() { } }]
});
underlying.createAction.mockClear();
await manager.grantPermission({
requestID: key,
ephemeral: false,
expiry: 123456789
});
(0, globals_1.expect)(underlying.createAction).toHaveBeenCalledTimes(1);
const { outputs } = underlying.createAction.mock.calls[0][0];
(0, globals_1.expect)(outputs).toHaveLength(1);
// "admin basket-access"
(0, globals_1.expect)(outputs[0].basket).toBe('admin basket-access');
(0, globals_1.expect)(outputs[0].tags).toEqual(globals_1.expect.arrayContaining(['originator shopper.com', 'basket myBasket']));
// 3 fields => domain, expiry, basket, plus two metadata calls (description, outputDescription)
(0, globals_1.expect)(underlying.encrypt).toHaveBeenCalledTimes(5);
});
(0, globals_1.it)('should create a new certificate token (DCAP)', async () => {
const request = {
type: 'certificate',
originator: 'org.certs',
privileged: true,
certificate: {
verifier: '02cccccc',
certType: 'KYC',
fields: ['name', 'id', 'photo']
},
reason: 'Present KYC docs'
};
const key = manager.buildRequestKey(request);
manager.activeRequests.set(key, {
request,
pending: [{ resolve() { }, reject() { } }]
});
underlying.createAction.mockClear();
await manager.grantPermission({
requestID: key,
ephemeral: false,
expiry: 44444444
});
(0, globals_1.expect)(underlying.createAction).toHaveBeenCalledTimes(1);
const { outputs } = underlying.createAction.mock.calls[0][0];
(0, globals_1.expect)(outputs[0].basket).toBe('admin certificate-access');
(0, globals_1.expect)(outputs[0].tags).toEqual(globals_1.expect.arrayContaining(['originator org.certs', 'privileged true', 'type KYC', 'verifier 02cccccc']));
// DP = domain, expiry, privileged, certType, fieldsJson, verifier => 6 encryption calls
// Two additional ones for metadata encryption (action description, output description) for 8 total.
(0, globals_1.expect)(underlying.encrypt).toHaveBeenCalledTimes(8);
});
(0, globals_1.it)('should create a new spending authorization token (DSAP)', async () => {
const request = {
type: 'spending',
originator: 'spender.com',
spending: {
satoshis: 9999
}
};
const key = manager.buildRequestKey(request);
manager.activeRequests.set(key, {
request,
pending: [{ resolve() { }, reject() { } }]
});
underlying.createAction.mockClear();
// We'll set "amount=20000" as the monthly limit
await manager.grantPermission({
requestID: key,
ephemeral: false,
amount: 20000
});
(0, globals_1.expect)(underlying.createAction).toHaveBeenCalledTimes(1);
const { outputs } = underlying.createAction.mock.calls[0][0];
// "admin spending-authorization"
(0, globals_1.expect)(outputs[0].basket).toBe('admin spending-authorization');
(0, globals_1.expect)(outputs[0].tags).toEqual(globals_1.expect.arrayContaining(['originator spender.com']));
// domain, amount => 2 calls, plus two metadata encryption calls (description, outputDescription)
(0, globals_1.expect)(underlying.encrypt).toHaveBeenCalledTimes(4);
});
});
/* ------------------------------------------------------------------------
* 3) INTEGRATION TESTS: Token Renewal
* ------------------------------------------------------------------------
* We test that renewing a token:
* - Spends the old token with createAction input referencing oldToken.txid/index
* - Produces a new token output in the same transaction with updated fields
* ------------------------------------------------------------------------
*/
(0, globals_1.describe)('Token Renewal - integration tests', () => {
(0, globals_1.it)('should spend the old token input and create a new protocol token output with updated expiry', async () => {
// Suppose the user has an old protocol token:
const oldToken = {
tx: [],
txid: 'oldTokenTX',
outputIndex: 2,
outputScript: '76a914...ac', // not used by the mock
satoshis: 1,
originator: 'some-site.io',
expiry: 222222,
privileged: false,
securityLevel: 1,
protocol: 'coolProto',
counterparty: 'self'
};
// The user’s request to renew:
const request = {
type: 'protocol',
originator: 'some-site.io',
privileged: false,
protocolID: [1, 'coolProto'],
counterparty: 'self',
renewal: true,
previousToken: oldToken
};
// Manager normally calls requestPermissionFlow, but let's skip ahead:
// We'll place the request in activeRequests:
const key = manager.buildRequestKey(request);
manager.activeRequests.set(key, {
request,
pending: [{ resolve() { }, reject() { } }]
});
// Clear the mock calls, then renew with ephemeral=false
underlying.createAction.mockClear();
await manager.grantPermission({
requestID: key,
ephemeral: false,
expiry: 999999 // new expiry
});
// We expect createAction with:
// - 1 input referencing oldToken "oldTokenTX.2"
// - 1 output with the new script
(0, globals_1.expect)(underlying.createAction).toHaveBeenCalledTimes(1);
const createArgs = underlying.createAction.mock.calls[0][0];
(0, globals_1.expect)(createArgs.inputs).toHaveLength(1);
(0, globals_1.expect)(createArgs.inputs[0].outpoint).toBe('oldTokenTX.2');
(0, globals_1.expect)(createArgs.outputs).toHaveLength(1);
// The new basket is still "admin protocol-permission"
(0, globals_1.expect)(createArgs.outputs[0].basket).toBe('admin protocol-permission');
// And we must confirm "renew" means 6 encryption calls again
// Metadata encryption means three extra calls (inputDescription, outputDescription, and Action description)
// this means a total of 9.
(0, globals_1.expect)(underlying.encrypt).toHaveBeenCalledTimes(9);
});
(0, globals_1.it)('should allow updating the authorizedAmount in DSAP renewal', async () => {
const oldToken = {
tx: [],
txid: 'dsap-old-tx',
outputIndex: 0,
outputScript: 'sample script',
satoshis: 1,
originator: 'spenderX.com',
authorizedAmount: 10000,
expiry: 0
};
const request = {
type: 'spending',
originator: 'spenderX.com',
spending: { satoshis: 3000 },
renewal: true,
previousToken: oldToken
};
const key = manager.buildRequestKey(request);
manager.activeRequests.set(key, {
request,
pending: [{ resolve() { }, reject() { } }]
});
underlying.createAction.mockClear();
// Renew with new monthly limit 50000
await manager.grantPermission({
requestID: key,
amount: 50000,
ephemeral: false
});
// check
const { inputs, outputs } = underlying.createAction.mock.calls[0][0];
(0, globals_1.expect)(inputs).toHaveLength(1);
(0, globals_1.expect)(inputs[0].outpoint).toBe('dsap-old-tx.0');
(0, globals_1.expect)(outputs).toHaveLength(1);
(0, globals_1.expect)(outputs[0].basket).toBe('admin spending-authorization');
// domain + new authorizedAmount => 2 encryption calls
// For metadata encryption, we have an input description, an output description, and a top-level description.
// This makes for a total of 5 calls.
(0, globals_1.expect)(underlying.encrypt).toHaveBeenCalledTimes(5);
// The second call’s plaintext should be "50000"
const secondPlaintext = underlying.encrypt.mock.calls[1][0].plaintext;
const asStr = String.fromCharCode(...secondPlaintext);
(0, globals_1.expect)(asStr).toBe('50000');
});
});
/* ------------------------------------------------------------------------
* 4) INTEGRATION TESTS: Token Revocation
* ------------------------------------------------------------------------
* - Revoking a token means we build a transaction that consumes the old
* token UTXO with no replacement output.
* - Then we typically call signAction to finalize. The old token is no
* longer listed as an unspent output.
* ------------------------------------------------------------------------
*/
(0, globals_1.describe)('Token Revocation - integration tests', () => {
(0, globals_1.it)('should create a transaction that consumes (spends) the old token with no new outputs', async () => {
// A sample old token
const oldToken = {
tx: [],
txid: 'revocableToken.txid',
outputIndex: 1,
outputScript: 'fakePushdropScript',
satoshis: 1,
originator: 'shopper.com',
basketName: 'myBasket',
expiry: 1111111111
};
underlying.createAction.mockClear();
underlying.signAction.mockClear();
await manager.revokePermission(oldToken);
// 1) The manager calls createAction with an input referencing oldToken
(0, globals_1.expect)(underlying.createAction).toHaveBeenCalledTimes(1);
const createArgs = underlying.createAction.mock.calls[0][0];
(0, globals_1.expect)(createArgs.inputs).toHaveLength(1);
(0, globals_1.expect)(createArgs.inputs[0].outpoint).toBe('revocableToken.txid.1');
// No new outputs => final array is empty
(0, globals_1.expect)(createArgs.outputs || []).toHaveLength(0);
// 2) The manager then calls signAction to finalize the spending
(0, globals_1.expect)(underlying.signAction).toHaveBeenCalledTimes(1);
const signArgs = underlying.signAction.mock.calls[0][0];
// signArgs.reference should be the same from createAction’s result
(0, globals_1.expect)(signArgs.reference).toBe('mockReference');
// The “spends” object should have an unlockingScript at index 0.
(0, globals_1.expect)(signArgs.spends).toHaveProperty('0.unlockingScript');
// The content can be a mock, we just check it’s not empty
(0, globals_1.expect)(signArgs.spends[0].unlockingScript).toBeDefined();
});
});
});
//# sourceMappingURL=WalletPermissionsManager.tokens.test.js.map