Compare commits

...

1 Commits

Author SHA1 Message Date
Bram Kragten 66235a4c99 Don't try to load brand images without a token (#52532) 2026-06-10 18:09:35 +02:00
2 changed files with 90 additions and 29 deletions
+29 -15
View File
@@ -77,15 +77,19 @@ export const clearBrandsTokenRefresh = (): void => {
};
export const brandsUrl = (options: BrandsOptions, hassUrl?: string): string => {
// The brands API requires a token; without one the request 401s. Return an
// empty src so no request fires until the token is available. Components
// re-render once the token arrives (see connection-mixin) and recompute this.
if (!_brandsAccessToken) {
return "";
}
hassUrl = hassUrl ?? location.origin;
const base = `/api/brands/integration/${options.domain}/${
options.darkOptimized ? "dark_" : ""
}${options.type}.png`;
const url = new URL(base, hassUrl);
if (_brandsAccessToken) {
url.searchParams.set("token", _brandsAccessToken);
}
url.searchParams.set("token", _brandsAccessToken);
return url.toString();
};
@@ -93,34 +97,44 @@ export const hardwareBrandsUrl = (
options: HardwareBrandsOptions,
hassUrl?: string
): string => {
// See brandsUrl: wait for the token before producing a loadable URL.
if (!_brandsAccessToken) {
return "";
}
hassUrl = hassUrl ?? location.origin;
const base = `/api/brands/hardware/${options.category}/${
options.darkOptimized ? "dark_" : ""
}${options.manufacturer}${options.model ? `_${options.model}` : ""}.png`;
const url = new URL(base, hassUrl);
if (_brandsAccessToken) {
url.searchParams.set("token", _brandsAccessToken);
}
url.searchParams.set("token", _brandsAccessToken);
return url.toString();
};
export const addBrandsAuth = (url: string, hassUrl?: string): string => {
hassUrl = hassUrl ?? location.origin;
if (!_brandsAccessToken) {
return url;
}
let parsedUrl: URL;
try {
const parsedUrl = new URL(url, hassUrl);
if (!parsedUrl.pathname.startsWith("/api/brands/")) {
return url;
}
parsedUrl.searchParams.set("token", _brandsAccessToken);
return parsedUrl.toString();
parsedUrl = new URL(url, hassUrl);
} catch {
return url;
}
// Non-brands URLs (e.g. CDN brands.home-assistant.io or camera proxies) are
// returned unchanged; they don't use the brands token.
if (!parsedUrl.pathname.startsWith("/api/brands/")) {
return url;
}
// Brands API request without a token would 401; return an empty src so it
// doesn't fire until the token is available.
if (!_brandsAccessToken) {
return "";
}
parsedUrl.searchParams.set("token", _brandsAccessToken);
return parsedUrl.toString();
};
export const extractDomainFromBrandUrl = (url: string): string => {
+61 -14
View File
@@ -1,4 +1,4 @@
import { assert, describe, it, vi, afterEach } from "vitest";
import { assert, describe, it, vi, afterEach, beforeEach } from "vitest";
import type { HomeAssistant } from "../../src/types";
import {
addBrandsAuth,
@@ -6,17 +6,74 @@ import {
clearBrandsTokenRefresh,
fetchAndScheduleBrandsAccessToken,
fetchBrandsAccessToken,
hardwareBrandsUrl,
scheduleBrandsTokenRefresh,
} from "../../src/util/brands-url";
// NOTE: the cached brands token is module-level state that persists across
// tests. The "without a token" assertions below must run before any test
// fetches a token, so this block is intentionally declared first.
describe("Brands URLs without a token", () => {
// The brands API requires a token; until one is fetched the URL builders
// return an empty src so no token-less request (which 401s) fires. Components
// re-render once the token arrives and recompute the URL.
it("brandsUrl returns an empty src", () => {
assert.strictEqual(
brandsUrl(
{ domain: "cloud", type: "logo" },
"http://homeassistant.local:8123"
),
""
);
});
it("hardwareBrandsUrl returns an empty src", () => {
assert.strictEqual(
hardwareBrandsUrl(
{ category: "boards", manufacturer: "raspberry_pi" },
"http://homeassistant.local:8123"
),
""
);
});
it("addBrandsAuth returns an empty src for brands URLs", () => {
assert.strictEqual(
addBrandsAuth(
"/api/brands/integration/demo/icon.png",
"http://homeassistant.local:8123"
),
""
);
});
it("addBrandsAuth returns non-brands URLs unchanged", () => {
assert.strictEqual(
addBrandsAuth(
"/api/camera_proxy/camera.foo?token=abc",
"http://homeassistant.local:8123"
),
"/api/camera_proxy/camera.foo?token=abc"
);
});
});
describe("Generate brands Url", () => {
// Fetch a token before these run so the URL builders produce loadable URLs.
beforeEach(async () => {
const mockHass = {
callWS: async () => ({ token: "test-token-123" }),
} as unknown as HomeAssistant;
await fetchBrandsAccessToken(mockHass);
});
it("Generate logo brands url for cloud component", () => {
assert.strictEqual(
brandsUrl(
{ domain: "cloud", type: "logo" },
"http://homeassistant.local:8123"
),
"http://homeassistant.local:8123/api/brands/integration/cloud/logo.png"
"http://homeassistant.local:8123/api/brands/integration/cloud/logo.png?token=test-token-123"
);
});
it("Generate icon brands url for cloud component", () => {
@@ -25,7 +82,7 @@ describe("Generate brands Url", () => {
{ domain: "cloud", type: "icon" },
"http://homeassistant.local:8123"
),
"http://homeassistant.local:8123/api/brands/integration/cloud/icon.png"
"http://homeassistant.local:8123/api/brands/integration/cloud/icon.png?token=test-token-123"
);
});
@@ -35,7 +92,7 @@ describe("Generate brands Url", () => {
{ domain: "cloud", type: "logo", darkOptimized: true },
"http://homeassistant.local:8123"
),
"http://homeassistant.local:8123/api/brands/integration/cloud/dark_logo.png"
"http://homeassistant.local:8123/api/brands/integration/cloud/dark_logo.png?token=test-token-123"
);
});
});
@@ -51,16 +108,6 @@ describe("addBrandsAuth", () => {
);
});
it("Returns brands URL unchanged when no token is available", () => {
assert.strictEqual(
addBrandsAuth(
"/api/brands/integration/demo/icon.png",
"http://homeassistant.local:8123"
),
"/api/brands/integration/demo/icon.png"
);
});
it("Appends token to brands URL when token is available", async () => {
const mockHass = {
callWS: async () => ({ token: "test-token-123" }),