TokenSource - add some more comprehensive tests (#1894)

This commit is contained in:
Ryan Gaus
2026-04-20 13:33:23 -04:00
committed by GitHub
parent 6696692732
commit 9da47f8482
4 changed files with 378 additions and 21 deletions

View File

@@ -0,0 +1,337 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { describe, expect, it, vi } from 'vitest';
import { sleep } from '../utils';
import { TokenSource } from './TokenSource';
import { TOKENS } from './test-tokens';
import { TokenSourceFetchOptions, TokenSourceResponseObject } from './types';
const EXAMPLE_FETCH_OPTIONS: TokenSourceFetchOptions = {
roomName: 'room name',
participantName: 'participant name',
participantIdentity: 'participant identity',
participantMetadata: '{"example": "metadata here"}',
participantAttributes: {},
agentName: 'agent name',
agentMetadata: '{"example": "agent metadata here"}',
};
const EXAMPLE_TOKEN_ENDPOINT_RESPONSE_JSON = {
server_url: 'wss://localhost:7000',
participant_token: 'bogus token',
};
function makeResponseObject(token: string = TOKENS.VALID): TokenSourceResponseObject {
return {
serverUrl: 'wss://localhost:7000',
participantToken: token,
};
}
function mockGlobalFetchResponse(
responseJson: any = EXAMPLE_TOKEN_ENDPOINT_RESPONSE_JSON,
responseOptions?: ResponseInit,
) {
return mockGlobalFetchResponses([{ responseJson, responseOptions }]);
}
function mockGlobalFetchResponses(
responses: Array<{
responseJson?: any;
responseOptions?: ResponseInit;
}>,
) {
const oldFetch = globalThis.fetch;
const fetchMock = vi.fn();
for (const {
responseJson = EXAMPLE_TOKEN_ENDPOINT_RESPONSE_JSON,
responseOptions,
} of responses) {
const response = new Response(JSON.stringify(responseJson), {
status: 200,
headers: { 'Content-Type': 'application/json' },
...responseOptions,
});
fetchMock.mockResolvedValueOnce(response);
}
globalThis.fetch = fetchMock;
const teardown = () => {
globalThis.fetch = oldFetch;
};
return { fetchMock: fetchMock.mock, teardown };
}
describe('TokenSource.endpoint', () => {
it('tests happy path with all options', async () => {
const { teardown, fetchMock } = mockGlobalFetchResponse();
try {
const tokenSource = TokenSource.endpoint('https://example.com/my/token/endpoint');
await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
expect(fetchMock.lastCall).toStrictEqual([
'https://example.com/my/token/endpoint',
{
method: 'POST',
body: JSON.stringify({
room_name: 'room name',
participant_name: 'participant name',
participant_identity: 'participant identity',
participant_metadata: '{"example": "metadata here"}',
room_config: {
agents: [
{
agent_name: 'agent name',
metadata: '{"example": "agent metadata here"}',
},
],
},
}),
headers: { 'Content-Type': 'application/json' },
},
]);
} finally {
teardown();
}
});
it('tests happy path with no options', async () => {
const { teardown, fetchMock } = mockGlobalFetchResponse();
try {
const tokenSource = TokenSource.endpoint('https://example.com/my/token/endpoint');
await tokenSource.fetch({});
expect(fetchMock.lastCall).toStrictEqual([
'https://example.com/my/token/endpoint',
{
method: 'POST',
body: JSON.stringify({}),
headers: { 'Content-Type': 'application/json' },
},
]);
} finally {
teardown();
}
});
it('throws on non-200 response', async () => {
const { teardown } = mockGlobalFetchResponse(
{ error: 'forbidden' },
{ status: 403, headers: { 'Content-Type': 'application/json' } },
);
try {
const tokenSource = TokenSource.endpoint('https://example.com/my/token/endpoint');
await expect(tokenSource.fetch(EXAMPLE_FETCH_OPTIONS)).rejects.toThrow(/received 403/);
} finally {
teardown();
}
});
it('merges custom headers from EndpointOptions', async () => {
const { teardown, fetchMock } = mockGlobalFetchResponse();
try {
const tokenSource = TokenSource.endpoint('https://example.com/my/token/endpoint', {
headers: { Authorization: 'Bearer my-token', 'X-Custom': 'value' },
});
await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
expect((fetchMock.lastCall![1] as RequestInit).headers).toStrictEqual({
'Content-Type': 'application/json',
Authorization: 'Bearer my-token',
'X-Custom': 'value',
});
} finally {
teardown();
}
});
it('sends only provided fields in request body', async () => {
const { teardown, fetchMock } = mockGlobalFetchResponse();
try {
const tokenSource = TokenSource.endpoint('https://example.com/my/token/endpoint');
await tokenSource.fetch({ roomName: 'my-room' });
const body = JSON.parse((fetchMock.lastCall![1] as RequestInit).body as string);
expect(body.room_name).toStrictEqual('my-room');
// Agent-related fields should not be present since they weren't provided
expect(body.room_config).toBeUndefined();
} finally {
teardown();
}
});
it('deserializes response with extra unknown fields without error', async () => {
const { teardown } = mockGlobalFetchResponse({
server_url: 'wss://localhost:7000',
participant_token: TOKENS.VALID,
some_future_field: 'should be ignored',
another_unknown: 42,
});
try {
const tokenSource = TokenSource.endpoint('https://example.com/my/token/endpoint');
const result = await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
expect(result.serverUrl).toStrictEqual('wss://localhost:7000');
expect(result.participantToken).toStrictEqual(TOKENS.VALID);
} finally {
teardown();
}
});
});
describe('TokenSource.custom', () => {
it('calls custom function and resolves result', async () => {
const customFn = vi.fn().mockResolvedValue(makeResponseObject());
const tokenSource = TokenSource.custom(customFn);
const result = await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
expect(customFn).toHaveBeenCalledWith(EXAMPLE_FETCH_OPTIONS);
expect(result.serverUrl).toStrictEqual('wss://localhost:7000');
expect(result.participantToken).toStrictEqual(TOKENS.VALID);
});
it('deserializes response with extra unknown fields without error', async () => {
const customFn = vi.fn().mockResolvedValue({
...makeResponseObject(),
someFutureField: 'should be ignored',
anotherUnknown: 42,
});
const tokenSource = TokenSource.custom(customFn);
const result = await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
expect(result.serverUrl).toStrictEqual('wss://localhost:7000');
expect(result.participantToken).toStrictEqual(TOKENS.VALID);
});
});
describe('TokenSourceConfigurable caching behavior (via TokenSource.custom)', () => {
it('returns cached value on second call with same options', async () => {
const customFn = vi.fn().mockResolvedValue(makeResponseObject());
const tokenSource = TokenSource.custom(customFn);
const result1 = await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
const result2 = await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
expect(customFn).toHaveBeenCalledTimes(1);
expect(result1).toStrictEqual(result2);
});
it('refetches when fetch options change', async () => {
const customFn = vi.fn().mockResolvedValue(makeResponseObject());
const tokenSource = TokenSource.custom(customFn);
await tokenSource.fetch({ roomName: 'room-1' });
await tokenSource.fetch({ roomName: 'room-2' });
expect(customFn).toHaveBeenCalledTimes(2);
expect(customFn).toHaveBeenNthCalledWith(1, { roomName: 'room-1' });
expect(customFn).toHaveBeenNthCalledWith(2, { roomName: 'room-2' });
});
it('refetches when force is true even with same options', async () => {
const customFn = vi.fn().mockResolvedValue(makeResponseObject());
const tokenSource = TokenSource.custom(customFn);
await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS, true);
expect(customFn).toHaveBeenCalledTimes(2);
});
it('refetches when cached token is expired', async () => {
const customFn = vi
.fn()
.mockResolvedValueOnce(makeResponseObject(TOKENS.EXP_IN_PAST))
.mockResolvedValueOnce(makeResponseObject(TOKENS.VALID));
const tokenSource = TokenSource.custom(customFn);
await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
// Should have called twice because the first token was expired
expect(customFn).toHaveBeenCalledTimes(2);
});
it('caches across multiple calls when token remains valid', async () => {
const customFn = vi.fn().mockResolvedValue(makeResponseObject());
const tokenSource = TokenSource.custom(customFn);
await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
expect(customFn).toHaveBeenCalledTimes(1);
});
it('refetches when any single option field changes', async () => {
const customFn = vi.fn().mockResolvedValue(makeResponseObject());
const tokenSource = TokenSource.custom(customFn);
const baseOptions: TokenSourceFetchOptions = {
roomName: 'room',
participantName: 'name',
participantIdentity: 'identity',
participantMetadata: 'meta',
participantAttributes: { key: 'value' },
agentName: 'agent',
agentMetadata: 'agent-meta',
};
await tokenSource.fetch(baseOptions);
expect(customFn).toHaveBeenCalledTimes(1);
// Changing participantIdentity should invalidate cache
await tokenSource.fetch({ ...baseOptions, participantIdentity: 'different-identity' });
expect(customFn).toHaveBeenCalledTimes(2);
});
it('getCachedResponseJwtPayload returns null before first fetch', () => {
const tokenSource = TokenSource.custom(async () => makeResponseObject());
expect(tokenSource.getCachedResponseJwtPayload()).toBeNull();
});
it('getCachedResponseJwtPayload returns decoded payload after fetch', async () => {
const tokenSource = TokenSource.custom(async () => makeResponseObject());
await tokenSource.fetch(EXAMPLE_FETCH_OPTIONS);
const payload = tokenSource.getCachedResponseJwtPayload();
expect(payload).not.toBeNull();
expect(payload!.sub).toStrictEqual('1234567890');
expect(payload!.roomConfig?.name).toStrictEqual('test room name');
});
it('serializes concurrent fetches via mutex', async () => {
let concurrentCalls = 0;
let maxConcurrentCalls = 0;
const customFn = vi.fn().mockImplementation(async () => {
concurrentCalls += 1;
maxConcurrentCalls = Math.max(maxConcurrentCalls, concurrentCalls);
// Simulate async work
await sleep(10);
concurrentCalls -= 1;
return makeResponseObject();
});
const tokenSource = TokenSource.custom(customFn);
// Launch concurrent fetches with different options so caching doesn't short-circuit
await Promise.all([
tokenSource.fetch({ roomName: 'room-1' }),
tokenSource.fetch({ roomName: 'room-2' }),
tokenSource.fetch({ roomName: 'room-3' }),
]);
// The mutex should ensure only one fetch runs at a time
expect(maxConcurrentCalls).toStrictEqual(1);
expect(customFn).toHaveBeenCalledTimes(3);
});
});

