refactor(permission): keep the notifications:push constant with the permission code

The consent check that reads it lives with the delivery code, outside
core. The grant flow test checks the grant through the permission
service directly.
This commit is contained in:
Juan Castro committed 2026-10-26 16:30:05 -04:00
1 parent 9fabd1d5b3
commit 47a6122c37
5 files changed
+34 -283

No files matched your search

@@ -50,12 +50,10 @@ import {
import { PuterServer } from '../../server.js';
import { createSecret as otpCreateSecret } from '../../services/auth/OTPUtil.js';
import { kvSharePermission } from '../../services/kvShare/kvShares.js';
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
import {
hasPushConsent,
FULL_API_ACCESS,
NOTIFICATIONS_PUSH_PERMISSION,
type PushConsentDeps,
} from '../../services/push/pushConsent.js';
} from '../../services/permission/consts.js';
import { setupTestServer } from '../../testUtil.js';
import { resetCardVerificationStatusCache } from '../../util/cardFallback.js';
import { FS_READ_LIMIT } from '../fs/limits.js';
@@ -2184,7 +2182,7 @@ describe('AuthController grant flows', () => {
).toContain(permission);
});
it('grant-user-app: grants an app push consent with notifications:push', async () => {
it('grant-user-app: grants and revokes notifications:push for an app', async () => {
const app = await server.stores.app.create(
{
name: `tgp-${uuidv4()}`,
@@ -2193,11 +2191,17 @@ describe('AuthController grant flows', () => {
},
{ ownerUserId: issuer.id },
);
const deps = {
stores: server.stores,
services: { permission: server.services.permission },
} as unknown as PushConsentDeps;
expect(await hasPushConsent(deps, issuer.id, app.uid)).toBe(false);
const user = await server.stores.user.getById(issuer.id);
const appActor = makeActor({
user: user!,
app: { uid: app.uid, id: app.id },
});
const hasPush = () =>
server.services.permission.check(
appActor,
NOTIFICATIONS_PUSH_PERMISSION,
);
expect(await hasPush()).toBe(false);
// Anything past the bare permission names nothing a prompt describes.
await expect(
@@ -2217,7 +2221,7 @@ describe('AuthController grant flows', () => {
statusCode: 400,
legacyCode: 'permission_not_grantable',
});
expect(await hasPushConsent(deps, issuer.id, app.uid)).toBe(false);
expect(await hasPush()).toBe(false);
const res = makeRes();
await inCtx(issuerActor, () =>
@@ -2233,7 +2237,21 @@ describe('AuthController grant flows', () => {
),
);
expect(res.body).toEqual({});
expect(await hasPushConsent(deps, issuer.id, app.uid)).toBe(true);
expect(await hasPush()).toBe(true);
await inCtx(issuerActor, () =>
controller.handleRevokeUserApp(
makeReq(
{
app_uid: app.uid,
permission: NOTIFICATIONS_PUSH_PERMISSION,
},
{ actor: issuerActor },
),
makeRes(),
),
);
expect(await hasPush()).toBe(false);
});
it('grant-user-app: resolves `origin` to the app when app_uid is omitted', async () => {
@@ -24,14 +24,13 @@ import {
type KvShareAccess,
kvShareRoot,
} from '../kvShare/kvShares.js';
import { NOTIFICATIONS_PUSH_PERMISSION } from '../push/pushConsent.js';
import {
APP_DATA_CLASSES,
APP_DATA_KV_OPS,
APP_DATA_PERMISSION_PREFIX,
type AppDataStore,
} from './appDataScopes.js';
import { MANAGE_PERM_PREFIX } from './consts.js';
import { MANAGE_PERM_PREFIX, NOTIFICATIONS_PUSH_PERMISSION } from './consts.js';
import { PermissionUtil } from './permissionUtil.js';
const ACCESS_LEVELS = ['read', 'write'] as const;
@@ -20,6 +20,9 @@
export const MANAGE_PERM_PREFIX = 'manage';
export const PERM_KEY_PREFIX = 'perm';
/** Per-app consent for an app's notifications to go out as pushes. */
export const NOTIFICATIONS_PUSH_PERMISSION = 'notifications:push';
/**
* Width of the `permission` column in the permission tables, which every
* dialect declares as `varchar(255)`. Anything building a permission string
@@ -1,206 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { makeActor, type Actor } from '../../core/actor.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { EVENTS_BACKGROUND_PERMISSION } from '../events/authorization.js';
import type { PermissionService } from '../permission/PermissionService.js';
import {
hasPushConsent,
NOTIFICATIONS_PUSH_PERMISSION,
type PushConsentDeps,
} from './pushConsent.js';
interface TestUser {
id: number;
uuid: string;
username: string;
}
describe('hasPushConsent', () => {
let server: PuterServer;
let permission: PermissionService;
let deps: PushConsentDeps;
const makeUser = async (): Promise<TestUser> =>
(await server.stores.user.create({
username: `push${Math.random().toString(36).slice(2, 10)}`,
uuid: uuidv4(),
password: null,
email: null,
requires_email_confirmation: false,
})) as TestUser;
const makeApp = async (owner: TestUser): Promise<string> => {
const create = server.stores.app.create.bind(
server.stores.app,
) as unknown as (
fields: Record<string, unknown>,
opts: { ownerUserId: number },
) => Promise<{ uid: string }>;
const app = await create(
{
name: `push-${uuidv4()}`,
title: 'Push app',
index_url: `https://push-${uuidv4()}.test/`,
},
{ ownerUserId: owner.id },
);
return app.uid;
};
const userActor = (user: TestUser): Actor => makeActor({ user });
const grant = (user: TestUser, appUid: string, perm: string) =>
permission.grantUserAppPermission(userActor(user), appUid, perm);
beforeAll(async () => {
server = await setupTestServer();
permission = server.services.permission as unknown as PermissionService;
deps = {
stores: server.stores,
services: { permission },
} as unknown as PushConsentDeps;
}, 60_000);
afterAll(async () => {
await server?.shutdown();
}, 60_000);
it('is false for an app the user never granted, though the user holds the root', async () => {
const user = await makeUser();
const appUid = await makeApp(user);
expect(
await permission.check(
userActor(user),
NOTIFICATIONS_PUSH_PERMISSION,
),
).toBe(true);
expect(await hasPushConsent(deps, user.id, appUid)).toBe(false);
});
it('is true only for the app and the user the grant names', async () => {
const user = await makeUser();
const other = await makeUser();
const appUid = await makeApp(user);
const otherAppUid = await makeApp(user);
await grant(user, appUid, NOTIFICATIONS_PUSH_PERMISSION);
expect(await hasPushConsent(deps, user.id, appUid)).toBe(true);
expect(await hasPushConsent(deps, user.id, otherAppUid)).toBe(false);
expect(await hasPushConsent(deps, other.id, appUid)).toBe(false);
});
it('turns false on the next check after the permission is revoked', async () => {
const user = await makeUser();
const appUid = await makeApp(user);
await grant(user, appUid, NOTIFICATIONS_PUSH_PERMISSION);
expect(await hasPushConsent(deps, user.id, appUid)).toBe(true);
await permission.revokeUserAppPermission(
userActor(user),
appUid,
NOTIFICATIONS_PUSH_PERMISSION,
);
expect(await hasPushConsent(deps, user.id, appUid)).toBe(false);
});
it('turns false on the next check after all of the app’s access is revoked', async () => {
const user = await makeUser();
const appUid = await makeApp(user);
await grant(user, appUid, NOTIFICATIONS_PUSH_PERMISSION);
expect(await hasPushConsent(deps, user.id, appUid)).toBe(true);
await permission.revokeUserAppAll(userActor(user), appUid);
expect(await hasPushConsent(deps, user.id, appUid)).toBe(false);
});
it('is not granted by events:background, nor grants it', async () => {
const user = await makeUser();
const backgroundApp = await makeApp(user);
const pushApp = await makeApp(user);
await grant(user, backgroundApp, EVENTS_BACKGROUND_PERMISSION);
await grant(user, pushApp, NOTIFICATIONS_PUSH_PERMISSION);
expect(await hasPushConsent(deps, user.id, backgroundApp)).toBe(false);
const pushAppRow = await server.stores.app.getByUid(pushApp);
expect(
await permission.check(
makeActor({ user, app: { uid: pushApp, id: pushAppRow.id } }),
EVENTS_BACKGROUND_PERMISSION,
),
).toBe(false);
});
it('builds the actor from the stored app uid', async () => {
const check = vi.fn(async () => true);
const stub = {
stores: {
user: {
getById: async () => ({ id: 1, uuid: 'u', username: 'u' }),
},
app: {
getByUid: async () => ({ id: 7, uid: 'app-canonical' }),
},
},
services: { permission: { check } },
} as unknown as PushConsentDeps;
expect(await hasPushConsent(stub, 1, 'APP-CANONICAL')).toBe(true);
expect(check).toHaveBeenCalledWith(
expect.objectContaining({ app: { uid: 'app-canonical', id: 7 } }),
NOTIFICATIONS_PUSH_PERMISSION,
);
});
it('is false for a user or an app that does not exist', async () => {
const user = await makeUser();
const appUid = await makeApp(user);
await grant(user, appUid, NOTIFICATIONS_PUSH_PERMISSION);
expect(await hasPushConsent(deps, user.id, `app-${uuidv4()}`)).toBe(
false,
);
expect(await hasPushConsent(deps, 2_000_000_000, appUid)).toBe(false);
});
it('is false when the check itself fails', async () => {
const user = await makeUser();
const appUid = await makeApp(user);
const failing = {
...deps,
services: {
permission: {
check: async () => {
throw new Error('unavailable');
},
},
},
} as PushConsentDeps;
expect(await hasPushConsent(failing, user.id, appUid)).toBe(false);
});
});
-63
View File
@@ -1,63 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { makeActor } from '../../core/actor.js';
import type { UserRow } from '../../stores/user/UserStore.js';
import type { PermissionService } from '../permission/PermissionService.js';
/** Per-app consent for an app's notifications to go out as pushes. */
export const NOTIFICATIONS_PUSH_PERMISSION = 'notifications:push';
export interface PushConsentDeps {
stores: {
user: { getById: (id: number) => Promise<UserRow | null | undefined> };
app: {
getByUid: (
uid: string,
) => Promise<
{ id?: number | null; uid?: string | null } | null | undefined
>;
};
};
services: { permission: Pick<PermissionService, 'check'> };
}
/** Read on every send for notifications an app itself sends, not Puter's. */
export const hasPushConsent = async (
deps: PushConsentDeps,
userId: number,
appUid: string,
): Promise<boolean> => {
try {
const [user, app] = await Promise.all([
deps.stores.user.getById(userId),
deps.stores.app.getByUid(appUid),
]);
if (!user?.id || !app?.id || !app.uid) return false;
// As the app for that user, keyed by the row's uid that revokes bump.
const actor = makeActor({ user, app: { uid: app.uid, id: app.id } });
return await deps.services.permission.check(
actor,
NOTIFICATIONS_PUSH_PERMISSION,
);
} catch (err) {
console.warn('[push] consent check failed', err);
return false;
}
};