diff --git a/test/admin-api.test.mjs b/test/admin-api.test.mjs index d12df45..5e1fa5c 100644 --- a/test/admin-api.test.mjs +++ b/test/admin-api.test.mjs @@ -128,4 +128,123 @@ describe('Admin API module', function() { expect(result.track.title).to.equal('Track'); expect(result.nextTracks).to.deep.equal([{ title: 'Next', artist: 'Queue Artist' }]); }); + + describe('#updateConfigValue edge cases', function() { + it('rejects a key that is not on the admin allow-list', async function() { + const config = createConfig({}); + const api = createApi({ config }); + + const result = await api.updateConfigValue('someRandomInternalKey', 'value'); + + expect(result).to.deep.equal({ success: false, error: 'Key not allowed to be updated via admin' }); + expect(config.store.someRandomInternalKey).to.equal(undefined); + }); + + it('accepts a valid log level and applies it live via logger.setLevel', async function() { + const config = createConfig({ logLevel: 'info' }); + const logger = { + debug: sinon.stub(), error: sinon.stub(), info: sinon.stub(), + setLevel: sinon.stub(), warn: sinon.stub() + }; + const api = createApi({ config, logger }); + + const result = await api.updateConfigValue('logLevel', 'debug'); + + expect(result.success).to.equal(true); + expect(config.store.logLevel).to.equal('debug'); + expect(logger.setLevel.calledOnceWithExactly('debug')).to.equal(true); + expect(logger.warn.calledOnce).to.equal(true); + }); + + it('rejects an invalid log level without touching the logger or config', async function() { + const config = createConfig({ logLevel: 'info' }); + const logger = { + debug: sinon.stub(), error: sinon.stub(), info: sinon.stub(), + setLevel: sinon.stub(), warn: sinon.stub() + }; + const api = createApi({ config, logger }); + + const result = await api.updateConfigValue('logLevel', 'shout'); + + expect(result.success).to.equal(false); + expect(result.error).to.match(/Invalid log level/); + expect(config.store.logLevel).to.equal('info'); + expect(logger.setLevel.called).to.equal(false); + }); + + it('rejects a non-numeric value for a numeric config key', async function() { + const config = createConfig({ maxVolume: 75 }); + const api = createApi({ config }); + + const result = await api.updateConfigValue('maxVolume', 'loud'); + + expect(result.success).to.equal(false); + expect(result.error).to.match(/Must be a number/); + expect(config.store.maxVolume).to.equal(75); + }); + + it('coerces boolean-ish strings to true', async function() { + const config = createConfig({ ttsEnabled: false }); + const api = createApi({ config }); + + for (const truthy of ['true', '1', 'yes', 'on', 'TRUE', ' On ']) { + const result = await api.updateConfigValue('ttsEnabled', truthy); + expect(result.success, `expected "${truthy}" to succeed`).to.equal(true); + expect(config.store.ttsEnabled, `expected "${truthy}" to coerce to true`).to.equal(true); + } + }); + + it('coerces non-matching strings to false for boolean config keys', async function() { + const config = createConfig({ ttsEnabled: true }); + const api = createApi({ config }); + + const result = await api.updateConfigValue('ttsEnabled', 'nope'); + + expect(result.success).to.equal(true); + expect(config.store.ttsEnabled).to.equal(false); + }); + + it('coerces a non-string value for a boolean key via Boolean()', async function() { + const config = createConfig({ crossfadeEnabled: false }); + const api = createApi({ config }); + + const result = await api.updateConfigValue('crossfadeEnabled', 1); + + expect(result.success).to.equal(true); + expect(config.store.crossfadeEnabled).to.equal(true); + }); + + it('logs an error but still reports success when persisting the config fails', async function() { + const logger = { + debug: sinon.stub(), error: sinon.stub(), info: sinon.stub(), + setLevel: sinon.stub(), warn: sinon.stub() + }; + const config = { + store: { maxVolume: 75 }, + get(key) { return this.store[key]; }, + set(key, value) { this.store[key] = value; }, + save(cb) { cb(new Error('disk full')); } + }; + const api = createApi({ config, logger }); + + const result = await api.updateConfigValue('maxVolume', '60'); + + expect(result.success).to.equal(true); + expect(config.store.maxVolume).to.equal(60); + expect(logger.error.calledWithMatch('Failed to save config:')).to.equal(true); + }); + + it('returns a failure result when an unexpected error is thrown', async function() { + const config = { + get() { return undefined; }, + set() { throw new Error('unexpected failure'); }, + save() {} + }; + const api = createApi({ config }); + + const result = await api.updateConfigValue('maxVolume', '60'); + + expect(result).to.deep.equal({ success: false, error: 'unexpected failure' }); + }); + }); }); diff --git a/test/ai-handler.test.mjs b/test/ai-handler.test.mjs index 4635cec..047c3b3 100644 --- a/test/ai-handler.test.mjs +++ b/test/ai-handler.test.mjs @@ -1,17 +1,66 @@ import { expect } from 'chai'; -import { getSeasonalContext } from '../lib/ai-handler.js'; +import sinon from 'sinon'; +import { createRequire } from 'module'; +import { + getSeasonalContext, + initialize, + parseNaturalLanguage, + isAIEnabled, + getAIDebugInfo, + setUserContext, + getUserContext, + clearUserContext +} from '../lib/ai-handler.js'; + +const require = createRequire(import.meta.url); +const nconf = require('nconf'); +const OpenAI = require('openai'); + +// Every OpenAI client shares the same Completions prototype, so stubbing it +// here intercepts the `openai.chat.completions.create(...)` calls made deep +// inside lib/ai-handler.js without needing to inject a fake client. +nconf.use('memory'); +const completionsProto = Object.getPrototypeOf(new OpenAI({ apiKey: 'sk-probe-0000000000000000' }).chat.completions); + +function createLogger() { + return { + debug: sinon.stub(), + info: sinon.stub(), + warn: sinon.stub(), + error: sinon.stub() + }; +} + +function planResponse(overrides = {}) { + const parsed = { + command: 'add', + args: ['Queen', '5'], + targetType: 'artist', + confidence: 0.92, + reasoning: 'Clear request for an artist', + summary: 'Queen signal caught; regal bangers incoming.', + followUp: null, + response: null, + suggestedAction: null, + ...overrides + }; + return { + choices: [{ message: { content: JSON.stringify(parsed) } }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } + }; +} describe('AI Handler', function() { describe('#getSeasonalContext', function() { it('should return seasonal context object', function() { const ctx = getSeasonalContext(); - + expect(ctx).to.be.an('object'); expect(ctx).to.have.property('season'); expect(ctx).to.have.property('month'); expect(ctx).to.have.property('themes'); expect(ctx).to.have.property('suggestion'); - + expect(ctx.season).to.be.a('string'); expect(ctx.month).to.be.a('string'); expect(ctx.themes).to.be.an('array'); @@ -20,9 +69,9 @@ describe('AI Handler', function() { it('should return valid month name', function() { const ctx = getSeasonalContext(); - const validMonths = ['January', 'February', 'March', 'April', 'May', 'June', + const validMonths = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; - + expect(validMonths).to.include(ctx.month); }); @@ -34,8 +83,294 @@ describe('AI Handler', function() { it('should return a valid season', function() { const ctx = getSeasonalContext(); const validSeasons = ['Winter', 'Spring', 'Summer', 'Autumn', 'Winter/Holiday', 'Halloween', "Valentine's"]; - + expect(validSeasons).to.include(ctx.season); }); }); + + describe('user context (#setUserContext / #getUserContext / #clearUserContext)', function() { + before(async function() { + // initialize() unconditionally stores the logger before touching the + // API key, so this gives the context helpers a logger without + // enabling AI parsing or making any network calls. + nconf.clear('openaiApiKey'); + await initialize(createLogger()); + }); + + afterEach(function() { + sinon.restore(); + }); + + it('returns null for a user with no stored context', function() { + expect(getUserContext('nobody-yet')).to.equal(null); + }); + + it('round-trips a stored suggestion', function() { + setUserContext('alice', 'add queen', 'wants queen music'); + + const ctx = getUserContext('alice'); + expect(ctx.lastSuggestion).to.equal('add queen'); + expect(ctx.context).to.equal('wants queen music'); + }); + + it('keeps separate contexts per scope for the same user', function() { + setUserContext('bob', 'add queen', 'ctx-a', null, { platform: 'slack', channel: 'C1' }); + setUserContext('bob', 'add u2', 'ctx-b', null, { platform: 'slack', channel: 'C2' }); + + expect(getUserContext('bob', { platform: 'slack', channel: 'C1' }).lastSuggestion).to.equal('add queen'); + expect(getUserContext('bob', { platform: 'slack', channel: 'C2' }).lastSuggestion).to.equal('add u2'); + }); + + it('clears stored context on request', function() { + setUserContext('carol', 'gong', 'wants to skip'); + clearUserContext('carol'); + + expect(getUserContext('carol')).to.equal(null); + }); + + it('expires context after the timeout window', function() { + const clock = sinon.useFakeTimers({ now: Date.now(), toFake: ['Date'] }); + + setUserContext('dave', 'add queen', 'wants queen music'); + expect(getUserContext('dave')).to.not.equal(null); + + clock.tick(5 * 60 * 1000 + 1000); // just past the 5 minute context timeout + + expect(getUserContext('dave')).to.equal(null); + }); + + it('sanitizes the stored suggestedAction (drops extra args, truncates description)', function() { + setUserContext('erin', 'add u2', 'ctx', { + command: 'add', + args: Array.from({ length: 15 }, (_, i) => `arg${i}`), + description: 'x'.repeat(200) + }); + + const ctx = getUserContext('erin'); + expect(ctx.suggestedAction.command).to.equal('add'); + expect(ctx.suggestedAction.args).to.have.lengthOf(10); + expect(ctx.suggestedAction.description).to.have.lengthOf(120); + }); + + it('rejects a "chat" suggestedAction as invalid', function() { + setUserContext('frank', 'chat', 'ctx', { command: 'chat', args: [], description: 'x' }); + + expect(getUserContext('frank').suggestedAction).to.equal(null); + }); + }); + + describe('#initialize', function() { + let logger; + let createStub; + + beforeEach(function() { + logger = createLogger(); + createStub = sinon.stub(completionsProto, 'create'); + }); + + afterEach(function() { + sinon.restore(); + nconf.clear('openaiApiKey'); + }); + + it('disables AI parsing when no API key is configured', async function() { + nconf.clear('openaiApiKey'); + + await initialize(logger); + + expect(isAIEnabled()).to.equal(false); + expect(logger.warn.calledOnce).to.equal(true); + expect(createStub.called).to.equal(false); + }); + + it('disables AI parsing when the key has an invalid format', async function() { + nconf.set('openaiApiKey', 'not-a-valid-key'); + + await initialize(logger); + + expect(isAIEnabled()).to.equal(false); + expect(logger.error.calledWithMatch(/Invalid OpenAI API key format/)).to.equal(true); + expect(createStub.called).to.equal(false); + }); + + it('enables AI parsing when the key validates successfully', async function() { + nconf.set('openaiApiKey', 'sk-valid-1234567890'); + createStub.resolves({ choices: [{ message: { content: 'ok' } }] }); + + await initialize(logger); + + expect(isAIEnabled()).to.equal(true); + expect(logger.info.calledWithMatch(/AI natural language parsing enabled/)).to.equal(true); + }); + + it('disables AI parsing and logs a specific message on a 401', async function() { + nconf.set('openaiApiKey', 'sk-valid-1234567890'); + const err = new Error('Unauthorized'); + err.status = 401; + createStub.rejects(err); + + await initialize(logger); + + expect(isAIEnabled()).to.equal(false); + expect(logger.error.calledWithMatch(/invalid or unauthorized/)).to.equal(true); + }); + + it('disables AI parsing and logs a quota message on a 429', async function() { + nconf.set('openaiApiKey', 'sk-valid-1234567890'); + const err = new Error('Too Many Requests'); + err.status = 429; + createStub.rejects(err); + + await initialize(logger); + + expect(isAIEnabled()).to.equal(false); + expect(logger.error.calledWithMatch(/quota exceeded/)).to.equal(true); + }); + + it('disables AI parsing and logs a connection message on DNS/connection errors', async function() { + nconf.set('openaiApiKey', 'sk-valid-1234567890'); + const err = new Error('getaddrinfo ENOTFOUND api.openai.com'); + err.code = 'ENOTFOUND'; + createStub.rejects(err); + + await initialize(logger); + + expect(isAIEnabled()).to.equal(false); + expect(logger.error.calledWithMatch(/Cannot connect to OpenAI API/)).to.equal(true); + }); + + it('disables AI parsing and logs the raw message for other errors', async function() { + nconf.set('openaiApiKey', 'sk-valid-1234567890'); + createStub.rejects(new Error('boom')); + + await initialize(logger); + + expect(isAIEnabled()).to.equal(false); + expect(logger.error.calledWithMatch(/Failed to initialize OpenAI client: boom/)).to.equal(true); + }); + + it('disables AI parsing when the validation response has no choices', async function() { + nconf.set('openaiApiKey', 'sk-valid-1234567890'); + createStub.resolves({}); + + await initialize(logger); + + expect(isAIEnabled()).to.equal(false); + expect(logger.error.calledWithMatch(/Invalid response from OpenAI API/)).to.equal(true); + }); + }); + + describe('#parseNaturalLanguage', function() { + let logger; + let createStub; + + beforeEach(async function() { + logger = createLogger(); + createStub = sinon.stub(completionsProto, 'create'); + nconf.set('openaiApiKey', 'sk-valid-1234567890'); + createStub.resolves({ choices: [{ message: { content: 'ok' } }] }); + await initialize(logger); + createStub.resetHistory(); + }); + + afterEach(function() { + sinon.restore(); + nconf.clear('openaiApiKey'); + }); + + it('returns null without calling OpenAI when AI parsing is not enabled', async function() { + nconf.clear('openaiApiKey'); + await initialize(logger); // re-init with no key disables AI again + createStub.resetHistory(); + + const result = await parseNaturalLanguage('play queen', 'alice'); + + expect(result).to.equal(null); + expect(createStub.called).to.equal(false); + }); + + it('parses a valid command plan from OpenAI', async function() { + createStub.resolves(planResponse()); + + const result = await parseNaturalLanguage('add queen', 'alice'); + + expect(result.command).to.equal('add'); + expect(result.args).to.deep.equal(['Queen', '5']); + expect(getAIDebugInfo().lastSuccessTS).to.be.a('string'); + }); + + it('retries with JSON mode when the model does not support structured outputs', async function() { + const schemaErr = new Error('This model does not support response_format json_schema'); + schemaErr.status = 400; + createStub.onFirstCall().rejects(schemaErr); + createStub.onSecondCall().resolves(planResponse()); + + const result = await parseNaturalLanguage('add queen', 'alice'); + + expect(result.command).to.equal('add'); + expect(createStub.calledTwice).to.equal(true); + expect(createStub.secondCall.args[0].response_format).to.deep.equal({ type: 'json_object' }); + }); + + it('returns null when OpenAI refuses to answer', async function() { + createStub.resolves({ choices: [{ message: { refusal: 'cannot comply' } }] }); + + const result = await parseNaturalLanguage('add queen', 'alice'); + + expect(result).to.equal(null); + expect(logger.warn.calledWithMatch(/refused/)).to.equal(true); + }); + + it('returns null and records an error when the response is not valid JSON', async function() { + createStub.resolves({ choices: [{ message: { content: 'not json' } }] }); + + const result = await parseNaturalLanguage('add queen', 'alice'); + + expect(result).to.equal(null); + expect(getAIDebugInfo().lastErrorMessage).to.be.a('string'); + }); + + it('returns null when the parsed plan fails schema validation', async function() { + createStub.resolves(planResponse({ command: 'not-a-real-command' })); + + const result = await parseNaturalLanguage('do something weird', 'alice'); + + expect(result).to.equal(null); + expect(logger.warn.calledWithMatch(/invalid command plan/)).to.equal(true); + }); + + it('re-interprets "play " as an add request', async function() { + createStub.resolves(planResponse({ + command: 'play', + args: [], + targetType: 'command', + summary: 'Got it.' + })); + + const result = await parseNaturalLanguage('play some queen', 'alice'); + + expect(result.command).to.equal('add'); + expect(result.args).to.deep.equal(['queen', '5']); + expect(result.summary).to.not.equal('Got it.'); + }); + + it('clears follow-up context after a confident parse', async function() { + setUserContext('alice', 'add queen', 'wants queen music'); + expect(getUserContext('alice')).to.not.equal(null); + + createStub.resolves(planResponse({ confidence: 0.9 })); + await parseNaturalLanguage('yes', 'alice'); + + expect(getUserContext('alice')).to.equal(null); + }); + + it('returns null and records the error when the OpenAI call rejects', async function() { + createStub.rejects(new Error('network down')); + + const result = await parseNaturalLanguage('add queen', 'alice'); + + expect(result).to.equal(null); + expect(getAIDebugInfo().lastErrorMessage).to.equal('network down'); + }); + }); }); diff --git a/test/discord-validator.test.mjs b/test/discord-validator.test.mjs new file mode 100644 index 0000000..838eeea --- /dev/null +++ b/test/discord-validator.test.mjs @@ -0,0 +1,118 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const { validateDiscordToken } = require('../lib/discord-validator.js'); + +// Well-formed dummy tokens (format-valid, length >= 50) used to exercise the +// network branches without tripping the early format/length guards. +const VALID_FORMAT_TOKEN = `${'A'.repeat(24)}.${'B'.repeat(6)}.${'C'.repeat(27)}`; +const VALID_MFA_TOKEN = `mfa.${'D'.repeat(80)}`; + +function mockJsonResponse(ok, body) { + return { + ok, + status: ok ? 200 : 401, + statusText: ok ? 'OK' : 'Unauthorized', + json: sinon.stub().resolves(body) + }; +} + +describe('Discord token validator', function() { + afterEach(function() { + sinon.restore(); + }); + + it('rejects an empty token without calling the network', async function() { + sinon.stub(global, 'fetch'); + + const result = await validateDiscordToken(''); + + expect(result).to.deep.equal({ valid: false, error: 'Discord token is required' }); + expect(global.fetch.called).to.equal(false); + }); + + it('rejects a whitespace-only token', async function() { + const result = await validateDiscordToken(' '); + + expect(result.valid).to.equal(false); + expect(result.error).to.equal('Discord token is required'); + }); + + it('rejects a malformed token that does not match the expected shape', async function() { + sinon.stub(global, 'fetch'); + + const result = await validateDiscordToken('not a valid token!!'); + + expect(result.valid).to.equal(false); + expect(result.error).to.match(/Invalid Discord token format/); + expect(global.fetch.called).to.equal(false); + }); + + it('rejects a correctly-shaped token that is too short', async function() { + sinon.stub(global, 'fetch'); + + const result = await validateDiscordToken('abc.def.ghi'); + + expect(result.valid).to.equal(false); + expect(result.error).to.match(/too short/); + expect(global.fetch.called).to.equal(false); + }); + + it('accepts the mfa.* token shape and calls the Discord API', async function() { + const fetchStub = sinon.stub(global, 'fetch').resolves( + mockJsonResponse(true, { id: '123', username: 'bot', discriminator: '0000' }) + ); + + const result = await validateDiscordToken(VALID_MFA_TOKEN); + + expect(result.valid).to.equal(true); + expect(fetchStub.calledOnce).to.equal(true); + expect(fetchStub.firstCall.args[0]).to.equal('https://discord.com/api/v10/users/@me'); + }); + + it('returns bot info when the Discord API confirms the token', async function() { + sinon.stub(global, 'fetch').resolves( + mockJsonResponse(true, { id: '42', username: 'SlackONOS', discriminator: '1234' }) + ); + + const result = await validateDiscordToken(VALID_FORMAT_TOKEN); + + expect(result).to.deep.equal({ + valid: true, + botInfo: { id: '42', username: 'SlackONOS', discriminator: '1234' } + }); + }); + + it('returns the API error message when Discord rejects the token', async function() { + sinon.stub(global, 'fetch').resolves( + mockJsonResponse(false, { message: '401: Unauthorized' }) + ); + + const result = await validateDiscordToken(VALID_FORMAT_TOKEN); + + expect(result.valid).to.equal(false); + expect(result.error).to.equal('401: Unauthorized'); + }); + + it('falls back to the HTTP status text when the error body has no message', async function() { + const response = mockJsonResponse(false, {}); + response.json = sinon.stub().rejects(new Error('no body')); + sinon.stub(global, 'fetch').resolves(response); + + const result = await validateDiscordToken(VALID_FORMAT_TOKEN); + + expect(result.valid).to.equal(false); + expect(result.error).to.equal('HTTP 401: Unauthorized'); + }); + + it('returns a network error message when the fetch call throws', async function() { + sinon.stub(global, 'fetch').rejects(new Error('getaddrinfo ENOTFOUND discord.com')); + + const result = await validateDiscordToken(VALID_FORMAT_TOKEN); + + expect(result.valid).to.equal(false); + expect(result.error).to.equal('getaddrinfo ENOTFOUND discord.com'); + }); +}); diff --git a/test/slack-validator.test.mjs b/test/slack-validator.test.mjs new file mode 100644 index 0000000..8673a00 --- /dev/null +++ b/test/slack-validator.test.mjs @@ -0,0 +1,145 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const { WebClient } = require('@slack/web-api'); +const { validateAppToken, validateBotToken, validateSlackTokens } = require('../lib/slack-validator.js'); + +describe('Slack token validator', function() { + afterEach(function() { + sinon.restore(); + }); + + describe('#validateAppToken', function() { + it('rejects a missing app token', async function() { + const result = await validateAppToken(''); + + expect(result).to.deep.equal({ valid: false, error: 'App token must start with xapp-' }); + }); + + it('rejects a token that does not start with xapp-', async function() { + const result = await validateAppToken('xoxb-not-an-app-token'); + + expect(result.valid).to.equal(false); + expect(result.error).to.equal('App token must start with xapp-'); + }); + + it('accepts a correctly-prefixed app token on format alone', async function() { + const result = await validateAppToken('xapp-1-A1B2C3-1234567890-abcdef'); + + expect(result).to.deep.equal({ valid: true }); + }); + }); + + describe('#validateBotToken', function() { + it('rejects a missing bot token without calling the network', async function() { + const apiCall = sinon.stub(WebClient.prototype, 'apiCall'); + + const result = await validateBotToken(''); + + expect(result).to.deep.equal({ valid: false, error: 'Bot token must start with xoxb-' }); + expect(apiCall.called).to.equal(false); + }); + + it('rejects a token that does not start with xoxb-', async function() { + const result = await validateBotToken('xapp-not-a-bot-token'); + + expect(result.valid).to.equal(false); + expect(result.error).to.equal('Bot token must start with xoxb-'); + }); + + it('returns bot info when Slack confirms the token via auth.test', async function() { + sinon.stub(WebClient.prototype, 'apiCall').resolves({ + ok: true, + bot_id: 'B123', + user_id: 'U123', + team: 'My Team', + team_id: 'T123' + }); + + const result = await validateBotToken('xoxb-valid-token'); + + expect(result).to.deep.equal({ + valid: true, + botInfo: { botId: 'B123', userId: 'U123', team: 'My Team', teamId: 'T123' } + }); + }); + + it('returns an error when Slack reports the token as invalid', async function() { + sinon.stub(WebClient.prototype, 'apiCall').resolves({ + ok: false, + error: 'invalid_auth' + }); + + const result = await validateBotToken('xoxb-bad-token'); + + expect(result).to.deep.equal({ valid: false, error: 'invalid_auth' }); + }); + + it('returns a generic error when Slack reports failure without a message', async function() { + sinon.stub(WebClient.prototype, 'apiCall').resolves({ ok: false }); + + const result = await validateBotToken('xoxb-bad-token'); + + expect(result.valid).to.equal(false); + expect(result.error).to.equal('Token validation failed'); + }); + + it('catches thrown errors and returns them as validation failures', async function() { + sinon.stub(WebClient.prototype, 'apiCall').rejects(new Error('network unreachable')); + + const result = await validateBotToken('xoxb-valid-token'); + + expect(result.valid).to.equal(false); + expect(result.error).to.equal('network unreachable'); + }); + }); + + describe('#validateSlackTokens', function() { + it('reports valid:true with combined bot info when both tokens are good', async function() { + sinon.stub(WebClient.prototype, 'apiCall').resolves({ + ok: true, + bot_id: 'B1', + user_id: 'U1', + team: 'Team', + team_id: 'T1' + }); + + const result = await validateSlackTokens('xapp-1-good', 'xoxb-good'); + + expect(result.valid).to.equal(true); + expect(result.errors).to.equal(undefined); + expect(result.botInfo).to.deep.equal({ botId: 'B1', userId: 'U1', team: 'Team', teamId: 'T1' }); + }); + + it('collects one error and still returns bot info when only the app token is bad', async function() { + sinon.stub(WebClient.prototype, 'apiCall').resolves({ + ok: true, + bot_id: 'B1', + user_id: 'U1', + team: 'Team', + team_id: 'T1' + }); + + const result = await validateSlackTokens('bad-app-token', 'xoxb-good'); + + expect(result.valid).to.equal(false); + expect(result.errors).to.have.lengthOf(1); + expect(result.errors[0]).to.match(/^App token:/); + expect(result.botInfo).to.deep.equal({ botId: 'B1', userId: 'U1', team: 'Team', teamId: 'T1' }); + }); + + it('collects two errors and no bot info when both tokens are bad', async function() { + sinon.stub(WebClient.prototype, 'apiCall').resolves({ ok: false, error: 'invalid_auth' }); + + const result = await validateSlackTokens('bad-app-token', 'bad-bot-token'); + + expect(result.valid).to.equal(false); + expect(result.errors).to.have.lengthOf(2); + expect(result.errors[0]).to.match(/^App token:/); + expect(result.errors[1]).to.match(/^Bot token:/); + expect(result.botInfo).to.equal(null); + }); + }); +}); diff --git a/test/spotify-validator.test.mjs b/test/spotify-validator.test.mjs new file mode 100644 index 0000000..d05f778 --- /dev/null +++ b/test/spotify-validator.test.mjs @@ -0,0 +1,110 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const { validateSpotifyCredentials } = require('../lib/spotify-validator.js'); + +function mockJsonResponse(ok, body) { + return { + ok, + json: sinon.stub().resolves(body) + }; +} + +describe('Spotify credentials validator', function() { + afterEach(function() { + sinon.restore(); + }); + + it('rejects a missing client ID without calling the network', async function() { + sinon.stub(global, 'fetch'); + + const result = await validateSpotifyCredentials('', 'secret'); + + expect(result).to.deep.equal({ valid: false, error: 'Client ID is required' }); + expect(global.fetch.called).to.equal(false); + }); + + it('rejects a whitespace-only client ID', async function() { + const result = await validateSpotifyCredentials(' ', 'secret'); + + expect(result.valid).to.equal(false); + expect(result.error).to.equal('Client ID is required'); + }); + + it('rejects a missing client secret without calling the network', async function() { + sinon.stub(global, 'fetch'); + + const result = await validateSpotifyCredentials('client-id', ''); + + expect(result).to.deep.equal({ valid: false, error: 'Client Secret is required' }); + expect(global.fetch.called).to.equal(false); + }); + + it('requests a client-credentials token with basic auth', async function() { + const fetchStub = sinon.stub(global, 'fetch').resolves( + mockJsonResponse(true, { access_token: 'token-123' }) + ); + + await validateSpotifyCredentials('my-id', 'my-secret'); + + expect(fetchStub.calledOnce).to.equal(true); + const [url, options] = fetchStub.firstCall.args; + expect(url).to.equal('https://accounts.spotify.com/api/token'); + expect(options.method).to.equal('POST'); + expect(options.body).to.equal('grant_type=client_credentials'); + const expectedAuth = 'Basic ' + Buffer.from('my-id:my-secret').toString('base64'); + expect(options.headers.Authorization).to.equal(expectedAuth); + }); + + it('returns valid when Spotify issues an access token', async function() { + sinon.stub(global, 'fetch').resolves( + mockJsonResponse(true, { access_token: 'token-123' }) + ); + + const result = await validateSpotifyCredentials('client-id', 'client-secret'); + + expect(result).to.deep.equal({ valid: true }); + }); + + it('returns the error_description when Spotify rejects the credentials', async function() { + sinon.stub(global, 'fetch').resolves( + mockJsonResponse(false, { error: 'invalid_client', error_description: 'Invalid client secret' }) + ); + + const result = await validateSpotifyCredentials('client-id', 'wrong-secret'); + + expect(result.valid).to.equal(false); + expect(result.error).to.equal('Invalid client secret'); + }); + + it('falls back to the error code when there is no error_description', async function() { + sinon.stub(global, 'fetch').resolves( + mockJsonResponse(false, { error: 'invalid_client' }) + ); + + const result = await validateSpotifyCredentials('client-id', 'wrong-secret'); + + expect(result.valid).to.equal(false); + expect(result.error).to.equal('invalid_client'); + }); + + it('falls back to a generic message when the response has no error details', async function() { + sinon.stub(global, 'fetch').resolves(mockJsonResponse(false, {})); + + const result = await validateSpotifyCredentials('client-id', 'wrong-secret'); + + expect(result.valid).to.equal(false); + expect(result.error).to.equal('Invalid credentials'); + }); + + it('returns a network error message when the fetch call throws', async function() { + sinon.stub(global, 'fetch').rejects(new Error('getaddrinfo ENOTFOUND accounts.spotify.com')); + + const result = await validateSpotifyCredentials('client-id', 'client-secret'); + + expect(result.valid).to.equal(false); + expect(result.error).to.equal('getaddrinfo ENOTFOUND accounts.spotify.com'); + }); +});