1014 lines
29 KiB
TypeScript
1014 lines
29 KiB
TypeScript
import type { ReactiveControllerHost } from 'lit';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { mock } from 'vitest-mock-extended';
|
|
|
|
import {
|
|
getSignedURLErrorText,
|
|
SignedURLController,
|
|
} from '../../src/components-lib/signed-url-controller';
|
|
import { homeAssistantGetSignedURLIfNecessary } from '../../src/ha/sign-path';
|
|
import { createProxiedEndpointIfNecessary } from '../../src/ha/web-proxy';
|
|
import type { Endpoint } from '../../src/types';
|
|
import { createHASS, flushPromises } from '../test-utils';
|
|
|
|
vi.mock('../../src/ha/sign-path');
|
|
vi.mock('../../src/ha/web-proxy');
|
|
|
|
const createEndpoint = (url: string, sign?: boolean): Endpoint => ({
|
|
endpoint: url,
|
|
...(sign !== undefined && { sign }),
|
|
});
|
|
|
|
// @vitest-environment jsdom
|
|
describe('SignedURLController', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('should initialize correctly', () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const controller = new SignedURLController(host, () => ({}));
|
|
|
|
expect(host.addController).toHaveBeenCalledWith(controller);
|
|
expect(controller.getValue()).toBeNull();
|
|
});
|
|
|
|
it('should fetch proxied url successfully', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
const proxyConfig = {
|
|
enabled: true,
|
|
dynamic: true,
|
|
ssl_verification: true,
|
|
ssl_ciphers: 'default' as const,
|
|
live: true,
|
|
media: true,
|
|
};
|
|
|
|
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({
|
|
endpoint: 'http://proxied-url.com',
|
|
sign: true,
|
|
});
|
|
|
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(
|
|
'http://signed-proxied-url.com',
|
|
);
|
|
|
|
const controller = new SignedURLController(host, () => ({
|
|
hass,
|
|
endpoint: createEndpoint('http://test-url.com'),
|
|
proxyConfig,
|
|
}));
|
|
|
|
controller.hostUpdate();
|
|
|
|
expect(controller.getValue()).toBeNull();
|
|
|
|
expect(createProxiedEndpointIfNecessary).toHaveBeenCalledWith(
|
|
hass,
|
|
{ endpoint: 'http://test-url.com/', sign: false },
|
|
proxyConfig,
|
|
{ ttl: 86400, openLimit: 0 },
|
|
);
|
|
|
|
await flushPromises();
|
|
|
|
expect(homeAssistantGetSignedURLIfNecessary).toHaveBeenCalled();
|
|
expect(controller.getValue()).toBe('http://signed-proxied-url.com');
|
|
expect(host.requestUpdate).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not fetch if inputs are missing', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const controller = new SignedURLController(host, () => ({}));
|
|
|
|
controller.hostUpdate();
|
|
await flushPromises();
|
|
|
|
expect(createProxiedEndpointIfNecessary).not.toHaveBeenCalled();
|
|
expect(controller.getValue()).toBeNull();
|
|
});
|
|
|
|
it('should return unproxied url if proxy is disabled', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
|
|
const controller = new SignedURLController(host, () => ({
|
|
hass,
|
|
endpoint: createEndpoint('http://test-url.com'),
|
|
}));
|
|
|
|
controller.hostUpdate();
|
|
await flushPromises();
|
|
|
|
expect(createProxiedEndpointIfNecessary).not.toHaveBeenCalled();
|
|
expect(controller.getValue()).toBe('http://test-url.com');
|
|
});
|
|
|
|
it('should clear value if inputs turn invalid', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
let endpoint: Endpoint | undefined = createEndpoint('http://test-url.com');
|
|
|
|
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({
|
|
endpoint: 'http://proxied-url.com',
|
|
sign: false,
|
|
});
|
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(
|
|
'http://signed-proxied-url.com',
|
|
);
|
|
|
|
const controller = new SignedURLController(host, () => ({
|
|
hass,
|
|
endpoint,
|
|
proxyConfig: {
|
|
enabled: true,
|
|
dynamic: true,
|
|
ssl_verification: true,
|
|
ssl_ciphers: 'default' as const,
|
|
live: true,
|
|
media: true,
|
|
},
|
|
}));
|
|
|
|
controller.hostUpdate();
|
|
await flushPromises();
|
|
expect(controller.getValue()).toBe('http://signed-proxied-url.com');
|
|
|
|
// Make endpoint invalid
|
|
endpoint = undefined;
|
|
controller.hostUpdate();
|
|
|
|
expect(controller.getValue()).toBeNull();
|
|
});
|
|
|
|
it('should call valueChangeCallback when a new URL is successfully resolved', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
const valueChangeCallback = vi.fn();
|
|
|
|
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({
|
|
endpoint: 'http://proxied-url.com',
|
|
sign: false,
|
|
});
|
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(
|
|
'http://signed-proxied-url.com',
|
|
);
|
|
|
|
const controller = new SignedURLController(
|
|
host,
|
|
() => ({
|
|
hass,
|
|
endpoint: createEndpoint('http://test-url.com'),
|
|
proxyConfig: {
|
|
enabled: true,
|
|
dynamic: true,
|
|
ssl_verification: true,
|
|
ssl_ciphers: 'default' as const,
|
|
live: true,
|
|
media: true,
|
|
},
|
|
}),
|
|
valueChangeCallback,
|
|
);
|
|
|
|
controller.hostUpdate();
|
|
await flushPromises();
|
|
|
|
expect(controller.getValue()).toBe('http://signed-proxied-url.com');
|
|
expect(valueChangeCallback).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should not call valueChangeCallback on null proxy endpoint', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
const valueChangeCallback = vi.fn();
|
|
|
|
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(null);
|
|
|
|
const controller = new SignedURLController(
|
|
host,
|
|
() => ({
|
|
hass,
|
|
endpoint: createEndpoint('http://test-url.com'),
|
|
proxyConfig: {
|
|
enabled: true,
|
|
dynamic: true,
|
|
ssl_verification: true,
|
|
ssl_ciphers: 'default' as const,
|
|
live: true,
|
|
media: true,
|
|
},
|
|
}),
|
|
valueChangeCallback,
|
|
);
|
|
|
|
controller.hostUpdate();
|
|
await flushPromises();
|
|
|
|
expect(controller.getValue()).toBeNull();
|
|
expect(valueChangeCallback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not call valueChangeCallback on null signed URL', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
const valueChangeCallback = vi.fn();
|
|
|
|
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({
|
|
endpoint: 'http://proxied-url.com',
|
|
sign: true,
|
|
});
|
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(null);
|
|
|
|
const controller = new SignedURLController(
|
|
host,
|
|
() => ({
|
|
hass,
|
|
endpoint: createEndpoint('http://test-url.com'),
|
|
proxyConfig: {
|
|
enabled: true,
|
|
dynamic: true,
|
|
ssl_verification: true,
|
|
ssl_ciphers: 'default' as const,
|
|
live: true,
|
|
media: true,
|
|
},
|
|
}),
|
|
valueChangeCallback,
|
|
);
|
|
|
|
controller.hostUpdate();
|
|
await flushPromises();
|
|
|
|
expect(controller.getValue()).toBeNull();
|
|
expect(valueChangeCallback).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should ignore successful fetch if inputs become invalid', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
let endpoint: Endpoint | undefined = createEndpoint('http://test-url.com');
|
|
|
|
let resolveProxy: ((value: Endpoint) => void) | undefined;
|
|
vi.mocked(createProxiedEndpointIfNecessary).mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
resolveProxy = resolve;
|
|
}),
|
|
);
|
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue('stale');
|
|
|
|
const controller = new SignedURLController(host, () => ({
|
|
hass,
|
|
endpoint,
|
|
proxyConfig: {
|
|
enabled: true,
|
|
dynamic: true,
|
|
ssl_verification: true,
|
|
ssl_ciphers: 'default' as const,
|
|
live: true,
|
|
media: true,
|
|
},
|
|
}));
|
|
|
|
controller.hostUpdate();
|
|
|
|
endpoint = undefined;
|
|
controller.hostUpdate();
|
|
|
|
resolveProxy?.({ endpoint: 'old', sign: false });
|
|
await flushPromises();
|
|
|
|
expect(controller.getValue()).toBeNull();
|
|
expect(host.requestUpdate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should invalidate cache if input changes', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
let url = 'http://test-url.com';
|
|
|
|
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({
|
|
endpoint: 'http://proxied-url.com',
|
|
sign: false,
|
|
});
|
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(
|
|
'http://signed-proxied-url.com',
|
|
);
|
|
|
|
const proxyConfig = {
|
|
enabled: true,
|
|
dynamic: true,
|
|
ssl_verification: true,
|
|
ssl_ciphers: 'default' as const,
|
|
live: true,
|
|
media: true,
|
|
};
|
|
|
|
const controller = new SignedURLController(host, () => ({
|
|
hass,
|
|
endpoint: createEndpoint(url),
|
|
proxyConfig,
|
|
}));
|
|
|
|
controller.hostUpdate();
|
|
await flushPromises();
|
|
expect(controller.getValue()).toBe('http://signed-proxied-url.com');
|
|
|
|
// Change url
|
|
url = 'http://new-url.com';
|
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(
|
|
'http://new-signed-proxied-url.com',
|
|
);
|
|
|
|
controller.hostUpdate();
|
|
|
|
expect(controller.getValue()).toBeNull();
|
|
|
|
await flushPromises();
|
|
|
|
expect(controller.getValue()).toBe('http://new-signed-proxied-url.com');
|
|
});
|
|
|
|
it('should invalidate cache if proxy config changes', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
|
|
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({
|
|
endpoint: 'http://proxied-url.com',
|
|
sign: false,
|
|
});
|
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(
|
|
'http://signed-proxied-url.com',
|
|
);
|
|
|
|
let proxyConfig = {
|
|
enabled: true,
|
|
dynamic: true,
|
|
ssl_verification: true,
|
|
ssl_ciphers: 'default' as const,
|
|
live: true,
|
|
media: true,
|
|
};
|
|
|
|
const controller = new SignedURLController(host, () => ({
|
|
hass,
|
|
endpoint: createEndpoint('http://test-url.com'),
|
|
proxyConfig,
|
|
}));
|
|
|
|
controller.hostUpdate();
|
|
await flushPromises();
|
|
expect(controller.getValue()).toBe('http://signed-proxied-url.com');
|
|
|
|
// Change a relevant proxy config field.
|
|
proxyConfig = { ...proxyConfig, dynamic: false };
|
|
|
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(
|
|
'http://new-signed-proxied-url.com',
|
|
);
|
|
|
|
controller.hostUpdate();
|
|
expect(controller.getValue()).toBeNull();
|
|
|
|
await flushPromises();
|
|
expect(controller.getValue()).toBe('http://new-signed-proxied-url.com');
|
|
});
|
|
|
|
it('should handle errors gracefully', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
vi.mocked(createProxiedEndpointIfNecessary).mockRejectedValue(
|
|
new Error('test-error'),
|
|
);
|
|
|
|
const controller = new SignedURLController(host, () => ({
|
|
hass,
|
|
endpoint: createEndpoint('http://test-url.com'),
|
|
proxyConfig: {
|
|
enabled: true,
|
|
dynamic: true,
|
|
ssl_verification: true,
|
|
ssl_ciphers: 'default' as const,
|
|
live: true,
|
|
media: true,
|
|
},
|
|
}));
|
|
|
|
controller.hostUpdate();
|
|
await flushPromises();
|
|
|
|
expect(controller.getValue()).toBeNull();
|
|
expect(controller.getError()).toBe('proxy');
|
|
expect(host.requestUpdate).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should not retry after sign error with same inputs', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
|
|
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({
|
|
endpoint: 'http://proxied-url.com',
|
|
sign: true,
|
|
});
|
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(null);
|
|
|
|
const controller = new SignedURLController(host, () => ({
|
|
hass,
|
|
endpoint: createEndpoint('http://test-url.com'),
|
|
proxyConfig: {
|
|
enabled: true,
|
|
dynamic: true,
|
|
ssl_verification: true,
|
|
ssl_ciphers: 'default' as const,
|
|
},
|
|
}));
|
|
|
|
await controller.hostUpdate();
|
|
|
|
expect(controller.getValue()).toBeNull();
|
|
expect(controller.getError()).toBe('sign');
|
|
expect(homeAssistantGetSignedURLIfNecessary).toHaveBeenCalledTimes(1);
|
|
|
|
// Re-render with same inputs should NOT retry.
|
|
await controller.hostUpdate();
|
|
expect(homeAssistantGetSignedURLIfNecessary).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should set sign error when signing throws', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
|
|
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({
|
|
endpoint: 'http://proxied-url.com',
|
|
sign: true,
|
|
});
|
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockRejectedValue(
|
|
new Error('sign failure'),
|
|
);
|
|
|
|
const controller = new SignedURLController(host, () => ({
|
|
hass,
|
|
endpoint: createEndpoint('http://test-url.com'),
|
|
proxyConfig: {
|
|
enabled: true,
|
|
dynamic: true,
|
|
ssl_verification: true,
|
|
ssl_ciphers: 'default' as const,
|
|
},
|
|
}));
|
|
|
|
await controller.hostUpdate();
|
|
|
|
expect(controller.getValue()).toBeNull();
|
|
expect(controller.getError()).toBe('sign');
|
|
});
|
|
|
|
it('should not fetch again if within cache TTL', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
|
|
vi.useFakeTimers();
|
|
|
|
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({
|
|
endpoint: 'http://proxied-url.com',
|
|
sign: false,
|
|
});
|
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(
|
|
'http://signed-proxied-url.com',
|
|
);
|
|
|
|
const controller = new SignedURLController(host, () => ({
|
|
hass,
|
|
endpoint: createEndpoint('http://test-url.com'),
|
|
proxyConfig: {
|
|
enabled: true,
|
|
dynamic: true,
|
|
ssl_verification: true,
|
|
ssl_ciphers: 'default',
|
|
live: true,
|
|
media: true,
|
|
},
|
|
}));
|
|
|
|
controller.hostUpdate();
|
|
await flushPromises();
|
|
expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1);
|
|
|
|
// Call again within TTL, inputs unchanged
|
|
controller.hostUpdate();
|
|
await flushPromises();
|
|
expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1);
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('should re-fetch after cache TTL expires', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
|
|
vi.useFakeTimers();
|
|
|
|
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({
|
|
endpoint: 'http://proxied-url.com',
|
|
sign: false,
|
|
});
|
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(
|
|
'http://signed-proxied-url.com',
|
|
);
|
|
|
|
const controller = new SignedURLController(host, () => ({
|
|
hass,
|
|
endpoint: createEndpoint('http://test-url.com'),
|
|
proxyConfig: {
|
|
enabled: true,
|
|
dynamic: true,
|
|
ssl_verification: true,
|
|
ssl_ciphers: 'default' as const,
|
|
live: true,
|
|
media: true,
|
|
},
|
|
}));
|
|
|
|
controller.hostUpdate();
|
|
await flushPromises();
|
|
expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1);
|
|
|
|
// Advance time past the TTL (12 hours + 1 second).
|
|
vi.advanceTimersByTime(12 * 60 * 60 * 1000 + 1000);
|
|
|
|
controller.hostUpdate();
|
|
await flushPromises();
|
|
expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(2);
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('should not re-enter while re-signing after cache expiry', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
|
|
vi.useFakeTimers();
|
|
|
|
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({
|
|
endpoint: 'http://proxied-url.com',
|
|
sign: false,
|
|
});
|
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(
|
|
'http://signed-proxied-url.com',
|
|
);
|
|
|
|
const controller = new SignedURLController(host, () => ({
|
|
hass,
|
|
endpoint: createEndpoint('http://test-url.com'),
|
|
proxyConfig: {
|
|
enabled: true,
|
|
dynamic: true,
|
|
ssl_verification: true,
|
|
ssl_ciphers: 'default' as const,
|
|
},
|
|
}));
|
|
|
|
controller.hostUpdate();
|
|
await flushPromises();
|
|
expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1);
|
|
|
|
// Advance past the default TTL.
|
|
vi.advanceTimersByTime(12 * 60 * 60 * 1000 + 1000);
|
|
|
|
// First call after expiry starts a re-sign.
|
|
controller.hostUpdate();
|
|
expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(2);
|
|
|
|
// Subsequent calls while in-flight should NOT start another re-sign.
|
|
controller.hostUpdate();
|
|
controller.hostUpdate();
|
|
expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(2);
|
|
|
|
await flushPromises();
|
|
expect(controller.getValue()).toBe('http://signed-proxied-url.com');
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('should ignore successful fetch if request ID changed', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
let url = 'http://test-url.com';
|
|
|
|
let resolveProxy: ((value: Endpoint) => void) | undefined;
|
|
vi.mocked(createProxiedEndpointIfNecessary)
|
|
.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
resolveProxy = resolve;
|
|
}),
|
|
)
|
|
.mockReturnValue(
|
|
// Request 2 pending forever.
|
|
new Promise(() => {}),
|
|
);
|
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue('ok');
|
|
|
|
const controller = new SignedURLController(host, () => ({
|
|
hass,
|
|
endpoint: createEndpoint(url),
|
|
proxyConfig: {
|
|
enabled: true,
|
|
dynamic: true,
|
|
ssl_verification: true,
|
|
ssl_ciphers: 'default',
|
|
live: true,
|
|
media: true,
|
|
},
|
|
}));
|
|
|
|
// Request 1 starts.
|
|
controller.hostUpdate();
|
|
|
|
url = 'http://new-url.com';
|
|
// Request 2 starts
|
|
controller.hostUpdate();
|
|
|
|
resolveProxy?.({ endpoint: 'old', sign: false });
|
|
await flushPromises();
|
|
|
|
expect(controller.getValue()).toBeNull();
|
|
});
|
|
|
|
it('should ignore failed fetch if request ID changed', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
let url = 'http://test-url.com';
|
|
|
|
let rejectProxy: ((error: Error) => void) | undefined;
|
|
vi.mocked(createProxiedEndpointIfNecessary)
|
|
.mockReturnValueOnce(
|
|
new Promise((_, reject) => {
|
|
rejectProxy = reject;
|
|
}),
|
|
)
|
|
.mockReturnValue(
|
|
// Pending forever for request 2.
|
|
new Promise(() => {}),
|
|
);
|
|
|
|
const controller = new SignedURLController(host, () => ({
|
|
hass,
|
|
endpoint: createEndpoint(url),
|
|
proxyConfig: {
|
|
enabled: true,
|
|
dynamic: true,
|
|
ssl_verification: true,
|
|
ssl_ciphers: 'default',
|
|
live: true,
|
|
media: true,
|
|
},
|
|
}));
|
|
|
|
// Request 1 starts.
|
|
controller.hostUpdate();
|
|
|
|
url = 'http://new-url.com';
|
|
|
|
// Request 2 starts.
|
|
controller.hostUpdate();
|
|
|
|
rejectProxy?.(new Error('fail'));
|
|
await flushPromises();
|
|
|
|
expect(host.requestUpdate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should clear value if proxy endpoint is null', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
|
|
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(null);
|
|
|
|
const controller = new SignedURLController(host, () => ({
|
|
hass,
|
|
endpoint: createEndpoint('http://test-url.com'),
|
|
proxyConfig: {
|
|
enabled: true,
|
|
dynamic: true,
|
|
ssl_verification: true,
|
|
ssl_ciphers: 'default' as const,
|
|
live: true,
|
|
media: true,
|
|
},
|
|
}));
|
|
|
|
controller.hostUpdate();
|
|
await flushPromises();
|
|
|
|
expect(controller.getValue()).toBeNull();
|
|
expect(controller.getError()).toBe('proxy');
|
|
expect(host.requestUpdate).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should not retry after proxy error with same inputs', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
|
|
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue(null);
|
|
|
|
const controller = new SignedURLController(host, () => ({
|
|
hass,
|
|
endpoint: createEndpoint('http://test-url.com'),
|
|
proxyConfig: {
|
|
enabled: true,
|
|
dynamic: true,
|
|
ssl_verification: true,
|
|
ssl_ciphers: 'default' as const,
|
|
},
|
|
}));
|
|
|
|
await controller.hostUpdate();
|
|
expect(controller.getError()).toBe('proxy');
|
|
expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1);
|
|
|
|
// Re-render with same inputs should NOT retry.
|
|
await controller.hostUpdate();
|
|
expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should not restart in-flight work on re-render with same inputs', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
|
|
let resolveProxy: ((value: Endpoint) => void) | undefined;
|
|
vi.mocked(createProxiedEndpointIfNecessary).mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
resolveProxy = resolve;
|
|
}),
|
|
);
|
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(
|
|
'http://signed-proxied-url.com',
|
|
);
|
|
|
|
const proxyConfig = {
|
|
enabled: true,
|
|
dynamic: true,
|
|
ssl_verification: true,
|
|
ssl_ciphers: 'default' as const,
|
|
live: true,
|
|
media: true,
|
|
};
|
|
|
|
const controller = new SignedURLController(host, () => ({
|
|
hass,
|
|
endpoint: createEndpoint('http://test-url.com'),
|
|
proxyConfig,
|
|
}));
|
|
|
|
// First call starts async work.
|
|
controller.hostUpdate();
|
|
expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1);
|
|
|
|
// Second call with identical inputs should be a no-op (in-flight guard).
|
|
controller.hostUpdate();
|
|
expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1);
|
|
|
|
// Resolve the original request -- should still succeed.
|
|
resolveProxy?.({ endpoint: 'http://proxied-url.com', sign: false });
|
|
await flushPromises();
|
|
|
|
expect(controller.getValue()).toBe('http://signed-proxied-url.com');
|
|
expect(host.requestUpdate).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should not invalidate cache when extraneous proxy config fields change', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
|
|
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({
|
|
endpoint: 'http://proxied.com',
|
|
sign: false,
|
|
});
|
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(
|
|
'http://signed.com',
|
|
);
|
|
|
|
// Start with extraneous `live: true`.
|
|
let extraneous = true;
|
|
const controller = new SignedURLController(host, () => ({
|
|
hass,
|
|
endpoint: createEndpoint('http://test-url.com'),
|
|
proxyConfig: {
|
|
enabled: true,
|
|
dynamic: true,
|
|
ssl_verification: true,
|
|
ssl_ciphers: 'default' as const,
|
|
live: extraneous,
|
|
},
|
|
}));
|
|
|
|
await controller.hostUpdate();
|
|
expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1);
|
|
expect(controller.getValue()).toBe('http://signed.com');
|
|
|
|
// Change only the extraneous field -- should hit the cache, not re-fetch.
|
|
extraneous = false;
|
|
await controller.hostUpdate();
|
|
expect(createProxiedEndpointIfNecessary).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should reset all state on hostDisconnected', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
|
|
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({
|
|
endpoint: 'http://proxied-url.com',
|
|
sign: false,
|
|
});
|
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(
|
|
'http://signed-proxied-url.com',
|
|
);
|
|
|
|
const controller = new SignedURLController(host, () => ({
|
|
hass,
|
|
endpoint: createEndpoint('http://test-url.com'),
|
|
proxyConfig: {
|
|
enabled: true,
|
|
dynamic: true,
|
|
ssl_verification: true,
|
|
ssl_ciphers: 'default' as const,
|
|
},
|
|
}));
|
|
|
|
controller.hostUpdate();
|
|
await flushPromises();
|
|
expect(controller.getValue()).toBe('http://signed-proxied-url.com');
|
|
expect(controller.getError()).toBeNull();
|
|
|
|
controller.hostDisconnected();
|
|
|
|
expect(controller.getValue()).toBeNull();
|
|
expect(controller.getError()).toBeNull();
|
|
});
|
|
|
|
it('should discard in-flight result after hostDisconnected', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
|
|
let resolveProxy: ((value: Endpoint) => void) | undefined;
|
|
vi.mocked(createProxiedEndpointIfNecessary).mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
resolveProxy = resolve;
|
|
}),
|
|
);
|
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(
|
|
'http://signed.com',
|
|
);
|
|
|
|
const controller = new SignedURLController(host, () => ({
|
|
hass,
|
|
endpoint: createEndpoint('http://test-url.com'),
|
|
proxyConfig: {
|
|
enabled: true,
|
|
dynamic: true,
|
|
ssl_verification: true,
|
|
ssl_ciphers: 'default' as const,
|
|
},
|
|
}));
|
|
|
|
controller.hostUpdate();
|
|
|
|
controller.hostDisconnected();
|
|
|
|
resolveProxy?.({ endpoint: 'http://proxied.com', sign: false });
|
|
await flushPromises();
|
|
|
|
expect(controller.getValue()).toBeNull();
|
|
expect(host.requestUpdate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should ignore stale null signed URL after request ID changed', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
let url = 'http://test-url.com';
|
|
|
|
let resolveSign: ((value: string | null) => void) | undefined;
|
|
vi.mocked(createProxiedEndpointIfNecessary).mockResolvedValue({
|
|
endpoint: 'http://proxied.com',
|
|
sign: true,
|
|
});
|
|
vi.mocked(homeAssistantGetSignedURLIfNecessary)
|
|
.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
resolveSign = resolve;
|
|
}),
|
|
)
|
|
.mockReturnValue(new Promise(() => {}));
|
|
|
|
const controller = new SignedURLController(host, () => ({
|
|
hass,
|
|
endpoint: createEndpoint(url),
|
|
proxyConfig: {
|
|
enabled: true,
|
|
dynamic: true,
|
|
ssl_verification: true,
|
|
ssl_ciphers: 'default' as const,
|
|
},
|
|
}));
|
|
|
|
controller.hostUpdate();
|
|
await flushPromises();
|
|
|
|
// Change URL to start a new request.
|
|
url = 'http://new-url.com';
|
|
controller.hostUpdate();
|
|
|
|
// Resolve the first signing request with null (stale).
|
|
resolveSign?.(null);
|
|
await flushPromises();
|
|
|
|
// Stale result should be discarded.
|
|
expect(host.requestUpdate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should sign endpoint without proxying when sign is set', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
|
|
vi.mocked(homeAssistantGetSignedURLIfNecessary).mockResolvedValue(
|
|
'http://ha.local/api/some/endpoint?authSig=abc',
|
|
);
|
|
|
|
const controller = new SignedURLController(host, () => ({
|
|
hass,
|
|
endpoint: createEndpoint('/api/some/endpoint', true),
|
|
}));
|
|
|
|
expect(controller.getValue()).toBeNull();
|
|
|
|
controller.hostUpdate();
|
|
await flushPromises();
|
|
|
|
expect(createProxiedEndpointIfNecessary).not.toHaveBeenCalled();
|
|
expect(homeAssistantGetSignedURLIfNecessary).toHaveBeenCalled();
|
|
expect(controller.getValue()).toBe('http://ha.local/api/some/endpoint?authSig=abc');
|
|
expect(host.requestUpdate).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return url directly when sign is false and proxy is disabled', () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
|
|
const controller = new SignedURLController(host, () => ({
|
|
hass: createHASS(),
|
|
endpoint: createEndpoint('http://example.com/stream'),
|
|
}));
|
|
|
|
expect(controller.getValue()).toBe('http://example.com/stream');
|
|
});
|
|
|
|
it('should ignore null proxy endpoint if request ID changed', async () => {
|
|
const host = mock<ReactiveControllerHost>();
|
|
const hass = createHASS();
|
|
let url = 'http://test-url.com';
|
|
|
|
let resolveProxy: ((value: Endpoint | null) => void) | undefined;
|
|
vi.mocked(createProxiedEndpointIfNecessary)
|
|
.mockReturnValueOnce(
|
|
new Promise((resolve) => {
|
|
resolveProxy = resolve;
|
|
}),
|
|
)
|
|
.mockReturnValue(new Promise(() => {}));
|
|
|
|
const controller = new SignedURLController(host, () => ({
|
|
hass,
|
|
endpoint: createEndpoint(url),
|
|
proxyConfig: {
|
|
enabled: true,
|
|
dynamic: true,
|
|
ssl_verification: true,
|
|
ssl_ciphers: 'default' as const,
|
|
live: true,
|
|
media: true,
|
|
},
|
|
}));
|
|
|
|
controller.hostUpdate();
|
|
|
|
url = 'http://new-url.com';
|
|
controller.hostUpdate();
|
|
|
|
resolveProxy?.(null);
|
|
await flushPromises();
|
|
|
|
expect(host.requestUpdate).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('getSignedURLErrorText', () => {
|
|
it('should describe a signing failure', () => {
|
|
expect(getSignedURLErrorText('sign')).toBe('Could not sign Home Assistant URL');
|
|
});
|
|
|
|
it('should describe a proxy failure', () => {
|
|
expect(getSignedURLErrorText('proxy')).toBe('Could not proxy via Home Assistant');
|
|
});
|
|
});
|