View File

@@ -0,0 +1,28 @@
// Test JWTs created for test purposes only.
// None of these actually auth against anything.
export const TOKENS = {
// Nbf date set at 1234567890 seconds (Fri Feb 13 2009 23:31:30 GMT+0000)
// Exp date set at 9876543210 seconds (Fri Dec 22 2282 20:13:30 GMT+0000)
// A dummy roomConfig value is also set, with room_config.name = "test room name", and room_config.agents = [{"agentName": "test agent name","metadata":"test agent metadata"}]
VALID:
'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjo5ODc2NTQzMjEwLCJuYmYiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MCwicm9vbUNvbmZpZyI6eyJuYW1lIjoidGVzdCByb29tIG5hbWUiLCJlbXB0eVRpbWVvdXQiOjAsImRlcGFydHVyZVRpbWVvdXQiOjAsIm1heFBhcnRpY2lwYW50cyI6MCwibWluUGxheW91dERlbGF5IjowLCJtYXhQbGF5b3V0RGVsYXkiOjAsInN5bmNTdHJlYW1zIjpmYWxzZSwiYWdlbnRzIjpbeyJhZ2VudE5hbWUiOiJ0ZXN0IGFnZW50IG5hbWUiLCJtZXRhZGF0YSI6InRlc3QgYWdlbnQgbWV0YWRhdGEifV0sIm1ldGFkYXRhIjoiIn19.EDetpHG8cSubaApzgWJaQrpCiSy9KDBlfCfVdIydbQ-_CHiNnXOK_f_mCJbTf9A-duT1jmvPOkLrkkWFT60XPQ',
// Nbf date set at 9876543210 seconds (Fri Dec 22 2282 20:13:30 GMT+0000)
// Exp date set at 9876543211 seconds (Fri Dec 22 2282 20:13:31 GMT+0000)
NBF_IN_FUTURE:
'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjo5ODc2NTQzMjExLCJuYmYiOjk4NzY1NDMyMTAsImlhdCI6MTIzNDU2Nzg5MH0.DcMmdKrD76eJg7IUBZqoTRDvBaXtCcwtuE5h7IwVXhG_6nvgxN_ix30_AmLgnYhvhkN-x9dTRPoHg-CME72AbQ',
// Nbf date set at 1234567890 seconds (Fri Feb 13 2009 23:31:30 GMT+0000)
// Exp date set at 1234567891 seconds (Fri Feb 13 2009 23:31:31 GMT+0000)
EXP_IN_PAST:
'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjoxMjM0NTY3ODkxLCJuYmYiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MH0.OYP1NITayotBYt0mioInLJmaIM0bHyyR-yG6iwKyQDzhoGha15qbsc7dOJlzz4za1iW5EzCgjc2_xGxqaSu5XA',
// This token contains extra fields embedded within which aren't part of the protobuf data
// structure. These could be new fields added in a future protocol version, etc.
//
// Nbf date set at 1234567890 seconds (Fri Feb 13 2009 23:31:30 GMT+0000)
// Exp date set at 9876543210 seconds (Fri Dec 22 2282 20:13:30 GMT+0000)
// A dummy roomConfig value is also set, with room_config.name = "test room name", room_config.extraField = "extra field value", and room_config.agents = [{"agentName": "test agent name","metadata":"test agent metadata","extraField":"extra field value"}]
EXTRA_FIELDS:
'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjo5ODc2NTQzMjEwLCJuYmYiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MCwicm9vbUNvbmZpZyI6eyJuYW1lIjoidGVzdCByb29tIG5hbWUiLCJlbXB0eVRpbWVvdXQiOjAsImRlcGFydHVyZVRpbWVvdXQiOjAsIm1heFBhcnRpY2lwYW50cyI6MCwibWluUGxheW91dERlbGF5IjowLCJtYXhQbGF5b3V0RGVsYXkiOjAsInN5bmNTdHJlYW1zIjpmYWxzZSwiYWdlbnRzIjpbeyJhZ2VudE5hbWUiOiJ0ZXN0IGFnZW50IG5hbWUiLCJtZXRhZGF0YSI6InRlc3QgYWdlbnQgbWV0YWRhdGEiLCJleHRyYUZpZWxkIjoiZXh0cmEgZmllbGQgdmFsdWUifV0sIm1ldGFkYXRhIjoiIiwiZXh0cmFGaWVsZCI6ImV4dHJhIGZpZWxkIHZhbHVlIn19Cg.EDetpHG8cSubaApzgWJaQrpCiSy9KDBlfCfVdIydbQ-_CHiNnXOK_f_mCJbTf9A-duT1jmvPOkLrkkWFT60XPQ',
};

