fix(apps): initialize launch permissions from license policy
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import { AppsService } from './apps.service';
|
||||
|
||||
describe('initial launcher permissions', () => {
|
||||
const setup = () => {
|
||||
const prisma = {
|
||||
launcherApp: { findMany: jest.fn().mockResolvedValue([{ id: 'lms', appKey: 'LMS' }, { id: 'other', appKey: 'OTHER' }]) },
|
||||
launcherUserApp: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
createMany: jest.fn().mockResolvedValue({ count: 2 }),
|
||||
},
|
||||
};
|
||||
const auth = {
|
||||
getLauncherAppPolicy: jest.fn().mockResolvedValue({
|
||||
defaultCanLaunch: true,
|
||||
permissions: [{ appKey: 'LMS', canLaunch: true }, { appKey: 'OTHER', canLaunch: false }],
|
||||
}),
|
||||
};
|
||||
const service = new AppsService(prisma as never, auth as never);
|
||||
return { service, prisma, auth };
|
||||
};
|
||||
|
||||
it('honors both allowed and denied apps in the license policy', async () => {
|
||||
const { service, prisma, auth } = setup();
|
||||
await service['ensureDefaultAppViewPermissions']('student', 'token');
|
||||
expect(auth.getLauncherAppPolicy).toHaveBeenCalledWith('token');
|
||||
expect(prisma.launcherUserApp.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{ authUserId: 'student', appId: 'lms', canView: true, canLaunch: true },
|
||||
{ authUserId: 'student', appId: 'other', canView: true, canLaunch: false },
|
||||
], skipDuplicates: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves existing user permissions unchanged and only initializes missing apps', async () => {
|
||||
const { service, prisma } = setup();
|
||||
prisma.launcherUserApp.findMany.mockResolvedValue([{ appId: 'lms' }]);
|
||||
await service['ensureDefaultAppViewPermissions']('student', 'token');
|
||||
expect(prisma.launcherUserApp.createMany.mock.calls[0][0].data).toEqual([
|
||||
{ authUserId: 'student', appId: 'other', canView: true, canLaunch: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not write permanent defaults when the auth server fails', async () => {
|
||||
const { service, prisma, auth } = setup();
|
||||
auth.getLauncherAppPolicy.mockRejectedValue(new Error('unavailable'));
|
||||
await expect(service['ensureDefaultAppViewPermissions']('student', 'token')).rejects.toThrow('unavailable');
|
||||
expect(prisma.launcherUserApp.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies apps when there is no active license', async () => {
|
||||
const { service, prisma, auth } = setup();
|
||||
auth.getLauncherAppPolicy.mockResolvedValue({ defaultCanLaunch: false, permissions: [] });
|
||||
await service['ensureDefaultAppViewPermissions']('student', 'token');
|
||||
expect(prisma.launcherUserApp.createMany.mock.calls[0][0].data.every((row: { canLaunch: boolean }) => !row.canLaunch)).toBe(true);
|
||||
});
|
||||
|
||||
it('skips policy lookup once all user permissions exist', async () => {
|
||||
const { service, prisma, auth } = setup();
|
||||
prisma.launcherUserApp.findMany.mockResolvedValue([{ appId: 'lms' }, { appId: 'other' }]);
|
||||
await service['ensureDefaultAppViewPermissions']('student', 'token');
|
||||
expect(auth.getLauncherAppPolicy).not.toHaveBeenCalled();
|
||||
expect(prisma.launcherUserApp.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -62,7 +62,7 @@ export class AppsService {
|
||||
async getApps(accessToken?: string) {
|
||||
const authUserId = await this.getAuthUserId(accessToken);
|
||||
if (authUserId) {
|
||||
await this.ensureDefaultAppViewPermissions(authUserId);
|
||||
await this.ensureDefaultAppViewPermissions(authUserId, accessToken!);
|
||||
}
|
||||
|
||||
const apps = await this.prisma.launcherApp.findMany({
|
||||
@@ -119,6 +119,9 @@ export class AppsService {
|
||||
async getUpdateStatus(appKey: string, input: UpdateStatusInput) {
|
||||
const isLauncherUpdate = appKey.toLowerCase() === 'launcher';
|
||||
const authUserId = isLauncherUpdate ? null : await this.getAuthUserId(input.accessToken);
|
||||
if (authUserId) {
|
||||
await this.ensureDefaultAppViewPermissions(authUserId, input.accessToken!);
|
||||
}
|
||||
const canLaunch = isLauncherUpdate
|
||||
? true
|
||||
: authUserId
|
||||
@@ -301,19 +304,31 @@ export class AppsService {
|
||||
return Boolean(permission);
|
||||
}
|
||||
|
||||
private async ensureDefaultAppViewPermissions(authUserId: string) {
|
||||
private async ensureDefaultAppViewPermissions(authUserId: string, accessToken: string) {
|
||||
const apps = await this.prisma.launcherApp.findMany({
|
||||
where: { isEnabled: true },
|
||||
select: { id: true },
|
||||
select: { id: true, appKey: true },
|
||||
});
|
||||
if (!apps.length) return;
|
||||
|
||||
const existing = await this.prisma.launcherUserApp.findMany({
|
||||
where: { authUserId },
|
||||
select: { appId: true },
|
||||
});
|
||||
const existingIds = new Set(existing.map((permission) => permission.appId));
|
||||
const missing = apps.filter((app) => !existingIds.has(app.id));
|
||||
if (!missing.length) return;
|
||||
|
||||
// Do not persist deny defaults when policy lookup fails; retry on the next request.
|
||||
const policy = await this.authService.getLauncherAppPolicy(accessToken);
|
||||
const byAppKey = new Map(policy.permissions.map((permission) => [permission.appKey, permission.canLaunch]));
|
||||
|
||||
await this.prisma.launcherUserApp.createMany({
|
||||
data: apps.map((app) => ({
|
||||
data: missing.map((app) => ({
|
||||
authUserId,
|
||||
appId: app.id,
|
||||
canView: true,
|
||||
canLaunch: false,
|
||||
canLaunch: byAppKey.get(app.appKey) ?? policy.defaultCanLaunch,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
@@ -106,6 +106,17 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
async getLauncherAppPolicy(accessToken: string) {
|
||||
return this.request<{
|
||||
defaultCanLaunch: boolean;
|
||||
permissions: Array<{ appKey: string; canLaunch: boolean }>;
|
||||
}>('/auth/launcher-app-policy', {
|
||||
method: 'GET',
|
||||
accessToken,
|
||||
unauthorizedMessage: 'Invalid bearer token',
|
||||
});
|
||||
}
|
||||
|
||||
async handoff(accessToken: string | undefined, audienceApp: string) {
|
||||
return this.request('/auth/handoff', {
|
||||
method: 'POST',
|
||||
|
||||
Reference in New Issue
Block a user