Files
puter/src/backend/core/actor.test.ts
T
Daniel Salazar 589a98b12c feat(kv): share KV prefixes with other users at watch/read/write, and bound what apps can be granted (#4331)
* feat(kv): share key-value prefixes with other users, and bound what apps can be granted

- KV share handles move out of Events into KvShareService, served at /kv/shares
  (the /events/kv-handles routes stay as an alias).
- Shares have watch, read and write levels, encoded in the permission root
  (kv-share, kv-share-read, kv-share-write) with write implying read and watch.
- Every puter.kv method takes optConfig.handle; keys are relative to the shared
  prefix, cursors are sealed, and private entries stay hidden.
- New puter.kv.share, unshare, listShared and listSharedByMe; optional label;
  kv.share.received notice in the app's mailbox only; recipient blocks apply.
- puter.kv.set gains onlyIfAbsent.
- fs and kv events gain viaWorker, and kv events report self from the writer.
- /auth/grant-user-app only accepts canonical permissions from a fixed set of
  families; the consent dialog, puter.perms and docs follow.
- Migrations: kv_share_handles gains revoked_by, label, and grantee and app
  indexes (mysql_mig_49, postgres_mig_38, sqlite 0095).

* fix(kv): never name a private key in a cross-app or handle cursor, and bound flat-permission scans

- Listings of another namespace (cross-app or through a handle) resume after
  the last entry they returned, and their cursors are sealed, bound to the
  namespace or handle, and padded to one length. Old plain cross-app cursors
  are still read when they name the target namespace; other namespaces get 400.
- PermissionStore scans flat permissions a few roots at a time and skips
  roots nested under another root in the same call.
2026-10-25 08:16:19 -07:00

140 lines
4.9 KiB
TypeScript

/*
* 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 { describe, expect, it } from 'vitest';
import {
assertResolvedActor,
makeActor,
SYSTEM_ACTOR,
userRelatedActor,
viaWorkerOf,
type Actor,
} from './actor';
describe('makeActor / effectiveApp', () => {
const user = { uuid: 'u-1', id: 1, username: 'u' };
it('resolves a plain user actor to no app', () => {
expect(makeActor({ user }).effectiveApp).toBeNull();
});
it("resolves an app-under-user actor to its own app", () => {
expect(makeActor({ user, app: { uid: 'app-1' } }).effectiveApp).toEqual({
uid: 'app-1',
});
});
it("resolves a token to the app that issued it", () => {
// The whole point: a token actor carries no `app` of its own, so
// anything reading `app` sees a bare user token and skips app gating.
const issuer = makeActor({ user, app: { uid: 'app-1' } });
const token = makeActor({
user,
accessToken: { uid: 'tok-1', issuer },
});
expect(token.app).toBeUndefined();
expect(token.effectiveApp).toEqual({ uid: 'app-1' });
});
it('collapses a chain of tokens in one hop', () => {
const issuer = makeActor({ user, app: { uid: 'app-1' } });
const inner = makeActor({ user, accessToken: { uid: 't1', issuer } });
const outer = makeActor({
user,
accessToken: { uid: 't2', issuer: inner },
});
expect(outer.effectiveApp).toEqual({ uid: 'app-1' });
});
it('resolves a user-issued token to no app', () => {
const issuer = makeActor({ user });
const token = makeActor({
user,
accessToken: { uid: 'tok-1', issuer, fullAccess: true },
});
expect(token.effectiveApp).toBeNull();
});
it('drops the app when narrowing to the underlying user', () => {
const app = makeActor({ user, app: { uid: 'app-1' } });
expect(userRelatedActor(app).effectiveApp).toBeNull();
});
it('keeps the handler depth when narrowing to the underlying user', () => {
const app = makeActor({ user, app: { uid: 'app-1' } });
app.handlerDepth = 3;
expect(userRelatedActor(app).handlerDepth).toBe(3);
expect(
userRelatedActor(makeActor({ user, app: { uid: 'app-1' } })),
).not.toHaveProperty('handlerDepth');
});
it('resolves the system actor', () => {
expect(SYSTEM_ACTOR.effectiveApp).toBeNull();
});
});
describe('assertResolvedActor', () => {
it('passes a resolved actor through unchanged', () => {
const actor = makeActor({ user: { uuid: 'u-1' } });
expect(assertResolvedActor(actor)).toBe(actor);
// `null` is a resolved answer, not a missing one.
expect(assertResolvedActor({ user: {}, effectiveApp: null })).toEqual({
user: {},
effectiveApp: null,
});
});
it('throws on an actor that skipped makeActor', () => {
// The field is optional so pre-existing literals still compile, which
// means an unresolved one can reach a gate. Fail loudly at the edge
// rather than let a gate read `undefined` as "no app" and wave it
// through — an app-under-user actor is the dangerous case.
const unresolved = { user: { uuid: 'u-1' }, app: { uid: 'app-1' } };
expect(() => assertResolvedActor(unresolved as Actor)).toThrow(
/effectiveApp/,
);
});
});
describe('viaWorkerOf', () => {
const user = { uuid: 'u-1', id: 1 };
it('reads a worker session or an events handler as server-side', () => {
expect(
viaWorkerOf(
makeActor({ user, session: { uid: 's-1', kind: 'worker' } }),
),
).toBe(true);
expect(viaWorkerOf(makeActor({ user, handlerDepth: 1 }))).toBe(true);
});
it('reads a browser session, an app or no actor at all as not', () => {
expect(
viaWorkerOf(
makeActor({ user, session: { uid: 's-1', kind: 'web' } }),
),
).toBe(false);
expect(viaWorkerOf(makeActor({ user, app: { uid: 'app-1' } }))).toBe(
false,
);
expect(viaWorkerOf(undefined)).toBe(false);
});
});