You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

377 lines
11 KiB

import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums';
import { permissions } from '@ghostfolio/common/permissions';
import {
getScopesOfOwnAccess,
getScopesOfUnrestrictedImpersonation,
scopes
} from '@ghostfolio/common/scopes';
import type { UserWithSettings } from '@ghostfolio/common/types';
import { Access } from '@prisma/client';
import { ImpersonationService } from './impersonation.service';
describe('Impersonation service', () => {
const accessId = 'a5d3f2c1-9b4e-4c8a-8f2d-1e6b7c9a0d3f';
const authenticatedUserId = 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d';
const impersonatedUserId = 'e2d43f0d-1a41-4b6e-9d5b-6f9a2b7c8d1e';
const authenticatedUser = {
id: authenticatedUserId,
permissions: [],
settings: { settings: { baseCurrency: 'CHF' } },
subscription: { type: SubscriptionType.Premium }
} as unknown as UserWithSettings;
function createService({
access,
impersonatedUser,
isSubscriptionEnabled = false
}: {
access?: Partial<Access>;
impersonatedUser?: unknown;
isSubscriptionEnabled?: boolean;
} = {}) {
const getSubscription = jest.fn().mockResolvedValue({
type: SubscriptionType.Basic
});
const configurationService = {
get: (key: string) => {
return key === 'ENABLE_FEATURE_SUBSCRIPTION'
? isSubscriptionEnabled
: undefined;
}
} as unknown as ConfigurationService;
const prismaService = {
access: {
findFirst: async ({
where
}: {
where?: {
granteeUserId?: string;
id?: string;
type?: { in?: string[] };
};
}) => {
if (!access) {
return null;
}
if (
where?.granteeUserId &&
where.granteeUserId !== access.granteeUserId
) {
return null;
}
if (where?.id && where.id !== access.id) {
return null;
}
if (where?.type?.in && !where.type.in.includes(access.type)) {
return null;
}
return access;
}
},
user: {
findUnique: async () => {
return impersonatedUser ?? null;
}
}
} as unknown as PrismaService;
const subscriptionService = {
getSubscription
} as unknown as SubscriptionService;
return {
getSubscription,
service: new ImpersonationService(
configurationService,
prismaService,
subscriptionService
)
};
}
describe('Without an impersonation', () => {
it('Resolves the own access of the authenticated user', async () => {
const { service } = createService();
expect(await service.resolve({ user: authenticatedUser })).toEqual({
authenticatedUserSubscription: authenticatedUser.subscription,
isActive: false,
scopes: getScopesOfOwnAccess(),
userId: authenticatedUserId,
userSettings: { baseCurrency: 'CHF' },
userSubscription: authenticatedUser.subscription
});
});
it('Resolves a user without settings', async () => {
const { service } = createService();
const { userSettings } = await service.resolve({
user: { id: authenticatedUserId } as UserWithSettings
});
expect(userSettings).toEqual({});
});
});
describe('With an impersonation', () => {
const grantedAccess = {
granteeUserId: authenticatedUserId,
id: accessId,
scopes: [scopes.portfolioRead],
type: 'PRIVATE',
userId: impersonatedUserId
} as unknown as Access;
const impersonatedUser = {
createdAt: new Date('2024-01-01'),
id: impersonatedUserId,
settings: { settings: { baseCurrency: 'USD' } },
subscriptions: []
};
it('Resolves the scopes of the granted access', async () => {
const { service } = createService({
access: grantedAccess,
impersonatedUser
});
expect(
await service.resolve({
impersonationId: accessId,
user: authenticatedUser
})
).toEqual({
accessId,
authenticatedUserSubscription: authenticatedUser.subscription,
isActive: true,
scopes: [scopes.portfolioRead],
userId: impersonatedUserId,
userSettings: { baseCurrency: 'USD' },
userSubscription: undefined
});
});
// The subscription of the authenticated user is required to evaluate the
// more restrictive of the two subscriptions
it('Keeps the subscription of the authenticated user', async () => {
const { service } = createService({
access: grantedAccess,
impersonatedUser
});
const { authenticatedUserSubscription } = await service.resolve({
impersonationId: accessId,
user: authenticatedUser
});
expect(authenticatedUserSubscription).toEqual(
authenticatedUser.subscription
);
});
it('Falls back to the default currency without settings', async () => {
const { service } = createService({
access: grantedAccess,
impersonatedUser: { ...impersonatedUser, settings: null }
});
const { userSettings } = await service.resolve({
impersonationId: accessId,
user: authenticatedUser
});
expect(userSettings).toEqual({ baseCurrency: DEFAULT_CURRENCY });
});
it('Omits the subscription while the feature is disabled', async () => {
const { getSubscription, service } = createService({
access: grantedAccess,
impersonatedUser
});
const { userSubscription } = await service.resolve({
impersonationId: accessId,
user: authenticatedUser
});
expect(userSubscription).toBeUndefined();
expect(getSubscription).not.toHaveBeenCalled();
});
it('Resolves the subscription while the feature is enabled', async () => {
const { getSubscription, service } = createService({
access: grantedAccess,
impersonatedUser,
isSubscriptionEnabled: true
});
const { userSubscription } = await service.resolve({
impersonationId: accessId,
user: authenticatedUser
});
expect(userSubscription).toEqual({ type: SubscriptionType.Basic });
expect(getSubscription).toHaveBeenCalledWith({
createdAt: impersonatedUser.createdAt,
subscriptions: []
});
});
// An administrator impersonates by a user id instead of an access id
it('Resolves the unrestricted scopes of an administrator', async () => {
const { service } = createService({
impersonatedUser: { id: impersonatedUserId }
});
const { isActive, scopes: scopesOfImpersonation } = await service.resolve(
{
impersonationId: impersonatedUserId,
user: {
...authenticatedUser,
permissions: [permissions.impersonateAllUsers]
} as UserWithSettings
}
);
expect(isActive).toEqual(true);
expect(scopesOfImpersonation).toEqual(
getScopesOfUnrestrictedImpersonation()
);
});
});
// A client of the model context protocol has no authenticated user, hence
// the access itself is the credential
describe('With an access as the credential', () => {
const accessOfMcp = {
granteeUserId: null,
id: accessId,
scopes: [scopes.portfolioRead],
settings: {},
type: 'MCP',
userId: impersonatedUserId
} as unknown as Access;
const impersonatedUser = {
createdAt: new Date('2024-01-01'),
id: impersonatedUserId,
settings: { settings: { baseCurrency: 'USD' } },
subscriptions: []
};
it('Resolves the scopes of the access', async () => {
const {
isActive,
scopes: scopesOfAccess,
userId
} = await createService({
access: accessOfMcp,
impersonatedUser
}).service.resolve({ impersonationId: accessId, types: ['MCP'] });
expect(isActive).toEqual(true);
expect(scopesOfAccess).toEqual([scopes.portfolioRead]);
expect(userId).toEqual(impersonatedUserId);
});
// The absence of the types is what stops an access from becoming a
// credential, hence a caller which omits them gets nothing
it('Refuses the identifier without the types', async () => {
const { isActive, userId } = await createService({
access: accessOfMcp,
impersonatedUser
}).service.resolve({ impersonationId: accessId });
expect(isActive).toEqual(false);
expect(userId).toBeUndefined();
});
it('Refuses the identifier with an empty list of types', async () => {
const { isActive, userId } = await createService({
access: accessOfMcp,
impersonatedUser
}).service.resolve({ impersonationId: accessId, types: [] });
expect(isActive).toEqual(false);
expect(userId).toBeUndefined();
});
it('Refuses an access of the type PRIVATE', async () => {
const { isActive, userId } = await createService({
access: { ...accessOfMcp, type: 'PRIVATE' } as unknown as Access,
impersonatedUser
}).service.resolve({ impersonationId: accessId, types: ['MCP'] });
expect(isActive).toEqual(false);
expect(userId).toBeUndefined();
});
it('Refuses an access of the type PUBLIC', async () => {
const { isActive, userId } = await createService({
access: { ...accessOfMcp, type: 'PUBLIC' } as unknown as Access,
impersonatedUser
}).service.resolve({ impersonationId: accessId, types: ['MCP'] });
expect(isActive).toEqual(false);
expect(userId).toBeUndefined();
});
// The identifier is the one of the access and not the one of the user who
// granted it, hence an access can never be resolved by another identifier
it('Refuses the identifier of another access', async () => {
const { isActive, userId } = await createService({
access: accessOfMcp,
impersonatedUser
}).service.resolve({
impersonationId: 'b7c9a0d3-f2c1-4c8a-8f2d-1e6b5d3f2c19',
types: ['MCP']
});
expect(isActive).toEqual(false);
expect(userId).toBeUndefined();
});
it('Refuses the identifier of the user who granted the access', async () => {
const { isActive, userId } = await createService({
access: accessOfMcp,
impersonatedUser
}).service.resolve({
impersonationId: impersonatedUserId,
types: ['MCP']
});
expect(isActive).toEqual(false);
expect(userId).toBeUndefined();
});
});
// The guard rejects the request in this case, hence the context must not
// present the data of the authenticated user as impersonated data
describe('With an identifier which cannot be resolved', () => {
it('Resolves the own access instead', async () => {
const { service } = createService();
const { isActive, userId } = await service.resolve({
impersonationId: 'a-revoked-access-id',
user: authenticatedUser
});
expect(isActive).toEqual(false);
expect(userId).toEqual(authenticatedUserId);
});
});
});