diff --git a/packages/server/src/db/seed-removal.test.ts b/packages/server/src/db/seed-removal.test.ts new file mode 100644 index 00000000..9f6baecb --- /dev/null +++ b/packages/server/src/db/seed-removal.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import Database from 'better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ensureDefaults } from './migrate.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +describe('fresh instance has no seeded credentials', () => { + let sqlite: Database.Database; + + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + ensureDefaults(sqlite); + }); + + it('creates no admin user and no default space on fresh boot', () => { + const users = sqlite.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number }; + const spaces = sqlite.prepare('SELECT COUNT(*) AS n FROM spaces').get() as { n: number }; + expect(users.n).toBe(0); + expect(spaces.n).toBe(0); + }); + + it('has no user named "admin"', () => { + const admin = sqlite.prepare("SELECT id FROM users WHERE username = 'admin'").get(); + expect(admin).toBeUndefined(); + }); + + it('still creates the singleton instance_settings row', () => { + const row = sqlite.prepare('SELECT id, worker_id FROM instance_settings WHERE id = 1').get() as + { id: number; worker_id: number | null } | undefined; + expect(row?.id).toBe(1); + expect(row?.worker_id).not.toBeNull(); + }); +}); diff --git a/packages/server/src/db/seed.ts b/packages/server/src/db/seed.ts deleted file mode 100644 index 5b36882d..00000000 --- a/packages/server/src/db/seed.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { getDb, schema } from './index.js'; -import { generateSnowflake } from '../utils/snowflake.js'; -import { hashPassword } from '../utils/auth.js'; -import { eq } from 'drizzle-orm'; -import { DEFAULT_EVERYONE_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js'; - -export async function seedDatabase(): Promise { - const db = getDb(); - - const existingSpaces = db.select().from(schema.spaces).all(); - if (existingSpaces.length > 0) { - console.log('Database already has data, skipping seed'); - return; - } - - console.log('Seeding database with default data...'); - - const adminId = generateSnowflake(); - const adminPasswordHash = await hashPassword('admin123'); - - db.insert(schema.users).values({ - id: adminId, - username: 'admin', - displayName: 'Admin', - passwordHash: adminPasswordHash, - status: 'offline', - isAdmin: 1, - createdAt: Date.now(), - }).run(); - - const spaceId = generateSnowflake(); - db.insert(schema.spaces).values({ - id: spaceId, - name: 'Backspace', - ownerId: adminId, - inviteCode: 'backspace', - createdAt: Date.now(), - }).run(); - - db.insert(schema.spaceMembers).values({ - spaceId: spaceId, - userId: adminId, - joinedAt: Date.now(), - }).run(); - - const generalChannelId = generateSnowflake(); - db.insert(schema.channels).values({ - id: generalChannelId, - spaceId: spaceId, - name: 'general', - type: 'text', - topic: 'General discussion', - position: 0, - createdAt: Date.now(), - }).run(); - - const voiceChannelId = generateSnowflake(); - db.insert(schema.channels).values({ - id: voiceChannelId, - spaceId: spaceId, - name: 'General Voice', - type: 'voice', - position: 1, - createdAt: Date.now(), - }).run(); - - // Create @everyone role (id === spaceId convention) - db.insert(schema.roles).values({ - id: spaceId, - spaceId: spaceId, - name: '@everyone', - color: '#b9bbbe', - position: 0, - permissions: permissionsToString(DEFAULT_EVERYONE_PERMISSIONS), - createdAt: Date.now(), - }).run(); - - console.log('Database seeded successfully'); - console.log(` Default space: Backspace (invite code: backspace)`); - console.log(` Admin user: admin / admin123`); -} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index d2179129..b138694f 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -5,7 +5,6 @@ import websocket from '@fastify/websocket'; import fastifyStatic from '@fastify/static'; import { config } from './config.js'; import { getDb, getRawDb } from './db/index.js'; -import { seedDatabase } from './db/seed.js'; import { checkFfmpeg } from './utils/thumbnail.js'; import { authRoutes } from './routes/auth.js'; import { userRoutes } from './routes/users.js'; @@ -108,7 +107,6 @@ async function main(): Promise { // Initialize database getDb(); - await seedDatabase(); // Reset orphaned `users.status` rows for locally-homed users. The previous // process's in-memory disconnect timers are gone, so any non-offline row diff --git a/packages/server/src/routes/auth.test.ts b/packages/server/src/routes/auth.test.ts index c51f17a0..547dd2b7 100644 --- a/packages/server/src/routes/auth.test.ts +++ b/packages/server/src/routes/auth.test.ts @@ -488,3 +488,60 @@ describe('POST /api/auth/register — federation gate split', () => { expect(reds).toHaveLength(0); }); }); + +describe('POST /api/auth/register — first-user-admin promotion', () => { + beforeEach(() => { + // Start from a completely empty users table (no pre-seeded admin from outer beforeEach). + testDb.delete(schema.users).run(); + // Ensure instance_settings row so registration can proceed. + testDb.delete(schema.instanceSettings).run(); + testDb.insert(schema.instanceSettings).values({ + id: 1, + registrationOpen: 1, + federatedRegistrationOpen: 1, + updatedAt: Date.now(), + }).run(); + }); + + it('first locally-registered user receives isAdmin = 1', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'firstuser', password: 'password123' }, + }); + expect(res.statusCode).toBe(201); + + const user = testDb.select().from(schema.users).where(eq(schema.users.username, 'firstuser')).get(); + expect(user?.isAdmin).toBe(1); + }); + + it('second locally-registered user does NOT receive isAdmin', async () => { + await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'firstuser', password: 'password123' }, + }); + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'seconduser', password: 'password123' }, + }); + expect(res.statusCode).toBe(201); + + const second = testDb.select().from(schema.users).where(eq(schema.users.username, 'seconduser')).get(); + expect(second?.isAdmin).toBe(0); + }); + + it('federated user (homeInstance set) is never promoted to admin even if first in DB', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { username: 'feduser@remote.example', password: 'password123', homeInstance: 'remote.example' }, + }); + // Federated registration is open (federatedRegistrationOpen: 1) + expect(res.statusCode).toBe(201); + + const user = testDb.select().from(schema.users).where(eq(schema.users.username, 'feduser@remote.example')).get(); + expect(user?.isAdmin).toBe(0); + }); +});