Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 203 additions & 0 deletions src/servers/supportedVersions/main.main.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,173 @@ describe('supportedVersions/main.ts', () => {
});
});

// ========== EXCEPTION UNIQUE ID SCOPE (SERVER-SIGNED PATH) ==========
describe('Exception uniqueId scope resolution on server-signed path', () => {
const tenantSupportedVersions = (overrides?: Partial<SupportedVersions>) =>
createMockSupportedVersions({
versions: [
{
version: '8.6.0',
expiration: new Date(Date.now() + 86400000),
},
],
exceptions: {
domain: 'test.rocket.chat',
uniqueId: 'tenant-unique-id',
versions: [
{
version: '7.13.9',
expiration: new Date(Date.now() + 86400000),
},
],
},
enforcementStartDate: '2023-12-15T00:00:00Z',
...overrides,
});

it('should fetch uniqueID before validating when /api/info omits uniqueId and exception is uniqueId-scoped', async () => {
const mockServer = createMockServer({ version: '7.13' });
const mockServerInfo = createMockServerInfo({
version: '7.13',
uniqueId: undefined,
});
selectMock.mockReturnValue(mockServer);
axiosMock.get = jest
.fn()
.mockResolvedValueOnce({ data: mockServerInfo })
.mockResolvedValueOnce({
data: { settings: [{ value: 'tenant-unique-id' }] },
});

(jest.spyOn(jsonwebtoken, 'verify') as jest.Mock).mockReturnValue(
tenantSupportedVersions()
);

await updateSupportedVersionsData(mockServer.url);

const uniqueIdDispatch = dispatchMock.mock.calls.find(
([action]) =>
(action as any).type === WEBVIEW_SERVER_UNIQUE_ID_UPDATED &&
(action as any).payload?.uniqueID === 'tenant-unique-id'
);
expect(uniqueIdDispatch).toBeDefined();

const verdictDispatch = dispatchMock.mock.calls.find(
([action]) =>
(action as any).type === WEBVIEW_SERVER_IS_SUPPORTED_VERSION
);
expect(verdictDispatch?.[0]).toEqual({
type: WEBVIEW_SERVER_IS_SUPPORTED_VERSION,
payload: {
url: mockServer.url,
isSupportedVersion: true,
},
});
});

it('should re-fetch uniqueID when persisted value is stale and honor the exception', async () => {
const mockServer = createMockServer({
version: '7.13',
uniqueID: 'stale-unique-id',
});
const mockServerInfo = createMockServerInfo({
version: '7.13',
uniqueId: undefined,
});
selectMock.mockReturnValue(mockServer);
axiosMock.get = jest
.fn()
.mockResolvedValueOnce({ data: mockServerInfo })
.mockResolvedValueOnce({
data: { settings: [{ value: 'tenant-unique-id' }] },
});

(jest.spyOn(jsonwebtoken, 'verify') as jest.Mock).mockReturnValue(
tenantSupportedVersions()
);

await updateSupportedVersionsData(mockServer.url);

const verdictDispatch = dispatchMock.mock.calls.find(
([action]) =>
(action as any).type === WEBVIEW_SERVER_IS_SUPPORTED_VERSION
);
expect(verdictDispatch?.[0]).toEqual({
type: WEBVIEW_SERVER_IS_SUPPORTED_VERSION,
payload: {
url: mockServer.url,
isSupportedVersion: true,
},
});
});

it('should reject the exception when the fetched uniqueID does not match the exception scope', async () => {
const mockServer = createMockServer({ version: '7.13' });
const mockServerInfo = createMockServerInfo({
version: '7.13',
uniqueId: undefined,
});
selectMock.mockReturnValue(mockServer);
axiosMock.get = jest
.fn()
.mockResolvedValueOnce({ data: mockServerInfo })
.mockResolvedValueOnce({
data: { settings: [{ value: 'other-tenant-id' }] },
});

(jest.spyOn(jsonwebtoken, 'verify') as jest.Mock).mockReturnValue(
tenantSupportedVersions()
);

await updateSupportedVersionsData(mockServer.url);

const verdictDispatch = dispatchMock.mock.calls.find(
([action]) =>
(action as any).type === WEBVIEW_SERVER_IS_SUPPORTED_VERSION
);
expect(verdictDispatch?.[0]).toEqual({
type: WEBVIEW_SERVER_IS_SUPPORTED_VERSION,
payload: {
url: mockServer.url,
isSupportedVersion: false,
},
});
});

it('should not fetch uniqueID when the persisted value already matches the exception scope', async () => {
const mockServer = createMockServer({
version: '7.13',
uniqueID: 'tenant-unique-id',
});
const mockServerInfo = createMockServerInfo({
version: '7.13',
uniqueId: undefined,
});
selectMock.mockReturnValue(mockServer);
axiosMock.get = jest.fn().mockResolvedValueOnce({ data: mockServerInfo });

(jest.spyOn(jsonwebtoken, 'verify') as jest.Mock).mockReturnValue(
tenantSupportedVersions()
);

await updateSupportedVersionsData(mockServer.url);

expect(axiosMock.get).toHaveBeenCalledTimes(1);

const verdictDispatch = dispatchMock.mock.calls.find(
([action]) =>
(action as any).type === WEBVIEW_SERVER_IS_SUPPORTED_VERSION
);
expect(verdictDispatch?.[0]).toEqual({
type: WEBVIEW_SERVER_IS_SUPPORTED_VERSION,
payload: {
url: mockServer.url,
isSupportedVersion: true,
},
});
});
});

