Use mockCredentials in CachedEntityLoader tests and simplify cache key

Replace hand-crafted BackstageCredentials objects with mockCredentials
from @backstage/backend-test-utils, which have proper toString() methods.
Simplify getCacheKey to use String(credentials) directly and remove the
auth service dependency from CachedEntityLoader.

Signed-off-by: Fredrik Adelöw <freben@spotify.com>
Made-with: Cursor
This commit is contained in:
Fredrik Adelöw
2026-04-04 13:52:58 +02:00
parent e7fdf2997f
commit 323da94780
3 changed files with 25 additions and 102 deletions
@@ -16,13 +16,11 @@
import { CachedEntityLoader } from './CachedEntityLoader';
import { CompoundEntityRef } from '@backstage/catalog-model';
import { mockServices } from '@backstage/backend-test-utils';
import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
import { BackstageCredentials } from '@backstage/backend-plugin-api';
describe('CachedEntityLoader', () => {
const cache = mockServices.cache.mock();
const auth = mockServices.auth.mock();
const entityName: CompoundEntityRef = {
kind: 'component',
@@ -39,21 +37,8 @@ describe('CachedEntityLoader', () => {
},
};
const userCredentials: BackstageCredentials = {
$$type: '@backstage/BackstageCredentials',
principal: {
type: 'user',
userEntityRef: 'user:default/test-user',
},
};
const pluginCredentials: BackstageCredentials = {
$$type: '@backstage/BackstageCredentials',
principal: {
type: 'plugin',
subject: 'plugin:test-plugin',
},
};
const userCredentials = mockCredentials.user('user:default/test-user');
const serviceCredentials = mockCredentials.service('plugin:test-plugin');
afterEach(() => {
jest.resetAllMocks();
@@ -63,9 +48,8 @@ describe('CachedEntityLoader', () => {
cache.get.mockResolvedValue(undefined);
const catalog = catalogServiceMock({ entities: [entity] });
jest.spyOn(catalog, 'getEntityByRef');
auth.isPrincipal.mockReturnValue(true);
const loader = new CachedEntityLoader({ auth, catalog, cache });
const loader = new CachedEntityLoader({ catalog, cache });
const result = await loader.load(userCredentials, entityName);
expect(result).toEqual(entity);
@@ -73,7 +57,7 @@ describe('CachedEntityLoader', () => {
credentials: userCredentials,
});
expect(cache.set).toHaveBeenCalledWith(
'catalog:component:default/test:user:default/test-user',
`catalog:component:default/test:${userCredentials}`,
entity,
{ ttl: 5000 },
);
@@ -83,9 +67,8 @@ describe('CachedEntityLoader', () => {
const catalog = catalogServiceMock();
jest.spyOn(catalog, 'getEntityByRef');
cache.get.mockResolvedValue(entity);
auth.isPrincipal.mockReturnValue(true);
const loader = new CachedEntityLoader({ auth, catalog, cache });
const loader = new CachedEntityLoader({ catalog, cache });
const result = await loader.load(userCredentials, entityName);
expect(result).toEqual(entity);
@@ -95,33 +78,14 @@ describe('CachedEntityLoader', () => {
it('does not cache missing entities', async () => {
const catalog = catalogServiceMock({ entities: [] });
cache.get.mockResolvedValue(undefined);
auth.isPrincipal.mockReturnValue(true);
const loader = new CachedEntityLoader({ auth, catalog, cache });
const loader = new CachedEntityLoader({ catalog, cache });
const result = await loader.load(userCredentials, entityName);
expect(result).toBeUndefined();
expect(cache.set).not.toHaveBeenCalled();
});
it('uses entity ref as cache key for service credentials', async () => {
const catalog = catalogServiceMock({ entities: [entity] });
cache.get.mockResolvedValue(undefined);
auth.isPrincipal.mockReturnValueOnce(false).mockReturnValueOnce(true);
const loader = new CachedEntityLoader({ auth, catalog, cache });
const result = await loader.load(pluginCredentials, entityName);
expect(result).toEqual(entity);
expect(cache.set).toHaveBeenCalledWith(
'catalog:component:default/test:plugin:test-plugin',
entity,
{
ttl: 5000,
},
);
});
it('calls the catalog if the cache read takes too long', async () => {
cache.get.mockImplementation(
() =>
@@ -130,80 +94,48 @@ describe('CachedEntityLoader', () => {
}),
);
const catalog = catalogServiceMock({ entities: [entity] });
auth.isPrincipal.mockReturnValue(true);
const loader = new CachedEntityLoader({ auth, catalog, cache });
const loader = new CachedEntityLoader({ catalog, cache });
const result = await loader.load(userCredentials, entityName);
expect(result).toEqual(entity);
});
it('creates different cache keys for different users', async () => {
it('creates different cache keys for different credentials', async () => {
const catalog = catalogServiceMock({ entities: [entity] });
cache.get.mockResolvedValue(undefined);
auth.isPrincipal.mockReturnValue(true);
const loader = new CachedEntityLoader({ auth, catalog, cache });
const loader = new CachedEntityLoader({ catalog, cache });
const anotherUserCredentials: BackstageCredentials = {
$$type: '@backstage/BackstageCredentials',
principal: {
type: 'user',
userEntityRef: 'user:default/another-user',
},
};
const anotherUserCredentials = mockCredentials.user(
'user:default/another-user',
);
await loader.load(userCredentials, entityName);
await loader.load(anotherUserCredentials, entityName);
expect(cache.set).toHaveBeenCalledWith(
'catalog:component:default/test:user:default/test-user',
`catalog:component:default/test:${userCredentials}`,
entity,
{ ttl: 5000 },
);
expect(cache.set).toHaveBeenCalledWith(
'catalog:component:default/test:user:default/another-user',
`catalog:component:default/test:${anotherUserCredentials}`,
entity,
{ ttl: 5000 },
);
});
it('creates cache key with service subject for service credentials', async () => {
it('uses service credentials as cache key for service credentials', async () => {
const catalog = catalogServiceMock({ entities: [entity] });
cache.get.mockResolvedValue(undefined);
auth.isPrincipal.mockReturnValueOnce(false).mockReturnValueOnce(true);
const loader = new CachedEntityLoader({ auth, catalog, cache });
const result = await loader.load(pluginCredentials, entityName);
const loader = new CachedEntityLoader({ catalog, cache });
const result = await loader.load(serviceCredentials, entityName);
expect(result).toEqual(entity);
expect(cache.set).toHaveBeenCalledWith(
'catalog:component:default/test:plugin:test-plugin',
entity,
{ ttl: 5000 },
);
expect(auth.isPrincipal).toHaveBeenCalledWith(pluginCredentials, 'user');
expect(auth.isPrincipal).toHaveBeenCalledWith(pluginCredentials, 'service');
});
it('handles credentials that are neither user nor service', async () => {
const catalog = catalogServiceMock({ entities: [entity] });
cache.get.mockResolvedValue(undefined);
auth.isPrincipal.mockReturnValue(false);
const unknownCredentials: BackstageCredentials = {
$$type: '@backstage/BackstageCredentials',
principal: {
type: 'unknown' as any,
},
};
const loader = new CachedEntityLoader({ auth, catalog, cache });
const result = await loader.load(unknownCredentials, entityName);
expect(result).toEqual(entity);
expect(cache.set).toHaveBeenCalledWith(
'catalog:component:default/test',
`catalog:component:default/test:${serviceCredentials}`,
entity,
{ ttl: 5000 },
);
@@ -15,7 +15,6 @@
*/
import {
AuthService,
BackstageCredentials,
CacheService,
} from '@backstage/backend-plugin-api';
@@ -27,19 +26,16 @@ import {
import { CatalogService } from '@backstage/plugin-catalog-node';
export type CachedEntityLoaderOptions = {
auth: AuthService;
catalog: CatalogService;
cache: CacheService;
};
export class CachedEntityLoader {
private readonly auth: AuthService;
private readonly catalog: CatalogService;
private readonly cache: CacheService;
private readonly readTimeout = 1000;
constructor({ auth, catalog, cache }: CachedEntityLoaderOptions) {
this.auth = auth;
constructor({ catalog, cache }: CachedEntityLoaderOptions) {
this.catalog = catalog;
this.cache = cache;
}
@@ -77,14 +73,10 @@ export class CachedEntityLoader {
entityName: CompoundEntityRef,
credentials: BackstageCredentials,
): string {
const key = ['catalog', stringifyEntityRef(entityName)];
if (this.auth.isPrincipal(credentials, 'user')) {
key.push(credentials.principal.userEntityRef);
} else if (this.auth.isPrincipal(credentials, 'service')) {
key.push(credentials.principal.subject);
}
return key.join(':');
return [
'catalog',
stringifyEntityRef(entityName),
String(credentials), // these have a well defined toString method
].join(':');
}
}
@@ -124,7 +124,6 @@ export async function createRouter(
// Entities are cached to optimize the /static/docs request path, which can be called many times
// when loading a single techdocs page.
const entityLoader = new CachedEntityLoader({
auth,
catalog,
cache: options.cache,
});