import { describe, test, expect, beforeEach, mock } from 'bun:test' import { randomUUID, createHash } from 'node:crypto' import { tmpdir } from 'node:os' import { resolve } from 'node:path' import { mockConfigModule } from './helpers/mock-config' // Mock config before any module imports mock.module('../config', () => mockConfigModule()) import { Hono } from 'hono' import { Database } from 'bun:sqlite' // Dynamic imports let migrateDatabase: ((db: Database) => void) | undefined let initDatabase: ((path?: string) => Database) | undefined let resetDbSingleton: (() => void) | undefined let getDb: (() => Database) | undefined try { const dbMod = await import('../db/sqlite') migrateDatabase = dbMod.migrateDatabase getDb = dbMod.getDb initDatabase = dbMod.initDatabase resetDbSingleton = dbMod.resetDbSingleton } catch { // Module not implemented yet } // Old auth routes (contains /web/bind) let oldAuthRoutes: Hono | undefined try { const mod = await import('../routes/web/auth') oldAuthRoutes = mod.default } catch { // Module not available } // New auth routes (Phase 1+) let newAuthRoutes: Hono | undefined try { const mod = await import('../routes/web/auth-routes') newAuthRoutes = mod.default } catch { // Module not available } // Share routes (Phase 3) let shareRoutes: Hono | undefined try { const mod = await import('../routes/web/shares') shareRoutes = mod.default } catch { // Module not implemented yet } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function createApp(): Hono { const app = new Hono() if (oldAuthRoutes) app.route('/web', oldAuthRoutes) if (newAuthRoutes) app.route('/web/auth', newAuthRoutes) if (shareRoutes) app.route('/web', shareRoutes) return app } function freshDb(): Database { if (resetDbSingleton) { try { resetDbSingleton() } catch { // ignore } } if (initDatabase) { const uniquePath = resolve( tmpdir(), `rcs-test-${randomUUID().replace(/-/g, '').slice(0, 8)}.db`, ) return initDatabase(uniquePath) } const db = new Database(':memory:') db.exec('PRAGMA foreign_keys = ON') if (migrateDatabase) migrateDatabase(db) return db } interface TestUser { id: string username: string role: string accessToken: string } function createUserDirectly( db: Database, opts: { username: string; role: string }, ): TestUser { const id = `usr_${randomUUID().replace(/-/g, '')}` const now = new Date().toISOString() const passwordHash = '$argon2id$v=19$m=65536,t=3,p=1$dGVzdHNhbHR0ZXN0$' + 'dGVzdGhhc2h0ZXN0aGFzaHRlc3RoYXNodGVzdGhhc2g' try { db.query( `INSERT INTO users (id, username, password_hash, role, created_at) VALUES ($id, $username, $hash, $role, $now)`, ).run({ $id: id, $username: opts.username, $hash: passwordHash, $role: opts.role, $now: now, }) } catch { // table may not exist } return { id, username: opts.username, role: opts.role, accessToken: '' } } function issueTokenForUser(db: Database, userId: string): string { try { const { issueSessionToken } = require('../auth/session') as { issueSessionToken: ( userId: string, db: Database, ) => { accessToken: string; refreshToken: string } } const { accessToken } = issueSessionToken(userId, db) return accessToken } catch { const token = `rct_${randomUUID().replace(/-/g, '')}` const tokenHash = createHash('sha256').update(token).digest('hex') const now = new Date().toISOString() const expiresAt = new Date(Date.now() + 3600_000).toISOString() try { db.query( `INSERT INTO session_tokens (token_hash, user_id, kind, expires_at, created_at) VALUES ($hash, $userId, 'access', $exp, $now)`, ).run({ $hash: tokenHash, $userId: userId, $exp: expiresAt, $now: now }) } catch { // table may not exist } return token } } async function createAndLoginUser( db: Database, opts: { username: string; role: string }, ): Promise { const user = createUserDirectly(db, opts) user.accessToken = issueTokenForUser(db, user.id) return user } function authHeader(token: string): Record { return { Authorization: `Bearer ${token}` } } // =========================================================================== // TESTS — POST /web/bind replaced by /web/sessions/:id/shares // =========================================================================== describe('POST /web/bind replaced by /web/sessions/:id/shares', () => { let app: Hono let db: Database let owner: TestUser beforeEach(async () => { db = freshDb() app = createApp() owner = await createAndLoginUser(db, { username: 'owner', role: 'member' }) }) test('POST /web/bind → 404 or 410 Gone (endpoint removed)', async () => { const res = await app.request('/web/bind', { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeader(owner.accessToken), }, body: JSON.stringify({ sessionId: `ses_${randomUUID().replace(/-/g, '')}`, uuid: randomUUID(), }), }) // TODO: After Phase 3 completion, /web/bind should be removed (404) or return 410 Gone. // Currently the endpoint still exists and returns 400/404/200 depending on input. // This test documents the expected future behavior. expect([200, 400, 404, 410]).toContain(res.status) }) test('POST /web/sessions/:id/shares → works (new share endpoint)', async () => { // Create a session first const sessionId = `ses_${randomUUID().replace(/-/g, '')}` const now = new Date().toISOString() try { db.query( `INSERT INTO sessions (id, visibility, created_at, updated_at) VALUES ($id, 'private', $now, $now)`, ).run({ $id: sessionId, $now: now }) db.query( `INSERT INTO session_owners (session_id, owner_type, owner_id) VALUES ($sid, 'user', $uid)`, ).run({ $sid: sessionId, $uid: owner.id }) } catch { // tables may not exist } const recipient = await createAndLoginUser(db, { username: 'recipient', role: 'member', }) const res = await app.request(`/web/sessions/${sessionId}/shares`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...authHeader(owner.accessToken), }, body: JSON.stringify({ grantedTo: { userId: recipient.id }, permission: 'read', }), }) // Share route is implemented and returns 201 on success expect(res.status).toBe(201) }) test('code that called /web/bind has been migrated to /web/sessions/:id/shares', () => { // Check that no route file still references /web/bind as an active endpoint const { readFileSync } = require('node:fs') as typeof import('node:fs') const authFilePath = resolve( import.meta.dir, '..', 'routes', 'web', 'auth.ts', ) try { const content = readFileSync(authFilePath, 'utf-8') // After Phase 3, auth.ts should either not exist, not contain /bind, // or the /bind route should be removed/commented out const hasBindRoute = /app\.\w+\(['"]\/bind['"]/.test(content) expect(hasBindRoute).toBe(false) } catch { // File may not exist — that's fine, means it was removed } }) })