fix(apps): initialize launch permissions from license policy

This commit is contained in:
2026-09-09 13:07:42 +09:00
parent 3f5e42c962
commit 904c93bc79
3 changed files with 95 additions and 5 deletions
+64
View File
@@ -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();
});
});
+20 -5
View File
@@ -62,7 +62,7 @@ export class AppsService {
async getApps(accessToken?: string) { async getApps(accessToken?: string) {
const authUserId = await this.getAuthUserId(accessToken); const authUserId = await this.getAuthUserId(accessToken);
if (authUserId) { if (authUserId) {
await this.ensureDefaultAppViewPermissions(authUserId); await this.ensureDefaultAppViewPermissions(authUserId, accessToken!);
} }
const apps = await this.prisma.launcherApp.findMany({ const apps = await this.prisma.launcherApp.findMany({
@@ -119,6 +119,9 @@ export class AppsService {
async getUpdateStatus(appKey: string, input: UpdateStatusInput) { async getUpdateStatus(appKey: string, input: UpdateStatusInput) {
const isLauncherUpdate = appKey.toLowerCase() === 'launcher'; const isLauncherUpdate = appKey.toLowerCase() === 'launcher';
const authUserId = isLauncherUpdate ? null : await this.getAuthUserId(input.accessToken); const authUserId = isLauncherUpdate ? null : await this.getAuthUserId(input.accessToken);
if (authUserId) {
await this.ensureDefaultAppViewPermissions(authUserId, input.accessToken!);
}
const canLaunch = isLauncherUpdate const canLaunch = isLauncherUpdate
? true ? true
: authUserId : authUserId
@@ -301,19 +304,31 @@ export class AppsService {
return Boolean(permission); return Boolean(permission);
} }
private async ensureDefaultAppViewPermissions(authUserId: string) { private async ensureDefaultAppViewPermissions(authUserId: string, accessToken: string) {
const apps = await this.prisma.launcherApp.findMany({ const apps = await this.prisma.launcherApp.findMany({
where: { isEnabled: true }, where: { isEnabled: true },
select: { id: true }, select: { id: true, appKey: true },
}); });
if (!apps.length) return; 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({ await this.prisma.launcherUserApp.createMany({
data: apps.map((app) => ({ data: missing.map((app) => ({
authUserId, authUserId,
appId: app.id, appId: app.id,
canView: true, canView: true,
canLaunch: false, canLaunch: byAppKey.get(app.appKey) ?? policy.defaultCanLaunch,
})), })),
skipDuplicates: true, skipDuplicates: true,
}); });
+11
View File
@@ -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) { async handoff(accessToken: string | undefined, audienceApp: string) {
return this.request('/auth/handoff', { return this.request('/auth/handoff', {
method: 'POST', method: 'POST',