View File

@@ -1,27 +1,8 @@
import { TokenSourceResponse } from '@livekit/protocol';
import { describe, expect, it } from 'vitest';
import { TOKENS } from './test-tokens';
import { areTokenSourceFetchOptionsEqual, decodeTokenPayload, isResponseTokenValid } from './utils';
// Test JWTs created for test purposes only.
// None of these actually auth against anything.
const TOKENS = {
// Nbf date set at 1234567890 seconds (Fri Feb 13 2009 23:31:30 GMT+0000)
// Exp date set at 9876543210 seconds (Fri Dec 22 2282 20:13:30 GMT+0000)
// A dummy roomConfig value is also set, with room_config.name = "test room name", and room_config.agents = [{"agentName": "test agent name","metadata":"test agent metadata"}]
VALID:
'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjo5ODc2NTQzMjEwLCJuYmYiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MCwicm9vbUNvbmZpZyI6eyJuYW1lIjoidGVzdCByb29tIG5hbWUiLCJlbXB0eVRpbWVvdXQiOjAsImRlcGFydHVyZVRpbWVvdXQiOjAsIm1heFBhcnRpY2lwYW50cyI6MCwibWluUGxheW91dERlbGF5IjowLCJtYXhQbGF5b3V0RGVsYXkiOjAsInN5bmNTdHJlYW1zIjpmYWxzZSwiYWdlbnRzIjpbeyJhZ2VudE5hbWUiOiJ0ZXN0IGFnZW50IG5hbWUiLCJtZXRhZGF0YSI6InRlc3QgYWdlbnQgbWV0YWRhdGEifV0sIm1ldGFkYXRhIjoiIn19.EDetpHG8cSubaApzgWJaQrpCiSy9KDBlfCfVdIydbQ-_CHiNnXOK_f_mCJbTf9A-duT1jmvPOkLrkkWFT60XPQ',
// Nbf date set at 9876543210 seconds (Fri Dec 22 2282 20:13:30 GMT+0000)
// Exp date set at 9876543211 seconds (Fri Dec 22 2282 20:13:31 GMT+0000)
NBF_IN_FUTURE:
'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjo5ODc2NTQzMjExLCJuYmYiOjk4NzY1NDMyMTAsImlhdCI6MTIzNDU2Nzg5MH0.DcMmdKrD76eJg7IUBZqoTRDvBaXtCcwtuE5h7IwVXhG_6nvgxN_ix30_AmLgnYhvhkN-x9dTRPoHg-CME72AbQ',
// Nbf date set at 1234567890 seconds (Fri Feb 13 2009 23:31:30 GMT+0000)
// Exp date set at 1234567891 seconds (Fri Feb 13 2009 23:31:31 GMT+0000)
EXP_IN_PAST:
'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjoxMjM0NTY3ODkxLCJuYmYiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MH0.OYP1NITayotBYt0mioInLJmaIM0bHyyR-yG6iwKyQDzhoGha15qbsc7dOJlzz4za1iW5EzCgjc2_xGxqaSu5XA',
};
describe('isResponseTokenValid', () => {
it('should find a valid jwt not expired', () => {
const isValid = isResponseTokenValid(
@@ -60,6 +41,17 @@ describe('decodeTokenPayload', () => {
expect(payload.roomConfig?.agents![0].agentName).toBe('test agent name');
expect(payload.roomConfig?.agents![0].metadata).toBe('test agent metadata');
});
it('should extract roomconfig metadata from a token with extra fields', () => {
const payload = decodeTokenPayload(TOKENS.EXTRA_FIELDS);
expect(payload.roomConfig?.name).toBe('test room name');
expect(payload.roomConfig?.agents).toHaveLength(1);
expect(payload.roomConfig?.agents![0].agentName).toBe('test agent name');
expect(payload.roomConfig?.agents![0].metadata).toBe('test agent metadata');
// Make sure the extra fields aren't in the payload, just the ones in the protobuf
expect((payload.roomConfig as any)?.extraField).toBeUndefined();
expect((payload.roomConfig?.agents![0] as any)?.extraField).toBeUndefined();
});
});
describe('areTokenSourceFetchOptionsEqual', () => {

View File

@@ -28,7 +28,7 @@
"ignoreDeprecations": "5.0",
"plugins": [{ "name": "@livekit/throws-transformer" }],
},
"exclude": ["dist", "**/*.test.ts", "test/**"],
"exclude": ["dist", "**/*.test.ts", "test/**", "src/room/token-source/test-tokens.ts"],
"include": ["src/**/*.ts"],
"typedocOptions": {
"entryPoints": ["src/index.ts"],