// ========== CLOUD FETCH PATH TESTS ==========
describe('Cloud Fetch Path', () => {
it('should fetch cloud info with retries when server fails', async () => {
Expand Down Expand Up @@ -837,6 +1004,42 @@ describe('supportedVersions/main.ts', () => {
expect(result.supported).toBe(true);
});

it('should honor exceptions when exceptions.domain differs only by letter case', async () => {
const futureDate = new Date(Date.now() + 86400000);
const supportedVersions: SupportedVersions = {
enforcementStartDate: new Date(Date.now() - 86400000).toISOString(),
timestamp: new Date().toISOString(),
versions: [
{
version: '8.4.0',
expiration: futureDate,
},
],
exceptions: {
domain: 'Open.Rocket.Chat',
uniqueId: 'test-unique-id',
versions: [
{
version: '8.5.1',
expiration: futureDate,
},
],
},
};

const result = await isServerVersionSupported(
{
url: 'https://open.rocket.chat/',
version: '8.5',
title: 'Rocket.Chat Open',
uniqueID: 'test-unique-id',
} as any,
supportedVersions
);

expect(result.supported).toBe(true);
});

it('should support sha-prefixed exception versions by git commit hash', async () => {
const futureDate = new Date(Date.now() + 86400000);
const supportedVersions: SupportedVersions = {
Expand Down
40 changes: 35 additions & 5 deletions src/servers/supportedVersions/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,8 @@ export const isServerVersionSupported = async (
} catch {
hostname = undefined;
}
if (exceptions.domain && exceptions.domain !== hostname) {
// DNS names are case-insensitive; URL.hostname is already lowercased.
if (exceptions.domain && exceptions.domain.toLowerCase() !== hostname) {
exceptionScopeMatches = false;
}
if (exceptions.uniqueId && exceptions.uniqueId !== server.uniqueID) {
Expand Down Expand Up @@ -502,6 +503,30 @@ const dispatchSupportedVersionsUpdated = (
});
};

// When a supported-versions payload carries a uniqueId-scoped exceptions
// block, the scope check requires server.uniqueID to equal
// exceptions.uniqueId. The server-signed fast path returns before the general
// getUniqueId call, and /api/info does not include uniqueId, so a missing or
// stale persisted uniqueID would permanently disqualify the tenant's own
// exceptions. Resolve it from the server before validating (and persist it
// for future runs, including offline cache validation).
const withExceptionScopeUniqueId = async (
serverView: Server,
supportedVersionsData: SupportedVersions | undefined,
versionForUniqueId: string
): Promise<Server> => {
const exceptionsUniqueId = supportedVersionsData?.exceptions?.uniqueId;
if (!exceptionsUniqueId || serverView.uniqueID === exceptionsUniqueId) {
return serverView;
}
const freshUniqueId = await getUniqueId(serverView.url, versionForUniqueId)
.then(dispatchUniqueIdUpdated(serverView.url))
.catch(logRequestError('unique ID'));
return freshUniqueId
? { ...serverView, uniqueID: freshUniqueId }
: serverView;
};

// Per-URL request generation counter. Each call to updateSupportedVersionsData
// bumps the counter and captures its own generation. Awaited steps inside the
// call check whether their generation is still current before dispatching, so
Expand Down Expand Up @@ -540,9 +565,8 @@ export const updateSupportedVersionsData = async (
// Build a server view that reflects the freshly-fetched version and
// uniqueId when available, so every downstream support check
// (server/cloud/cache/builtin) is evaluated against the same authoritative
// identity. /api/info returns `uniqueId`, which is required for exception
// scope matching to honor server-source exceptions on first launch (when
// persisted server.uniqueID may be undefined).
// identity. Current servers do NOT include `uniqueId` in /api/info, so the
// fallback to persisted server.uniqueID is the common path.
const serverWithFreshVersion: Server = serverInfoResult
? {
...server,
Expand All @@ -567,9 +591,15 @@ export const updateSupportedVersionsData = async (
const serverSupportedVersions = decodeSupportedVersions(serverEncoded);
if (isStale()) return;
saveToCache(serverUrl, serverSupportedVersions);
const supported = await isServerVersionSupported(
const serverForValidation = await withExceptionScopeUniqueId(
serverWithFreshVersion,
serverSupportedVersions,
serverInfoResult.version
);
if (isStale()) return;
const supported = await isServerVersionSupported(
serverForValidation,
serverSupportedVersions,
freshCommitHash
);
if (isStale()) return;
Expand Down
Loading