diff --git a/src/apps/apps-permissions.spec.ts b/src/apps/apps-permissions.spec.ts new file mode 100644 index 0000000..eb00909 --- /dev/null +++ b/src/apps/apps-permissions.spec.ts @@ -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(); + }); +}); diff --git a/src/apps/apps.service.ts b/src/apps/apps.service.ts index 2442320..281469e 100644 --- a/src/apps/apps.service.ts +++ b/src/apps/apps.service.ts @@ -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, }); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index c094846..e78e3e3 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -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',