diff --git a/lib/endpoint.js b/lib/endpoint.js index 866dc68..dd3010f 100644 --- a/lib/endpoint.js +++ b/lib/endpoint.js @@ -174,6 +174,7 @@ class Endpoint extends EventEmitter { try { const res = await this._request('licensing.generate-token', { callId }); this._sessionToken1 = res?.token || ''; + this._validatedToken2 = ''; return { body: this._sessionToken1 ? `+OK ${this._sessionToken1}` : '-ERR not licensed' }; } catch (err) { return { body: `-ERR ${err.message}` }; @@ -514,8 +515,15 @@ class Endpoint extends EventEmitter { * feature-server drops the call — its media-timeout path already handles it. */ async _validateSessionToken2(token2) { if (!this._sessionToken1 || !token2) return; + /* token-2 is a one-time proof minted at call setup, and the feature-server + * sets it again on every later provisional and on the final response, still + * carrying its original timestamp. Re-validating that same token after the + * 40s window fails as 'expired' on any call that rings that long, tearing + * the endpoint down before answer. Check each token once. */ + if (token2 === this._validatedToken2) return; try { await this._request('licensing.validate-token-2', { token1: this._sessionToken1, token2 }); + this._validatedToken2 = token2; } catch (err) { if (this.connected) { this.connected = false; diff --git a/test/licensing-conduit.test.js b/test/licensing-conduit.test.js index bb22e84..d109a8f 100644 --- a/test/licensing-conduit.test.js +++ b/test/licensing-conduit.test.js @@ -79,3 +79,63 @@ test('token-2 with no token-1 (unlicensed) is a no-op — no validate call, no t assert.strictEqual(destroyed, false); assert.strictEqual(ep.connected, true); }); + +/* mediajam rejects a token-2 older than 40s; the feature-server sets the same + token again on each later provisional and on the 200 OK. */ +const expiringValidator = () => { + let validated = 0; + return (cmd) => { + if (cmd === 'licensing.generate-token') return { token: 'TOK1' }; + if (cmd === 'licensing.validate-token-2') { + if (validated++ > 0) throw new Error('session token 2 invalid'); + return { valid: true }; + } + return {}; + }; +}; + +test('the same token-2 is validated once: a re-set after a long ring does not tear down', async () => { + const { ep, calls } = makeEp(expiringValidator()); + await ep.api('uuid_jambonz_licensing', 'generate-session-token ep-1 call-123'); + let destroyed = false; + ep.on('destroy', () => { destroyed = true; }); + await ep.set('jambonz_session_token_2', 'TOK2'); // 180, fresh + await ep.set('jambonz_session_token_2', 'TOK2'); // 183 at 45s, same token + await ep.set('jambonz_session_token_2', 'TOK2'); // 200 OK, same token + assert.strictEqual(calls.filter((c) => c.cmd === 'licensing.validate-token-2').length, 1); + assert.strictEqual(destroyed, false); + assert.strictEqual(ep.connected, true); +}); + +test('a different token-2 is still validated (and torn down if invalid)', async () => { + const { ep, calls } = makeEp(expiringValidator()); + await ep.api('uuid_jambonz_licensing', 'generate-session-token ep-1 call-123'); + let destroyed = null; + ep.on('destroy', (evt) => { destroyed = evt; }); + await ep.set('jambonz_session_token_2', 'TOK2'); + await ep.set('jambonz_session_token_2', 'OTHER'); + assert.strictEqual(calls.filter((c) => c.cmd === 'licensing.validate-token-2').length, 2); + assert.strictEqual(ep.connected, false); + assert.strictEqual(destroyed.reason, 'license-violation'); +}); + +test('a token-2 that failed validation is not remembered as validated', async () => { + const { ep, calls } = makeEp((cmd) => { + if (cmd === 'licensing.generate-token') return { token: 'TOK1' }; + if (cmd === 'licensing.validate-token-2') throw new Error('session token 2 invalid'); + return {}; + }); + await ep.api('uuid_jambonz_licensing', 'generate-session-token ep-1 call-123'); + await ep.set('jambonz_session_token_2', 'BAD'); + assert.strictEqual(ep._validatedToken2 || '', ''); + assert.strictEqual(calls.filter((c) => c.cmd === 'licensing.validate-token-2').length, 1); +}); + +test('minting a new token-1 clears the remembered token-2', async () => { + const { ep, calls } = makeEp((cmd) => (cmd === 'licensing.generate-token' ? { token: 'TOK1' } : { valid: true })); + await ep.api('uuid_jambonz_licensing', 'generate-session-token ep-1 call-123'); + await ep.set('jambonz_session_token_2', 'TOK2'); + await ep.api('uuid_jambonz_licensing', 'generate-session-token ep-1 call-456'); + await ep.set('jambonz_session_token_2', 'TOK2'); + assert.strictEqual(calls.filter((c) => c.cmd === 'licensing.validate-token-2').length, 2); +});