diff --git a/src/backend/clients/database/SqliteDatabaseClient.test.ts b/src/backend/clients/database/SqliteDatabaseClient.test.ts index 931a2bdbe..724271576 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.test.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.test.ts @@ -18,7 +18,7 @@ */ import Database from 'better-sqlite3'; -import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js'; import { SqliteDatabaseClient } from './SqliteDatabaseClient.js'; /** Highest schema version the migration table can reach. */ -const CURRENT_SCHEMA_VERSION = 65; +const CURRENT_SCHEMA_VERSION = 66; /** * These suites migrate real files on disk. Idle they finish in well under a @@ -91,6 +91,105 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI expect(rows).toEqual([{ username: 'system' }]); }); + // 0070 sweeps three by-hand groups no code reads. Both tables it can reach + // cascade on delete, so the guard is the point: a group that still carries + // permissions or members has to survive, or the sweep silently revokes + // them. Re-running the statement is safe — it is a plain conditional DELETE. + describe('0070 drop-orphaned-default-groups', () => { + const MIGRATION = readFileSync( + new URL( + './migrations/sqlite/0070_drop-orphaned-default-groups.sql', + import.meta.url, + ), + 'utf8', + ); + + const seedGroup = async (name: string): Promise => { + const [system] = await client.read( + 'SELECT `id` FROM `user` WHERE `uuid` = ?', + [SYSTEM_USER_UUID], + ); + const uid = `grp-${name}-${Math.random().toString(36).slice(2, 10)}`; + await client.write( + 'INSERT INTO `group` (`uid`, `owner_user_id`, `extra`, `metadata`) ' + + 'VALUES (?, ?, ?, ?)', + [uid, system.id, JSON.stringify({ name }), '{}'], + ); + const [row] = await client.read( + 'SELECT `id` FROM `group` WHERE `uid` = ?', + [uid], + ); + return Number(row.id); + }; + + const stillThere = async (id: number): Promise => { + const rows = await client.read( + 'SELECT `id` FROM `group` WHERE `id` = ?', + [id], + ); + return rows.length > 0; + }; + + it('drops an orphaned freeai/experimental/dangerous group', async () => { + const ids = await Promise.all( + ['freeai', 'experimental', 'dangerous'].map(seedGroup), + ); + await client.write(MIGRATION); + for (const id of ids) expect(await stillThere(id)).toBe(false); + }); + + it('spares one that still carries a permission row', async () => { + const id = await seedGroup('freeai'); + const [system] = await client.read( + 'SELECT `id` FROM `user` WHERE `uuid` = ?', + [SYSTEM_USER_UUID], + ); + await client.write( + 'INSERT INTO `user_to_group_permissions` ' + + '(`user_id`, `group_id`, `permission`, `extra`) VALUES (?, ?, ?, ?)', + [system.id, id, 'service:some-paid-thing', '{}'], + ); + + await client.write(MIGRATION); + + expect(await stillThere(id)).toBe(true); + // And the grant it carries is still intact, not cascaded away. + const perms = await client.read( + 'SELECT `permission` FROM `user_to_group_permissions` WHERE `group_id` = ?', + [id], + ); + expect(perms).toEqual([{ permission: 'service:some-paid-thing' }]); + }); + + it('spares one that still has members', async () => { + const id = await seedGroup('experimental'); + const [system] = await client.read( + 'SELECT `id` FROM `user` WHERE `uuid` = ?', + [SYSTEM_USER_UUID], + ); + await client.write( + 'INSERT INTO `jct_user_group` (`user_id`, `group_id`) VALUES (?, ?)', + [system.id, id], + ); + + await client.write(MIGRATION); + + expect(await stillThere(id)).toBe(true); + }); + + it('leaves the groups the platform actually depends on alone', async () => { + // system, admin, user and temp are named by config or code. + await client.write(MIGRATION); + const rows = await client.read( + "SELECT json_extract(`extra`, '$.name') AS name FROM `group`", + ); + const names = rows.map((r) => String(r.name)); + for (const name of ['system', 'admin', 'user']) { + expect(names).toContain(name); + } + }); + }); + it('leaves an already-migrated database untouched on a second boot', async () => { const dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-')); const path = join(dir, 'nested', 'puter.sqlite'); diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts index 086534cda..cd29500e1 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.ts @@ -99,6 +99,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [ [62, ['0067_share_entries.sql']], [63, ['0068_referral-code-unique.sql']], [64, ['0069_user-block.sql']], + [65, ['0070_drop-orphaned-default-groups.sql']], ]; export class SqliteDatabaseClient extends AbstractDatabaseClient { diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_25.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_25.sql new file mode 100644 index 000000000..ca09a22f7 --- /dev/null +++ b/src/backend/clients/database/migrations/mysql/mysql_mig_25.sql @@ -0,0 +1,22 @@ +-- Drops three groups that no code has ever read: `freeai`, `experimental` and +-- `dangerous`. No migration creates them — they were added by hand back when +-- hardcoded permissions were keyed by group name. That map is now a flat +-- per-user floor (`default_user_permissions` in data/hardcoded-permissions.js), +-- so a group nothing looks up grants nothing, and the rows are dead weight. +-- +-- Guarded, and deliberately so: `user_to_group_permissions.group_id` and +-- `jct_user_group.group_id` both cascade on delete, so removing a group that +-- still carries permissions or members would silently revoke them from every +-- member. Only a group with neither is dropped. A group that survives this +-- migration has dependents and needs a deliberate decision, not a sweep -- +-- query `user_to_group_permissions` by `group_id` to see what it holds. +-- +-- The joins rather than `NOT IN` subqueries: MySQL will not read from the table +-- it is deleting from inside a subquery on the same statement. +DELETE g FROM `group` g +LEFT JOIN `user_to_group_permissions` p ON p.`group_id` = g.`id` +LEFT JOIN `jct_user_group` j ON j.`group_id` = g.`id` +WHERE JSON_UNQUOTE(JSON_EXTRACT(g.`extra`, '$.name')) + IN ('freeai', 'experimental', 'dangerous') + AND p.`group_id` IS NULL + AND j.`group_id` IS NULL; diff --git a/src/backend/clients/database/migrations/postgres/postgres_mig_13.sql b/src/backend/clients/database/migrations/postgres/postgres_mig_13.sql new file mode 100644 index 000000000..77f426b4e --- /dev/null +++ b/src/backend/clients/database/migrations/postgres/postgres_mig_13.sql @@ -0,0 +1,20 @@ +-- Drops three groups that no code has ever read: `freeai`, `experimental` and +-- `dangerous`. No migration creates them — they were added by hand back when +-- hardcoded permissions were keyed by group name. That map is now a flat +-- per-user floor (`default_user_permissions` in data/hardcoded-permissions.js), +-- so a group nothing looks up grants nothing, and the rows are dead weight. +-- +-- Guarded, and deliberately so: user_to_group_permissions.group_id and +-- jct_user_group.group_id both cascade on delete, so removing a group that +-- still carries permissions or members would silently revoke them from every +-- member. Only a group with neither is dropped. A group that survives this +-- migration has dependents and needs a deliberate decision, not a sweep -- +-- query user_to_group_permissions by group_id to see what it holds. +DELETE FROM "group" g +WHERE g.extra ->> 'name' IN ('freeai', 'experimental', 'dangerous') + AND NOT EXISTS ( + SELECT 1 FROM user_to_group_permissions p WHERE p.group_id = g.id + ) + AND NOT EXISTS ( + SELECT 1 FROM jct_user_group j WHERE j.group_id = g.id + ); diff --git a/src/backend/clients/database/migrations/sqlite/0070_drop-orphaned-default-groups.sql b/src/backend/clients/database/migrations/sqlite/0070_drop-orphaned-default-groups.sql new file mode 100644 index 000000000..a98c4531a --- /dev/null +++ b/src/backend/clients/database/migrations/sqlite/0070_drop-orphaned-default-groups.sql @@ -0,0 +1,33 @@ +-- 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 . + +-- Drops three groups that no code has ever read: `freeai`, `experimental` and +-- `dangerous`. No migration creates them — they were added by hand back when +-- hardcoded permissions were keyed by group name. That map is now a flat +-- per-user floor (`default_user_permissions` in data/hardcoded-permissions.js), +-- so a group nothing looks up grants nothing, and the rows are dead weight. +-- +-- Guarded, and deliberately so: `user_to_group_permissions.group_id` and +-- `jct_user_group.group_id` both cascade on delete, so removing a group that +-- still carries permissions or members would silently revoke them from every +-- member. Only a group with neither is dropped. A group that survives this +-- migration has dependents and needs a deliberate decision, not a sweep -- +-- query `user_to_group_permissions` by `group_id` to see what it holds. +DELETE FROM `group` +WHERE json_extract(`extra`, '$.name') IN ('freeai', 'experimental', 'dangerous') + AND `id` NOT IN (SELECT `group_id` FROM `user_to_group_permissions`) + AND `id` NOT IN (SELECT `group_id` FROM `jct_user_group`);