Skip to content

Commit 858cd76

Browse files
Yerazeclaude
andauthored
feat: detect channel moves on startup, migrate messages and permissions (#2433)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3de28d6 commit 858cd76

File tree

5 files changed

+572
-1
lines changed

5 files changed

+572
-1
lines changed
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/**
2+
* Auth Repository - Permission Migration Tests
3+
*
4+
* Tests migratePermissionsForChannelMoves() which handles:
5+
* - Simple permission moves (channel_A → channel_B)
6+
* - Permission swaps (channel_A ↔ channel_B)
7+
* - Multiple simultaneous moves
8+
*/
9+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
10+
import Database from 'better-sqlite3';
11+
import { drizzle, BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
12+
import { AuthRepository } from './auth.js';
13+
import * as schema from '../schema/index.js';
14+
15+
describe('AuthRepository.migratePermissionsForChannelMoves', () => {
16+
let db: Database.Database;
17+
let drizzleDb: BetterSQLite3Database<typeof schema>;
18+
let repo: AuthRepository;
19+
20+
beforeEach(() => {
21+
db = new Database(':memory:');
22+
23+
// Create users table
24+
db.exec(`
25+
CREATE TABLE users (
26+
id INTEGER PRIMARY KEY AUTOINCREMENT,
27+
username TEXT NOT NULL UNIQUE,
28+
password_hash TEXT NOT NULL,
29+
email TEXT,
30+
display_name TEXT,
31+
is_admin INTEGER NOT NULL DEFAULT 0,
32+
is_active INTEGER NOT NULL DEFAULT 1,
33+
auth_provider TEXT NOT NULL DEFAULT 'local',
34+
external_id TEXT,
35+
mfa_secret TEXT,
36+
mfa_enabled INTEGER NOT NULL DEFAULT 0,
37+
created_at INTEGER NOT NULL,
38+
updated_at INTEGER NOT NULL,
39+
last_login INTEGER
40+
)
41+
`);
42+
43+
// Create permissions table
44+
db.exec(`
45+
CREATE TABLE permissions (
46+
id INTEGER PRIMARY KEY AUTOINCREMENT,
47+
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
48+
resource TEXT NOT NULL,
49+
can_view_on_map INTEGER NOT NULL DEFAULT 0,
50+
can_read INTEGER NOT NULL DEFAULT 1,
51+
can_write INTEGER NOT NULL DEFAULT 0,
52+
granted_at INTEGER,
53+
granted_by INTEGER,
54+
UNIQUE(user_id, resource)
55+
)
56+
`);
57+
58+
// Create test users
59+
db.exec(`
60+
INSERT INTO users (id, username, password_hash, is_admin, auth_provider, created_at, updated_at)
61+
VALUES (1, 'user1', 'hash', 0, 'local', ${Date.now()}, ${Date.now()});
62+
INSERT INTO users (id, username, password_hash, is_admin, auth_provider, created_at, updated_at)
63+
VALUES (2, 'user2', 'hash', 0, 'local', ${Date.now()}, ${Date.now()});
64+
`);
65+
66+
drizzleDb = drizzle(db, { schema });
67+
repo = new AuthRepository(drizzleDb, 'sqlite');
68+
});
69+
70+
afterEach(() => {
71+
db.close();
72+
});
73+
74+
const addPermission = (userId: number, resource: string, canRead: boolean, canWrite: boolean) => {
75+
db.exec(`
76+
INSERT INTO permissions (user_id, resource, can_read, can_write, granted_at)
77+
VALUES (${userId}, '${resource}', ${canRead ? 1 : 0}, ${canWrite ? 1 : 0}, ${Date.now()})
78+
`);
79+
};
80+
81+
const getPermission = (userId: number, resource: string): { can_read: number; can_write: number } | undefined => {
82+
return db.prepare('SELECT can_read, can_write FROM permissions WHERE user_id = ? AND resource = ?')
83+
.get(userId, resource) as any;
84+
};
85+
86+
const countPermissions = (resource: string): number => {
87+
const row = db.prepare('SELECT COUNT(*) as count FROM permissions WHERE resource = ?').get(resource) as any;
88+
return row.count;
89+
};
90+
91+
it('should handle empty moves array', async () => {
92+
addPermission(1, 'channel_0', true, false);
93+
await repo.migratePermissionsForChannelMoves([]);
94+
expect(getPermission(1, 'channel_0')).toBeDefined();
95+
});
96+
97+
it('should move permissions from one channel to another', async () => {
98+
addPermission(1, 'channel_1', true, true);
99+
addPermission(2, 'channel_1', true, false);
100+
addPermission(1, 'channel_2', true, false); // Should not be affected
101+
102+
await repo.migratePermissionsForChannelMoves([{ from: 1, to: 3 }]);
103+
104+
expect(getPermission(1, 'channel_1')).toBeUndefined();
105+
expect(getPermission(2, 'channel_1')).toBeUndefined();
106+
expect(getPermission(1, 'channel_3')?.can_read).toBe(1);
107+
expect(getPermission(1, 'channel_3')?.can_write).toBe(1);
108+
expect(getPermission(2, 'channel_3')?.can_read).toBe(1);
109+
expect(getPermission(2, 'channel_3')?.can_write).toBe(0);
110+
expect(getPermission(1, 'channel_2')).toBeDefined(); // Unchanged
111+
});
112+
113+
it('should swap permissions between two channels', async () => {
114+
addPermission(1, 'channel_1', true, true); // user1 has read+write on ch1
115+
addPermission(1, 'channel_4', true, false); // user1 has read-only on ch4
116+
117+
await repo.migratePermissionsForChannelMoves([
118+
{ from: 1, to: 4 },
119+
{ from: 4, to: 1 },
120+
]);
121+
122+
// Permissions should be swapped
123+
expect(getPermission(1, 'channel_1')?.can_read).toBe(1);
124+
expect(getPermission(1, 'channel_1')?.can_write).toBe(0); // Was ch4's permission
125+
expect(getPermission(1, 'channel_4')?.can_read).toBe(1);
126+
expect(getPermission(1, 'channel_4')?.can_write).toBe(1); // Was ch1's permission
127+
});
128+
129+
it('should handle swap when one channel has no permissions', async () => {
130+
addPermission(1, 'channel_1', true, true);
131+
// No permissions on channel_4
132+
133+
await repo.migratePermissionsForChannelMoves([
134+
{ from: 1, to: 4 },
135+
{ from: 4, to: 1 },
136+
]);
137+
138+
expect(getPermission(1, 'channel_1')).toBeUndefined();
139+
expect(getPermission(1, 'channel_4')?.can_write).toBe(1);
140+
});
141+
142+
it('should not affect non-channel permissions', async () => {
143+
addPermission(1, 'channel_1', true, true);
144+
addPermission(1, 'messages', true, false);
145+
addPermission(1, 'dashboard', true, false);
146+
147+
await repo.migratePermissionsForChannelMoves([{ from: 1, to: 5 }]);
148+
149+
expect(getPermission(1, 'messages')).toBeDefined();
150+
expect(getPermission(1, 'dashboard')).toBeDefined();
151+
expect(getPermission(1, 'channel_5')).toBeDefined();
152+
});
153+
154+
it('should handle multiple users with swapped channels', async () => {
155+
addPermission(1, 'channel_0', true, true);
156+
addPermission(2, 'channel_0', true, false);
157+
addPermission(1, 'channel_2', false, false);
158+
addPermission(2, 'channel_2', true, true);
159+
160+
await repo.migratePermissionsForChannelMoves([
161+
{ from: 0, to: 2 },
162+
{ from: 2, to: 0 },
163+
]);
164+
165+
// User 1: ch0 had rw, ch2 had none → now ch0 has none, ch2 has rw
166+
expect(getPermission(1, 'channel_0')?.can_read).toBe(0); // Was ch2's
167+
expect(getPermission(1, 'channel_2')?.can_write).toBe(1); // Was ch0's
168+
// User 2: ch0 had r, ch2 had rw → now ch0 has rw, ch2 has r
169+
expect(getPermission(2, 'channel_0')?.can_write).toBe(1); // Was ch2's
170+
expect(getPermission(2, 'channel_2')?.can_write).toBe(0); // Was ch0's
171+
});
172+
173+
it('should handle mix of swaps and simple moves', async () => {
174+
addPermission(1, 'channel_0', true, true);
175+
addPermission(1, 'channel_1', true, false);
176+
addPermission(1, 'channel_3', true, true);
177+
178+
await repo.migratePermissionsForChannelMoves([
179+
{ from: 0, to: 1 }, // Swap 0 ↔ 1
180+
{ from: 1, to: 0 },
181+
{ from: 3, to: 5 }, // Simple move 3 → 5
182+
]);
183+
184+
expect(getPermission(1, 'channel_0')?.can_write).toBe(0); // Was ch1's (read-only)
185+
expect(getPermission(1, 'channel_1')?.can_write).toBe(1); // Was ch0's (read+write)
186+
expect(getPermission(1, 'channel_3')).toBeUndefined(); // Moved away
187+
expect(getPermission(1, 'channel_5')?.can_write).toBe(1); // Was ch3's
188+
});
189+
});

src/db/repositories/auth.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* Includes: users, permissions, sessions, audit_log, api_tokens
66
* Supports SQLite, PostgreSQL, and MySQL through Drizzle ORM.
77
*/
8-
import { eq, lt, desc, and } from 'drizzle-orm';
8+
import { eq, lt, desc, and, sql } from 'drizzle-orm';
99
import bcrypt from 'bcrypt';
1010
import crypto from 'crypto';
1111
import {
@@ -711,4 +711,45 @@ export class AuthRepository extends BaseRepository {
711711
}
712712
return toDelete.length;
713713
}
714+
715+
/**
716+
* Migrate channel permissions when channels are moved between slots.
717+
* Uses the same swap/move pattern as message migration.
718+
*/
719+
async migratePermissionsForChannelMoves(moves: { from: number; to: number }[]): Promise<void> {
720+
if (moves.length === 0) return;
721+
722+
const TEMP_RESOURCE = 'channel_temp_swap';
723+
724+
// Detect swap pairs
725+
const swapPairs = new Set<string>();
726+
for (const move of moves) {
727+
const reverse = moves.find(m => m.from === move.to && m.to === move.from);
728+
if (reverse) {
729+
swapPairs.add([Math.min(move.from, move.to), Math.max(move.from, move.to)].join(','));
730+
}
731+
}
732+
733+
// Process swaps first (need temp value)
734+
const processedSwaps = new Set<string>();
735+
for (const move of moves) {
736+
const key = [Math.min(move.from, move.to), Math.max(move.from, move.to)].join(',');
737+
if (swapPairs.has(key) && !processedSwaps.has(key)) {
738+
processedSwaps.add(key);
739+
const a = Math.min(move.from, move.to);
740+
const b = Math.max(move.from, move.to);
741+
await this.executeRun(sql`UPDATE permissions SET resource = ${TEMP_RESOURCE} WHERE resource = ${'channel_' + a}`);
742+
await this.executeRun(sql`UPDATE permissions SET resource = ${'channel_' + a} WHERE resource = ${'channel_' + b}`);
743+
await this.executeRun(sql`UPDATE permissions SET resource = ${'channel_' + b} WHERE resource = ${TEMP_RESOURCE}`);
744+
}
745+
}
746+
747+
// Process simple moves
748+
for (const move of moves) {
749+
const key = [Math.min(move.from, move.to), Math.max(move.from, move.to)].join(',');
750+
if (!swapPairs.has(key)) {
751+
await this.executeRun(sql`UPDATE permissions SET resource = ${'channel_' + move.to} WHERE resource = ${'channel_' + move.from}`);
752+
}
753+
}
754+
}
714755
}

0 commit comments

Comments
 (0)