diff --git a/codecov.yml b/codecov.yml index d01e52090a..e4edafcd5f 100644 --- a/codecov.yml +++ b/codecov.yml @@ -73,6 +73,39 @@ ignore: # - install-dynamic-plugins: the pytest-based install script coverage # Additional flags (overlays-e2e-*, community-*) will be introduced by # the respective Stories under RHIDP-11866 and RHIDP-11865. +# Components give a per-area coverage breakdown in the dashboard and in the +# PR comment (the comment layout above already renders a `components` section). +# Unlike flags, components are pure path-based views over the same uploads, so +# they need no CI changes. Statuses are informational while coverage matures, +# matching the project/patch approach above. +component_management: + default_rules: + statuses: + - type: project + target: auto + informational: true + individual_components: + - component_id: backend_plugins + name: Backend plugins + paths: + - plugins/*/src/** + - component_id: backend_app + name: Backend app + paths: + - packages/backend/src/** + - component_id: frontend_app + name: Frontend app + paths: + - packages/app/src/** + - component_id: plugin_utils + name: Plugin utils + paths: + - packages/plugin-utils/src/** + - component_id: dynamic_plugins_utils + name: Dynamic plugins utils + paths: + - dynamic-plugins/_utils/src/** + flag_management: default_rules: carryforward: true diff --git a/packages/backend/src/modules/rbacDynamicPluginsModule.test.ts b/packages/backend/src/modules/rbacDynamicPluginsModule.test.ts new file mode 100644 index 0000000000..ddcb19356a --- /dev/null +++ b/packages/backend/src/modules/rbacDynamicPluginsModule.test.ts @@ -0,0 +1,67 @@ +import { + DynamicPluginProvider, + dynamicPluginsServiceRef, +} from '@backstage/backend-dynamic-feature-service'; +import { createServiceFactory } from '@backstage/backend-plugin-api'; +import { ServiceFactoryTester } from '@backstage/backend-test-utils'; + +import { pluginIDProviderService } from './rbacDynamicPluginsModule'; + +const pkg = (name: string, role = 'backend-plugin') => ({ + manifest: { name, backstage: { role } }, +}); + +const dynamicPluginsWith = (availablePackages: unknown[]) => + createServiceFactory({ + service: dynamicPluginsServiceRef, + deps: {}, + async factory() { + return { availablePackages } as unknown as DynamicPluginProvider; + }, + }); + +const resolvePluginIds = async (availablePackages: unknown[]) => { + const tester = ServiceFactoryTester.from(pluginIDProviderService, { + dependencies: [dynamicPluginsWith(availablePackages)], + }); + const provider = await tester.getSubject(); + return provider.getPluginIds(); +}; + +describe('pluginIDProviderService', () => { + it('always exposes the core plugin ids', async () => { + expect(await resolvePluginIds([])).toEqual([ + 'catalog', + 'scaffolder', + 'permission', + ]); + }); + + it('normalizes backend dynamic plugin package names across naming styles', async () => { + const ids = await resolvePluginIds([ + pkg('@backstage/plugin-foo-backend-dynamic'), + pkg('backstage-plugin-bar-backend-dynamic'), + pkg('@redhat/backstage-plugin-baz-backend-dynamic'), + pkg('custom-plugin-qux-backend-dynamic'), + ]); + + expect(ids).toEqual([ + 'catalog', + 'scaffolder', + 'permission', + 'foo', + 'bar', + 'baz', + 'qux', + ]); + }); + + it('ignores packages that are not backend plugins', async () => { + const ids = await resolvePluginIds([ + pkg('@backstage/plugin-frontend-dynamic', 'frontend-plugin'), + { manifest: { name: 'no-role-plugin' } }, + ]); + + expect(ids).toEqual(['catalog', 'scaffolder', 'permission']); + }); +}); diff --git a/packages/backend/src/modules/resolverUtils.test.ts b/packages/backend/src/modules/resolverUtils.test.ts new file mode 100644 index 0000000000..d832c5d9d8 --- /dev/null +++ b/packages/backend/src/modules/resolverUtils.test.ts @@ -0,0 +1,156 @@ +import type { OidcAuthResult } from '@backstage/plugin-auth-backend-module-oidc-provider'; +import { + AuthResolverContext, + BackstageSignInResult, + OAuthAuthenticatorResult, + SignInInfo, + SignInResolver, +} from '@backstage/plugin-auth-node'; + +import { decodeJwt } from 'jose'; + +import { + createOidcSubClaimResolver, + OidcProviderInfo, + trySignInResolvers, +} from './resolverUtils'; + +jest.mock('jose', () => ({ decodeJwt: jest.fn() })); + +const decodeJwtMock = decodeJwt as jest.Mock; + +const signInResult = { token: 'mock-token' } as BackstageSignInResult; + +const buildContext = () => + ({ + signInWithCatalogUser: jest.fn().mockResolvedValue(signInResult), + }) as unknown as AuthResolverContext; + +const KEYCLOAK: OidcProviderInfo = { + userIdKey: 'keycloak.org/id', + providerName: 'Keycloak', +}; + +const buildOidcInfo = (sub?: string, idToken?: string) => + ({ + result: { + fullProfile: { + userinfo: { sub }, + tokenset: { id_token: idToken }, + }, + }, + }) as unknown as SignInInfo>; + +describe('createOidcSubClaimResolver', () => { + beforeEach(() => jest.clearAllMocks()); + + it('signs in with the provider annotation when sub matches the id token', async () => { + decodeJwtMock.mockReturnValue({ sub: 'user-123' }); + const ctx = buildContext(); + const resolver = createOidcSubClaimResolver(KEYCLOAK)(); + + const result = await resolver(buildOidcInfo('user-123', 'id.token'), ctx); + + expect(result).toBe(signInResult); + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { annotations: { 'keycloak.org/id': 'user-123' } }, + { dangerousEntityRefFallback: undefined }, + ); + }); + + it('throws when the sub claim is missing', async () => { + const resolver = createOidcSubClaimResolver(KEYCLOAK)(); + + await expect( + resolver(buildOidcInfo(undefined, 'id.token'), buildContext()), + ).rejects.toThrow(/missing a 'sub' claim/); + }); + + it('throws when the id token is missing', async () => { + const resolver = createOidcSubClaimResolver(KEYCLOAK)(); + + await expect( + resolver(buildOidcInfo('user-123', undefined), buildContext()), + ).rejects.toThrow(/user ID token from Keycloak is missing/); + }); + + it('throws when the sub claim does not match the id token', async () => { + decodeJwtMock.mockReturnValue({ sub: 'someone-else' }); + const resolver = createOidcSubClaimResolver(KEYCLOAK)(); + + await expect( + resolver(buildOidcInfo('user-123', 'id.token'), buildContext()), + ).rejects.toThrow(/mismatching 'sub' claim/); + }); + + it('throws when the id token carries no sub claim', async () => { + decodeJwtMock.mockReturnValue({}); + const resolver = createOidcSubClaimResolver(KEYCLOAK)(); + + await expect( + resolver(buildOidcInfo('user-123', 'id.token'), buildContext()), + ).rejects.toThrow(/mismatching 'sub' claim/); + }); + + it('passes a dangerous entity ref fallback when explicitly allowed', async () => { + decodeJwtMock.mockReturnValue({ sub: 'user-123' }); + const ctx = buildContext(); + const resolver = createOidcSubClaimResolver(KEYCLOAK)({ + dangerouslyAllowSignInWithoutUserInCatalog: true, + }); + + await resolver(buildOidcInfo('user-123', 'id.token'), ctx); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { annotations: { 'keycloak.org/id': 'user-123' } }, + { dangerousEntityRefFallback: { entityRef: 'user-123' } }, + ); + }); +}); + +describe('trySignInResolvers', () => { + const info = {} as unknown as SignInInfo; + const ctx = {} as unknown as AuthResolverContext; + + it('returns the result of the first resolver that succeeds', async () => { + const first: SignInResolver = jest + .fn() + .mockResolvedValue(signInResult); + const second: SignInResolver = jest.fn(); + + const result = await trySignInResolvers([first, second])(info, ctx); + + expect(result).toBe(signInResult); + expect(second).not.toHaveBeenCalled(); + }); + + it('skips failing resolvers and uses the next that succeeds', async () => { + const failing: SignInResolver = jest + .fn() + .mockRejectedValue(new Error('no match')); + const succeeding: SignInResolver = jest + .fn() + .mockResolvedValue(signInResult); + + const result = await trySignInResolvers([failing, succeeding])(info, ctx); + + expect(result).toBe(signInResult); + expect(failing).toHaveBeenCalledTimes(1); + expect(succeeding).toHaveBeenCalledTimes(1); + }); + + it('throws a descriptive error after attempting every resolver', async () => { + const first: SignInResolver = jest + .fn() + .mockRejectedValue(new Error('no match')); + const second: SignInResolver = jest + .fn() + .mockRejectedValue(new Error('no match')); + + await expect( + trySignInResolvers([first, second])(info, ctx), + ).rejects.toThrow(/unable to resolve user identity/); + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/backend/src/modules/rhdhSignInResolvers.test.ts b/packages/backend/src/modules/rhdhSignInResolvers.test.ts new file mode 100644 index 0000000000..628c89d408 --- /dev/null +++ b/packages/backend/src/modules/rhdhSignInResolvers.test.ts @@ -0,0 +1,275 @@ +import type { OAuth2ProxyResult } from '@backstage/plugin-auth-backend-module-oauth2-proxy-provider'; +import type { OidcAuthResult } from '@backstage/plugin-auth-backend-module-oidc-provider'; +import { + AuthResolverContext, + BackstageSignInResult, + OAuthAuthenticatorResult, + SignInInfo, +} from '@backstage/plugin-auth-node'; + +import { decodeJwt } from 'jose'; + +import { rhdhSignInResolvers } from './rhdhSignInResolvers'; + +jest.mock('jose', () => ({ decodeJwt: jest.fn() })); + +const decodeJwtMock = decodeJwt as jest.Mock; + +const signInResult = { token: 'mock-token' } as BackstageSignInResult; + +const buildContext = () => + ({ + signInWithCatalogUser: jest.fn().mockResolvedValue(signInResult), + }) as unknown as AuthResolverContext; + +const buildOidcInfo = (userinfo: Record, idToken?: string) => + ({ + result: { + fullProfile: { + userinfo, + tokenset: { id_token: idToken }, + }, + }, + }) as unknown as SignInInfo>; + +const buildProxyInfo = (headers: Record) => + ({ + result: { + getHeader: (name: string) => headers[name], + }, + }) as unknown as SignInInfo; + +beforeEach(() => jest.clearAllMocks()); + +describe('preferredUsernameMatchingUserEntityName', () => { + it('signs in using the preferred_username as the entity name', async () => { + const ctx = buildContext(); + const resolver = + rhdhSignInResolvers.preferredUsernameMatchingUserEntityName(); + + await resolver(buildOidcInfo({ preferred_username: 'jdoe' }), ctx); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { entityRef: { name: 'jdoe' } }, + { dangerousEntityRefFallback: undefined }, + ); + }); + + it('throws when the profile has no username', async () => { + const resolver = + rhdhSignInResolvers.preferredUsernameMatchingUserEntityName(); + + await expect(resolver(buildOidcInfo({}), buildContext())).rejects.toThrow( + 'OIDC user profile does not contain a username', + ); + }); + + it('passes a fallback entity ref when explicitly allowed', async () => { + const ctx = buildContext(); + const resolver = + rhdhSignInResolvers.preferredUsernameMatchingUserEntityName({ + dangerouslyAllowSignInWithoutUserInCatalog: true, + }); + + await resolver(buildOidcInfo({ preferred_username: 'jdoe' }), ctx); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { entityRef: { name: 'jdoe' } }, + { dangerousEntityRefFallback: { entityRef: 'jdoe' } }, + ); + }); +}); + +describe('oauth2ProxyUserHeaderMatchingUserEntityName', () => { + const ORIGINAL_HEADER_ENV = process.env.OAUTH_USER_HEADER; + + afterEach(() => { + if (ORIGINAL_HEADER_ENV === undefined) { + delete process.env.OAUTH_USER_HEADER; + } else { + process.env.OAUTH_USER_HEADER = ORIGINAL_HEADER_ENV; + } + }); + + it('reads the user from the configured OAUTH_USER_HEADER', async () => { + process.env.OAUTH_USER_HEADER = 'x-custom-user'; + const ctx = buildContext(); + const resolver = + rhdhSignInResolvers.oauth2ProxyUserHeaderMatchingUserEntityName(); + + await resolver(buildProxyInfo({ 'x-custom-user': 'alice' }), ctx); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { entityRef: { name: 'alice' } }, + { dangerousEntityRefFallback: undefined }, + ); + }); + + it('prefers x-forwarded-preferred-username over x-forwarded-user', async () => { + delete process.env.OAUTH_USER_HEADER; + const ctx = buildContext(); + const resolver = + rhdhSignInResolvers.oauth2ProxyUserHeaderMatchingUserEntityName(); + + await resolver( + buildProxyInfo({ + 'x-forwarded-preferred-username': 'preferred', + 'x-forwarded-user': 'fallback', + }), + ctx, + ); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { entityRef: { name: 'preferred' } }, + { dangerousEntityRefFallback: undefined }, + ); + }); + + it('falls back to x-forwarded-user when preferred-username is absent', async () => { + delete process.env.OAUTH_USER_HEADER; + const ctx = buildContext(); + const resolver = + rhdhSignInResolvers.oauth2ProxyUserHeaderMatchingUserEntityName(); + + await resolver(buildProxyInfo({ 'x-forwarded-user': 'carol' }), ctx); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { entityRef: { name: 'carol' } }, + { dangerousEntityRefFallback: undefined }, + ); + }); + + it('throws when no user header is present', async () => { + delete process.env.OAUTH_USER_HEADER; + const resolver = + rhdhSignInResolvers.oauth2ProxyUserHeaderMatchingUserEntityName(); + + await expect(resolver(buildProxyInfo({}), buildContext())).rejects.toThrow( + 'Request did not contain a user', + ); + }); + + it('passes a fallback entity ref when explicitly allowed', async () => { + delete process.env.OAUTH_USER_HEADER; + const ctx = buildContext(); + const resolver = + rhdhSignInResolvers.oauth2ProxyUserHeaderMatchingUserEntityName({ + dangerouslyAllowSignInWithoutUserInCatalog: true, + }); + + await resolver( + buildProxyInfo({ 'x-forwarded-preferred-username': 'bob' }), + ctx, + ); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { entityRef: { name: 'bob' } }, + { dangerousEntityRefFallback: { entityRef: 'bob' } }, + ); + }); +}); + +describe('oidcLdapUuidMatchingAnnotation', () => { + it('signs in with the ldap-uuid annotation when the uuid matches the id token', async () => { + decodeJwtMock.mockReturnValue({ ldap_uuid: 'uuid-1' }); + const ctx = buildContext(); + const resolver = rhdhSignInResolvers.oidcLdapUuidMatchingAnnotation(); + + await resolver(buildOidcInfo({ ldap_uuid: 'uuid-1' }, 'id.token'), ctx); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { annotations: { 'backstage.io/ldap-uuid': 'uuid-1' } }, + { dangerousEntityRefFallback: undefined }, + ); + }); + + it('honors a custom ldapUuidKey option', async () => { + decodeJwtMock.mockReturnValue({ custom_uuid: 'uuid-2' }); + const ctx = buildContext(); + const resolver = rhdhSignInResolvers.oidcLdapUuidMatchingAnnotation({ + ldapUuidKey: 'custom_uuid', + }); + + await resolver(buildOidcInfo({ custom_uuid: 'uuid-2' }, 'id.token'), ctx); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { annotations: { 'backstage.io/ldap-uuid': 'uuid-2' } }, + { dangerousEntityRefFallback: undefined }, + ); + }); + + it('throws when the uuid is missing', async () => { + const resolver = rhdhSignInResolvers.oidcLdapUuidMatchingAnnotation(); + + await expect( + resolver(buildOidcInfo({}, 'id.token'), buildContext()), + ).rejects.toThrow(/missing the UUID/); + }); + + it('throws when the id token is missing', async () => { + const resolver = rhdhSignInResolvers.oidcLdapUuidMatchingAnnotation(); + + await expect( + resolver( + buildOidcInfo({ ldap_uuid: 'uuid-1' }, undefined), + buildContext(), + ), + ).rejects.toThrow(/user ID token from LDAP is missing/); + }); + + it('throws when the uuid does not match the id token', async () => { + decodeJwtMock.mockReturnValue({ ldap_uuid: 'someone-else' }); + const resolver = rhdhSignInResolvers.oidcLdapUuidMatchingAnnotation(); + + await expect( + resolver( + buildOidcInfo({ ldap_uuid: 'uuid-1' }, 'id.token'), + buildContext(), + ), + ).rejects.toThrow(/mismatching UUID/); + }); + + it('passes a fallback entity ref when explicitly allowed', async () => { + decodeJwtMock.mockReturnValue({ ldap_uuid: 'uuid-1' }); + const ctx = buildContext(); + const resolver = rhdhSignInResolvers.oidcLdapUuidMatchingAnnotation({ + dangerouslyAllowSignInWithoutUserInCatalog: true, + }); + + await resolver(buildOidcInfo({ ldap_uuid: 'uuid-1' }, 'id.token'), ctx); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { annotations: { 'backstage.io/ldap-uuid': 'uuid-1' } }, + { dangerousEntityRefFallback: { entityRef: 'uuid-1' } }, + ); + }); +}); + +describe('oidc sub-claim resolvers', () => { + it('matches Keycloak users on the keycloak.org/id annotation', async () => { + decodeJwtMock.mockReturnValue({ sub: 'kc-1' }); + const ctx = buildContext(); + const resolver = rhdhSignInResolvers.oidcSubClaimMatchingKeycloakUserId(); + + await resolver(buildOidcInfo({ sub: 'kc-1' }, 'id.token'), ctx); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { annotations: { 'keycloak.org/id': 'kc-1' } }, + { dangerousEntityRefFallback: undefined }, + ); + }); + + it('matches Ping Identity users on the pingidentity.org/id annotation', async () => { + decodeJwtMock.mockReturnValue({ sub: 'ping-1' }); + const ctx = buildContext(); + const resolver = + rhdhSignInResolvers.oidcSubClaimMatchingPingIdentityUserId(); + + await resolver(buildOidcInfo({ sub: 'ping-1' }, 'id.token'), ctx); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith( + { annotations: { 'pingidentity.org/id': 'ping-1' } }, + { dangerousEntityRefFallback: undefined }, + ); + }); +}); diff --git a/plugins/dynamic-plugins-info-backend/src/service/router.integration.test.ts b/plugins/dynamic-plugins-info-backend/src/service/router.integration.test.ts new file mode 100644 index 0000000000..dd53865d30 --- /dev/null +++ b/plugins/dynamic-plugins-info-backend/src/service/router.integration.test.ts @@ -0,0 +1,77 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + DynamicPluginProvider, + dynamicPluginsServiceRef, +} from '@backstage/backend-dynamic-feature-service'; +import { createServiceFactory } from '@backstage/backend-plugin-api'; +import { + mockCredentials, + mockServices, + startTestBackend, +} from '@backstage/backend-test-utils'; + +import request from 'supertest'; + +import { plugins } from '../../__fixtures__/data'; +import { dynamicPluginsInfoPlugin } from '../plugin'; + +// The plugin resolves its dynamic-plugin source from `dynamicPluginsServiceRef`. +// A stub factory feeds the same fixtures the unit tests use, so the real plugin +// wiring runs end-to-end without a dynamic-plugin runtime. +const mockDynamicPluginsService = createServiceFactory({ + service: dynamicPluginsServiceRef, + deps: {}, + async factory() { + return { plugins: () => plugins } as unknown as DynamicPluginProvider; + }, +}); + +const startBackend = () => + startTestBackend({ + features: [ + dynamicPluginsInfoPlugin, + mockDynamicPluginsService, + mockServices.rootConfig.factory(), + ], + }); + +describe('dynamic-plugins-info backend (Layer 2 integration)', () => { + it('serves the loaded-plugins endpoint to a user and strips installer details', async () => { + const { server } = await startBackend(); + + const response = await request(server) + .get('/api/dynamic-plugins-info/loaded-plugins') + .set('Authorization', mockCredentials.user.header()); + + expect(response.status).toEqual(200); + expect(response.body.length).toBeGreaterThan(0); + for (const plugin of response.body) { + expect(plugin).not.toHaveProperty('installer'); + } + }, 30_000); + + it('authorizes service principals to read the plugin list', async () => { + const { server } = await startBackend(); + + const response = await request(server) + .get('/api/dynamic-plugins-info/loaded-plugins') + .set('Authorization', mockCredentials.service.header()); + + expect(response.status).toEqual(200); + expect(response.body.length).toBeGreaterThan(0); + }, 30_000); +}); diff --git a/plugins/dynamic-plugins-info-backend/src/service/router.test.ts b/plugins/dynamic-plugins-info-backend/src/service/router.test.ts index d0f4a58292..9bb0062494 100644 --- a/plugins/dynamic-plugins-info-backend/src/service/router.test.ts +++ b/plugins/dynamic-plugins-info-backend/src/service/router.test.ts @@ -1,4 +1,4 @@ -import { DynamicPluginManager } from '@backstage/backend-dynamic-feature-service'; +import { DynamicPluginProvider } from '@backstage/backend-dynamic-feature-service'; import { mockServices } from '@backstage/backend-test-utils'; import express from 'express'; @@ -8,34 +8,88 @@ import { plugins } from '../../__fixtures__/data'; import { expectedList } from '../../__fixtures__/expected_result'; import { createRouter } from './router'; -describe('createRouter', () => { - let app: express.Express; - - beforeAll(async () => { - const pluginManager = new (DynamicPluginManager as any)(); - pluginManager._plugins = plugins; - - const router = await createRouter({ - pluginProvider: pluginManager, - discovery: mockServices.discovery(), - httpAuth: mockServices.httpAuth(), - config: mockServices.rootConfig(), - logger: mockServices.logger.mock(), - }); +const buildApp = async ( + pluginList: unknown[], + httpAuth = mockServices.httpAuth(), +) => { + // The router only consumes `pluginProvider.plugins()`, so a stub against + // that public method is enough and avoids coupling to the manager internals. + const pluginProvider = { + plugins: () => pluginList, + } as unknown as DynamicPluginProvider; - app = express(); - app = express().use(router); + const router = await createRouter({ + pluginProvider, + discovery: mockServices.discovery(), + httpAuth, + config: mockServices.rootConfig(), + logger: mockServices.logger.mock(), }); + return express().use(router); +}; + +describe('createRouter', () => { afterEach(() => { jest.resetAllMocks(); }); describe('GET /loaded-plugins', () => { it('returns the list of loaded dynamic plugins', async () => { + const app = await buildApp(plugins); + const response = await request(app).get('/loaded-plugins'); + expect(response.status).toEqual(200); expect(response.body).toEqual(expectedList); }); + + it('strips the installer details from node platform plugins', async () => { + const app = await buildApp(plugins); + + const response = await request(app).get('/loaded-plugins'); + + expect(response.body.length).toBeGreaterThan(0); + for (const plugin of response.body) { + expect(plugin).not.toHaveProperty('installer'); + } + }); + + it('returns an empty list when no dynamic plugins are loaded', async () => { + const app = await buildApp([]); + + const response = await request(app).get('/loaded-plugins'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([]); + }); + + it('returns front-end plugins unchanged (no installer stripping)', async () => { + const frontendPlugin = { + name: 'backstage-plugin-example', + version: '1.0.0', + platform: 'web', + role: 'frontend-plugin', + }; + + const app = await buildApp([frontendPlugin]); + + const response = await request(app).get('/loaded-plugins'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual([frontendPlugin]); + }); + + it('enforces authentication before returning the plugin list', async () => { + const httpAuth = mockServices.httpAuth(); + const credentialsSpy = jest.spyOn(httpAuth, 'credentials'); + + const app = await buildApp(plugins, httpAuth); + await request(app).get('/loaded-plugins'); + + expect(credentialsSpy).toHaveBeenCalledWith(expect.anything(), { + allow: ['user', 'service'], + }); + }); }); }); diff --git a/plugins/licensed-users-info-backend/src/database/databaseUserInfoStore.test.ts b/plugins/licensed-users-info-backend/src/database/databaseUserInfoStore.test.ts new file mode 100644 index 0000000000..70e378913d --- /dev/null +++ b/plugins/licensed-users-info-backend/src/database/databaseUserInfoStore.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Knex } from 'knex'; + +import { DatabaseUserInfoStore, UserInfoRow } from './databaseUserInfoStore'; + +// Knex query builders are both thenable (resolve to rows) and chainable +// (`.count().first()`). The helper reproduces both shapes from a single call +// and exposes the spies so tests can assert the exact query methods the store +// invokes, keeping failures clear and less sensitive to the promise/method +// interop. +const mockDatabase = (rows: UserInfoRow[], countResult?: { count: number }) => { + const first = jest.fn().mockResolvedValue(countResult); + const count = jest.fn().mockReturnValue({ first }); + const query: any = Promise.resolve(rows); + query.count = count; + const table = jest.fn().mockReturnValue(query); + return { db: table as unknown as Knex, table, count, first }; +}; + +const userRow: UserInfoRow = { + user_entity_ref: 'user:default/jdoe', + user_info: '{}', + updated_at: '2026-01-01 12:00:00', +}; + +describe('DatabaseUserInfoStore', () => { + it('returns the recorded user rows from the user_info table', async () => { + const { db, table } = mockDatabase([userRow]); + const store = new DatabaseUserInfoStore(db); + + await expect(store.getListUsers()).resolves.toEqual([userRow]); + expect(table).toHaveBeenCalledWith('user_info'); + }); + + it('returns the active user count via a count query', async () => { + const { db, count } = mockDatabase([], { count: 7 }); + const store = new DatabaseUserInfoStore(db); + + await expect(store.getQuantityRecordedActiveUsers()).resolves.toEqual(7); + expect(count).toHaveBeenCalledWith('user_entity_ref as count'); + }); + + it('throws when the count query returns no result', async () => { + const { db } = mockDatabase([], undefined); + const store = new DatabaseUserInfoStore(db); + + await expect(store.getQuantityRecordedActiveUsers()).rejects.toThrow( + 'No user info found', + ); + }); +}); diff --git a/plugins/licensed-users-info-backend/src/service/catalogStore.test.ts b/plugins/licensed-users-info-backend/src/service/catalogStore.test.ts new file mode 100644 index 0000000000..16477dbff9 --- /dev/null +++ b/plugins/licensed-users-info-backend/src/service/catalogStore.test.ts @@ -0,0 +1,77 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { mockServices } from '@backstage/backend-test-utils'; +import { CatalogClient } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; + +import { CatalogEntityStore } from './catalogStore'; + +const buildStore = (items: Entity[]) => { + const getEntities = jest.fn().mockResolvedValue({ items }); + const catalogClient = { getEntities } as unknown as CatalogClient; + const store = new CatalogEntityStore(catalogClient, mockServices.auth()); + return { store, getEntities }; +}; + +describe('CatalogEntityStore', () => { + it('keys User entities by a lowercased default-namespace reference', async () => { + const { store } = buildStore([ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { name: 'JDoe' }, + spec: { profile: { displayName: 'John Doe' } }, + }, + ]); + + const entityMap = await store.getUserEntities(); + + expect(entityMap.size).toEqual(1); + expect(entityMap.get('user:default/jdoe')).toMatchObject({ + metadata: { name: 'JDoe' }, + }); + }); + + it('ignores non-User kinds and entities without a name', async () => { + const { store } = buildStore([ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'team-a' }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: {} as Entity['metadata'], + }, + ]); + + const entityMap = await store.getUserEntities(); + + expect(entityMap.size).toEqual(0); + }); + + it('queries the catalog for User entities with an on-behalf-of token', async () => { + const { store, getEntities } = buildStore([]); + + await store.getUserEntities(); + + expect(getEntities).toHaveBeenCalledWith( + expect.objectContaining({ filter: { kind: 'User' } }), + expect.objectContaining({ token: expect.any(String) }), + ); + }); +}); diff --git a/plugins/licensed-users-info-backend/src/service/readBackstageTokenExpiration.test.ts b/plugins/licensed-users-info-backend/src/service/readBackstageTokenExpiration.test.ts new file mode 100644 index 0000000000..9852d90032 --- /dev/null +++ b/plugins/licensed-users-info-backend/src/service/readBackstageTokenExpiration.test.ts @@ -0,0 +1,60 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { mockServices } from '@backstage/backend-test-utils'; + +import { readBackstageTokenExpiration } from './readBackstageTokenExpiration'; + +describe('readBackstageTokenExpiration', () => { + it('returns the default (3600s) when the key is not configured', () => { + const config = mockServices.rootConfig(); + expect(readBackstageTokenExpiration(config)).toEqual(3600); + }); + + it('returns the configured duration when within the allowed range', () => { + const config = mockServices.rootConfig({ + data: { auth: { backstageTokenExpiration: { minutes: 30 } } }, + }); + expect(readBackstageTokenExpiration(config)).toEqual(1800); + }); + + it('clamps to the minimum (600s) when the configured value is too low', () => { + const config = mockServices.rootConfig({ + data: { auth: { backstageTokenExpiration: { seconds: 100 } } }, + }); + expect(readBackstageTokenExpiration(config)).toEqual(600); + }); + + it('clamps to the maximum (86400s) when the configured value is too high', () => { + const config = mockServices.rootConfig({ + data: { auth: { backstageTokenExpiration: { hours: 48 } } }, + }); + expect(readBackstageTokenExpiration(config)).toEqual(86400); + }); + + it('returns the minimum boundary (600s) unchanged', () => { + const config = mockServices.rootConfig({ + data: { auth: { backstageTokenExpiration: { seconds: 600 } } }, + }); + expect(readBackstageTokenExpiration(config)).toEqual(600); + }); + + it('returns the maximum boundary (86400s) unchanged', () => { + const config = mockServices.rootConfig({ + data: { auth: { backstageTokenExpiration: { hours: 24 } } }, + }); + expect(readBackstageTokenExpiration(config)).toEqual(86400); + }); +}); diff --git a/plugins/licensed-users-info-backend/src/service/router.integration.test.ts b/plugins/licensed-users-info-backend/src/service/router.integration.test.ts new file mode 100644 index 0000000000..65ebfb623a --- /dev/null +++ b/plugins/licensed-users-info-backend/src/service/router.integration.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { mockServices, startTestBackend } from '@backstage/backend-test-utils'; + +import request from 'supertest'; + +import { licensedUsersInfoPlugin } from '../plugin'; + +// The plugin reads `backend.database` directly via DatabaseManager.fromConfig +// and disables itself for a pure in-memory SQLite database. A SQLite database +// with an explicit (in-memory) directory keeps the plugin enabled while staying +// cluster-free — this is the real backend wiring, no mocks of the plugin itself. +const sqliteConfig = mockServices.rootConfig.factory({ + data: { + backend: { + database: { + client: 'better-sqlite3', + connection: { directory: ':memory:' }, + }, + }, + }, +}); + +// Pure in-memory SQLite (no `directory`) trips the plugin's self-disable check. +const inMemoryConfig = mockServices.rootConfig.factory({ + data: { + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + }, +}); + +describe('licensed-users-info backend (Layer 2 integration)', () => { + it('starts the real plugin and serves the health endpoint', async () => { + const { server } = await startTestBackend({ + features: [licensedUsersInfoPlugin, sqliteConfig], + }); + + const response = await request(server).get( + '/api/licensed-users-info/health', + ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ status: 'ok' }); + }, 30_000); + + it('disables all routes under a pure in-memory SQLite database', async () => { + const { server } = await startTestBackend({ + features: [licensedUsersInfoPlugin, inMemoryConfig], + }); + + const response = await request(server).get( + '/api/licensed-users-info/health', + ); + + expect(response.status).toEqual(404); + }, 30_000); +}); diff --git a/plugins/licensed-users-info-backend/src/service/router.test.ts b/plugins/licensed-users-info-backend/src/service/router.test.ts index 036119530e..7b177cb83e 100644 --- a/plugins/licensed-users-info-backend/src/service/router.test.ts +++ b/plugins/licensed-users-info-backend/src/service/router.test.ts @@ -1,9 +1,12 @@ -import { mockServices } from '@backstage/backend-test-utils'; +import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; +import { NotAllowedError } from '@backstage/errors'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; import express from 'express'; +import { json2csv } from 'json-2-csv'; import request from 'supertest'; -import { createRouter } from './router'; +import { createRouter, permissionCheck, rowToResponse } from './router'; jest.mock('@backstage/backend-defaults/database', () => ({ DatabaseManager: { @@ -15,33 +18,264 @@ jest.mock('@backstage/backend-defaults/database', () => ({ }, })); +// Keep the real CSV serializer by default; individual tests override it to +// exercise the conversion-failure branch. +jest.mock('json-2-csv', () => { + const actual = jest.requireActual('json-2-csv'); + return { ...actual, json2csv: jest.fn(actual.json2csv) }; +}); + +const mockGetQuantityRecordedActiveUsers = jest.fn(); +const mockGetListUsers = jest.fn(); +jest.mock('../database/databaseUserInfoStore', () => ({ + DatabaseUserInfoStore: jest.fn().mockImplementation(() => ({ + getQuantityRecordedActiveUsers: mockGetQuantityRecordedActiveUsers, + getListUsers: mockGetListUsers, + })), +})); + +const mockGetUserEntities = jest.fn(); +jest.mock('./catalogStore', () => ({ + CatalogEntityStore: jest.fn().mockImplementation(() => ({ + getUserEntities: mockGetUserEntities, + })), +})); + describe('createRouter', () => { let app: express.Express; + const permissions = mockServices.permissions.mock(); - beforeAll(async () => { - // TODO: Replace with module + const buildApp = async () => { const router = await createRouter({ logger: mockServices.logger.mock(), config: mockServices.rootConfig(), auth: mockServices.auth.mock(), discovery: mockServices.discovery.mock(), - permissions: mockServices.permissions.mock(), + permissions, httpAuth: mockServices.httpAuth.mock(), lifecycle: mockServices.lifecycle.mock(), }); - app = express().use(router); - }); + return express().use(router); + }; - beforeEach(() => { - jest.resetAllMocks(); + beforeEach(async () => { + jest.clearAllMocks(); + permissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + mockGetUserEntities.mockResolvedValue(new Map()); + app = await buildApp(); }); describe('GET /health', () => { - it('returns ok', async () => { + it('returns ok without requiring authorization', async () => { const response = await request(app).get('/health'); expect(response.status).toEqual(200); expect(response.body).toEqual({ status: 'ok' }); + expect(permissions.authorize).not.toHaveBeenCalled(); + }); + }); + + describe('GET /users/quantity', () => { + it('returns the number of recorded active users', async () => { + mockGetQuantityRecordedActiveUsers.mockResolvedValue(42); + + const response = await request(app).get('/users/quantity'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ quantity: 42 }); + }); + + it('returns 403 when the caller is not authorized', async () => { + permissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + const response = await request(app).get('/users/quantity'); + + expect(response.status).toEqual(403); + expect(mockGetQuantityRecordedActiveUsers).not.toHaveBeenCalled(); + }); + }); + + describe('GET /users', () => { + const userRow = { + user_entity_ref: 'user:default/jdoe', + user_info: '{}', + updated_at: '2026-01-01 12:00:00', + }; + + it('returns the list of users enriched with catalog profile data', async () => { + mockGetListUsers.mockResolvedValue([userRow]); + mockGetUserEntities.mockResolvedValue( + new Map([ + [ + 'user:default/jdoe', + { + spec: { + profile: { + displayName: 'John Doe', + email: 'jdoe@example.com', + }, + }, + }, + ], + ]), + ); + + const response = await request(app).get('/users'); + + expect(response.status).toEqual(200); + expect(response.body).toHaveLength(1); + expect(response.body[0]).toMatchObject({ + userEntityRef: 'user:default/jdoe', + displayName: 'John Doe', + email: 'jdoe@example.com', + }); }); + + it('returns the list as CSV when the content-type is text/csv', async () => { + mockGetListUsers.mockResolvedValue([userRow]); + + const response = await request(app) + .get('/users') + .set('content-type', 'text/csv'); + + expect(response.status).toEqual(200); + expect(response.headers['content-type']).toContain('text/csv'); + expect(response.text).toContain('userEntityRef'); + expect(response.text).toContain('user:default/jdoe'); + }); + + it('returns users without profile data when no catalog entity matches', async () => { + mockGetListUsers.mockResolvedValue([userRow]); + mockGetUserEntities.mockResolvedValue(new Map()); + + const response = await request(app).get('/users'); + + expect(response.status).toEqual(200); + expect(response.body[0]).toEqual({ + userEntityRef: 'user:default/jdoe', + lastAuthTime: expect.any(String), + }); + }); + + it('returns 500 when CSV conversion fails', async () => { + mockGetListUsers.mockResolvedValue([userRow]); + (json2csv as jest.Mock).mockImplementationOnce(() => { + throw new Error('conversion failed'); + }); + + const response = await request(app) + .get('/users') + .set('content-type', 'text/csv'); + + expect(response.status).toEqual(500); + expect(response.text).toContain('Error converting to CSV'); + }); + + it('returns 403 when the caller is not authorized', async () => { + permissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + const response = await request(app).get('/users'); + + expect(response.status).toEqual(403); + expect(mockGetListUsers).not.toHaveBeenCalled(); + }); + }); + + describe('SQLite in-memory database', () => { + it('disables the router and warns when SQLite has no on-disk directory', async () => { + const logger = mockServices.logger.mock(); + const router = await createRouter({ + logger, + config: mockServices.rootConfig({ + data: { backend: { database: { client: 'better-sqlite3' } } }, + }), + auth: mockServices.auth.mock(), + discovery: mockServices.discovery.mock(), + permissions, + httpAuth: mockServices.httpAuth.mock(), + lifecycle: mockServices.lifecycle.mock(), + }); + const disabledApp = express().use(router); + + const response = await request(disabledApp).get('/health'); + + expect(response.status).toEqual(404); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('was disabled'), + ); + }); + }); +}); + +describe('rowToResponse', () => { + it('parses a SQL timestamp and back-dates it by the token expiration window', () => { + const result = rowToResponse( + { + user_entity_ref: 'user:default/jdoe', + user_info: '{}', + updated_at: '2026-01-01 12:00:00', + }, + 3600, + ); + + expect(result.userEntityRef).toEqual('user:default/jdoe'); + // 12:00:00 UTC minus a 3600s window => 11:00:00 UTC + expect(result.lastAuthTime).toEqual('Thu, 01 Jan 2026 11:00:00 GMT'); + }); + + it('falls back to JS Date parsing for ISO timestamps', () => { + const result = rowToResponse( + { + user_entity_ref: 'user:default/jdoe', + user_info: '{}', + updated_at: '2026-01-01T12:00:00.000Z', + }, + 3600, + ); + + expect(result.lastAuthTime).toEqual('Thu, 01 Jan 2026 11:00:00 GMT'); + }); + + it('throws when the timestamp cannot be parsed', () => { + expect(() => + rowToResponse( + { + user_entity_ref: 'user:default/jdoe', + user_info: '{}', + updated_at: 'not-a-date', + }, + 3600, + ), + ).toThrow('Failed to parse expiration date format'); + }); +}); + +describe('permissionCheck', () => { + const credentials = mockCredentials.user(); + + const permissionsReturning = ( + result: AuthorizeResult.ALLOW | AuthorizeResult.DENY, + ) => { + const permissions = mockServices.permissions.mock(); + permissions.authorize.mockResolvedValue([{ result }]); + return permissions; + }; + + it('resolves when the decision is ALLOW', async () => { + await expect( + permissionCheck(permissionsReturning(AuthorizeResult.ALLOW), credentials), + ).resolves.toBeUndefined(); + }); + + it('throws NotAllowedError when the decision is DENY', async () => { + await expect( + permissionCheck(permissionsReturning(AuthorizeResult.DENY), credentials), + ).rejects.toThrow(NotAllowedError); }); }); diff --git a/plugins/scalprum-backend/src/service/router.integration.test.ts b/plugins/scalprum-backend/src/service/router.integration.test.ts new file mode 100644 index 0000000000..2965d1c83c --- /dev/null +++ b/plugins/scalprum-backend/src/service/router.integration.test.ts @@ -0,0 +1,94 @@ +import { + DynamicPluginProvider, + dynamicPluginsServiceRef, +} from '@backstage/backend-dynamic-feature-service'; +import { createServiceFactory } from '@backstage/backend-plugin-api'; +import { + createMockDirectory, + mockServices, + startTestBackend, +} from '@backstage/backend-test-utils'; + +import request from 'supertest'; + +import url from 'url'; + +import { scalprumPlugin } from '../plugin'; + +// The plugin resolves dynamic frontend packages from `dynamicPluginsServiceRef`. +// A stub lets each test drive the real plugin wiring (router, static serving, +// unauthenticated auth policy) without a dynamic-plugin runtime. +const dynamicPluginsServiceWith = ( + plugins: unknown[], + availablePackages: unknown[], +) => + createServiceFactory({ + service: dynamicPluginsServiceRef, + deps: {}, + async factory() { + return { + plugins: () => plugins, + availablePackages, + } as unknown as DynamicPluginProvider; + }, + }); + +describe('scalprum backend (Layer 2 integration)', () => { + // Created at describe scope so its afterAll cleanup is registered and the + // temporary directory does not leak across tests. + const mockDir = createMockDirectory(); + + it('serves an empty plugin map unauthenticated when no frontend plugins are loaded', async () => { + const { server } = await startTestBackend({ + features: [ + scalprumPlugin, + dynamicPluginsServiceWith([], []), + mockServices.rootConfig.factory(), + ], + }); + + const response = await request(server).get('/api/scalprum/plugins'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({}); + }, 30_000); + + it('lists a web plugin and serves its manifest as static content', async () => { + mockDir.setContent({ + 'dist-scalprum': { + 'plugin-manifest.json': JSON.stringify({ name: 'scalprum-plugin' }), + }, + }); + + const { server } = await startTestBackend({ + features: [ + scalprumPlugin, + dynamicPluginsServiceWith( + [{ name: 'frontend-plugin', version: '1.0.0', platform: 'web' }], + [ + { + manifest: { name: 'frontend-plugin', version: '1.0.0' }, + location: url.pathToFileURL(mockDir.path), + }, + ], + ), + mockServices.rootConfig.factory(), + ], + }); + + const list = await request(server).get('/api/scalprum/plugins'); + expect(list.status).toEqual(200); + expect(list.body['scalprum-plugin']).toMatchObject({ + name: 'scalprum-plugin', + manifestLocation: expect.stringContaining( + '/scalprum-plugin/plugin-manifest.json', + ), + }); + + const manifest = await request(server).get( + '/api/scalprum/scalprum-plugin/plugin-manifest.json', + ); + expect(manifest.status).toEqual(200); + expect(manifest.body).toEqual({ name: 'scalprum-plugin' }); + }, 30_000); +}); diff --git a/plugins/scalprum-backend/src/service/router.test.ts b/plugins/scalprum-backend/src/service/router.test.ts index c373838912..8d9ac560c6 100644 --- a/plugins/scalprum-backend/src/service/router.test.ts +++ b/plugins/scalprum-backend/src/service/router.test.ts @@ -193,4 +193,41 @@ describe('createRouter', () => { expect(warn).toHaveBeenCalledWith(tc.expectedWarning); } }); + + it('warns and skips a web plugin that has no matching package', async () => { + const warn = jest.fn(); + const logger: LoggerService = { + error: jest.fn(), + warn, + info: jest.fn(), + debug: jest.fn(), + child: jest.fn(), + }; + + const pluginManager = { + availablePackages: [], + plugins: () => [ + { name: 'orphan-plugin', version: '1.0.0', platform: 'web' }, + ], + } as unknown as DynamicPluginManager; + + router = await createRouter({ + logger, + discovery: { + getBaseUrl: jest.fn(), + getExternalBaseUrl: jest.fn().mockReturnValue('http://localhost:3000'), + }, + pluginManager, + config: mockServices.rootConfig(), + }); + + app.use('/scalprum', router); + const response = await request(app).get('/scalprum/plugins'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({}); + expect(warn).toHaveBeenCalledWith( + 'Could not find package for plugin orphan-plugin@1.0.0', + ); + }); });