/
githubmirror
/
tldraw
Обзор
Документация
Войти
/
githubmirror
/
tldraw
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
packages/dotcom-shared/src/mutators.test.ts
1 900 строк
60 KB
Mime Čuvalo
feat: commenting followups (#9782)
03 авг 2026, 18:39
Не верифицирован
03 авг 2026, 18:39
0527a7d
Код
Авторство
О чём код?
import { IndexKey, uniqueId } from '@tldraw/utils' import { describe, expect, it } from 'vitest' import { createMutators, parseFlags, userHasFlag } from './mutators' import { FILE_PREFIX, PUBLISH_PREFIX } from './routes' import { TlaComment, TlaCommentRead, TlaFile, TlaFileState, TlaGroup, TlaGroupFile, TlaGroupUser, TlaSchema, TlaUser, } from './tlaSchema' import { ZErrorCode } from './types' // ---- helpers ---- function makeUser(overrides: Partial<TlaUser> & { id: string }): TlaUser { return { name: 'Test', email: 'test@test.com', avatar: '', color: '#000', exportFormat: 'png', exportTheme: 'auto', exportBackground: true, exportPadding: true, createdAt: 1, updatedAt: 1, flags: '', locale: null, animationSpeed: null, areKeyboardShortcutsEnabled: null, edgeScrollSpeed: null, colorScheme: null, isSnapMode: null, isWrapMode: null, isDynamicSizeMode: null, isPasteAtCursorMode: null, inputMode: null, enhancedA11yMode: null, isZoomDirectionInverted: null, allowAnalyticsCookie: null, ...overrides, } } function makeComment(overrides: Partial<TlaComment> & { id: string; fileId: string }): TlaComment { return { threadId: 'thread-1', pageId: 'page:page', authorId: 'author-1', authorName: 'Author One', authorColor: '#EC5E41', authorAvatar: '', body: {}, createdAt: 1, isDeleted: false, updatedAt: 1, ...overrides, } } function makeFile(overrides: Partial<TlaFile> & { id: string }): TlaFile { return { name: 'Untitled', ownerId: null, owningGroupId: null, ownerName: '', ownerAvatar: '', thumbnail: '', shared: false, sharedLinkType: 'edit', published: false, lastPublished: 0, publishedSlug: uniqueId(), createdAt: 1, updatedAt: 1, isEmpty: true, isDeleted: false, createSource: null, ...overrides, } } function makeGroupUser( overrides: Partial<TlaGroupUser> & { userId: string; groupId: string } ): TlaGroupUser { return { createdAt: 1, updatedAt: 1, role: 'member', userName: 'Test', userColor: '#000', index: 'a1' as IndexKey, ...overrides, } } function makeGroupFile( overrides: Partial<TlaGroupFile> & { fileId: string; groupId: string } ): TlaGroupFile { return { createdAt: 1, updatedAt: 1, index: null, ...overrides, } } function makeGroup(overrides: Partial<TlaGroup> & { id: string }): TlaGroup { return { name: 'Test Group', inviteSecret: null, inviteLinkEnabled: true, isDeleted: false, createdAt: 1, updatedAt: 1, ...overrides, } } function makeFileState( overrides: Partial<TlaFileState> & { userId: string; fileId: string } ): TlaFileState { return { firstVisitAt: null, lastEditAt: null, lastSessionState: null, lastVisitAt: null, isFileOwner: false, isPinned: false, ...overrides, } } // Table storage keyed by table name interface TableStore { user: TlaUser[] file: TlaFile[] file_state: TlaFileState[] group: TlaGroup[] group_user: TlaGroupUser[] group_file: TlaGroupFile[] comment: TlaComment[] comment_read: TlaCommentRead[] } const TABLE_PKS: Record<keyof TableStore, string[]> = { user: ['id'], file: ['id'], file_state: ['userId', 'fileId'], group: ['id'], group_user: ['userId', 'groupId'], group_file: ['fileId', 'groupId'], comment: ['id'], comment_read: ['userId', 'commentId'], } /** * Build a mock Transaction that resolves zql builder queries against in-memory data. * * The `tx.run(query)` API passes an AST, but the builder stores filter info. * We intercept via Proxy so `.where()` chains build up predicates, and * `.one()` / the final run resolves them. * * This is a simplified mock — it handles the subset of queries used by mutators: * zql.<table>.where(col, '=', val)...one() * zql.<table>.where(col, '=', val) */ function createMockTx( store: TableStore, opts: { location: 'server' | 'client' } = { location: 'server' } ) { // Track mutations for assertions const mutations: Array<{ op: string; table: string; data: any }> = [] function getRows(table: keyof TableStore) { return store[table] ?? [] } function matchPk(table: keyof TableStore, row: any, data: any) { return TABLE_PKS[table].every((pk) => row[pk] === data[pk]) } // The mutate object provides insert/update/upsert/delete for each table function makeTableMutator(tableName: keyof TableStore) { return { insert: async (data: any) => { mutations.push({ op: 'insert', table: tableName, data }) ;(store[tableName] as any[]).push({ ...data }) }, update: async (data: any) => { mutations.push({ op: 'update', table: tableName, data }) const rows = store[tableName] as any[] const idx = rows.findIndex((r) => matchPk(tableName, r, data)) if (idx >= 0) Object.assign(rows[idx], data) }, upsert: async (data: any) => { mutations.push({ op: 'upsert', table: tableName, data }) const rows = store[tableName] as any[] const idx = rows.findIndex((r) => matchPk(tableName, r, data)) if (idx >= 0) { Object.assign(rows[idx], data) } else { rows.push({ ...data }) } }, delete: async (data: any) => { mutations.push({ op: 'delete', table: tableName, data }) const rows = store[tableName] as any[] const idx = rows.findIndex((r) => matchPk(tableName, r, data)) if (idx >= 0) rows.splice(idx, 1) }, } } const mutate: any = {} for (const t of Object.keys(store) as (keyof TableStore)[]) { mutate[t] = makeTableMutator(t) } // tx.run(query) — the query is a builder that carries an AST. // We need to resolve it against our store. // The builder from createBuilder(schema) returns objects with .ast property. // We parse the AST to figure out which table + where clauses + one(). function resolveAst(ast: any): any[] { const table = ast.table as keyof TableStore let rows = [...getRows(table)] // Apply where conditions if (ast.where) { rows = applyCondition(rows, ast.where) } return rows } function applyCondition(rows: any[], cond: any): any[] { if (!cond) return rows if (cond.type === 'simple') { const field = cond.left?.name const val = cond.right?.value const op = cond.op return rows.filter((r) => { if (op === '=') return r[field] === val if (op === '!=') return r[field] !== val if (op === 'IN') return Array.isArray(val) && val.includes(r[field]) return true }) } if (cond.type === 'and') { let result = rows for (const sub of cond.conditions) { result = applyCondition(result, sub) } return result } if (cond.type === 'or') { const sets = cond.conditions.map((c: any) => applyCondition(rows, c)) const merged = new Set<any>() for (const s of sets) for (const r of s) merged.add(r) return [...merged] } return rows } const tx = { location: opts.location, clientID: '', mutationID: 0, reason: opts.location === 'server' ? 'authoritative' : 'optimistic', mutate, query: undefined as any, run: async (query: any) => { // query is a Query object from createBuilder // It has a `.ast` property const ast = query.ast ?? query const rows = resolveAst(ast) // If the query was `.one()`, return first or null if (ast.limit === 1) { return rows[0] ?? null } return rows }, dbTransaction: { query: async (sql: string, params: unknown[]) => { // Handle the specific SQL for assertNotMaxFiles if (sql.includes('count(*)') && sql.includes('"file"')) { const userId = params[0] const count = store.file.filter( (f) => !f.isDeleted && (f.ownerId === userId || f.owningGroupId === userId) ).length return [{ count: String(count) }] } return [] }, }, } as unknown as import('@rocicorp/zero').Transaction<TlaSchema> return { tx, mutations, store } } async function expectValid(fn: () => Promise<any>) { await expect(fn()).resolves.not.toThrow() } function expectForbidden(fn: () => Promise<any>) { return expect(fn()).rejects.toThrow(ZErrorCode.forbidden) } function expectBadRequest(fn: () => Promise<any>) { return expect(fn()).rejects.toThrow(ZErrorCode.bad_request) } // ---- tests ---- describe('parseFlags / userHasFlag', () => { it('parses comma-separated', () => { expect(parseFlags('flag_a,flag_b')).toEqual(['flag_a', 'flag_b']) }) it('parses space-separated', () => { expect(parseFlags('flag_a flag_b')).toEqual(['flag_a', 'flag_b']) }) it('handles null/undefined', () => { expect(parseFlags(null)).toEqual([]) expect(parseFlags(undefined)).toEqual([]) }) it('userHasFlag checks presence', () => { expect(userHasFlag('flag_a', 'flag_a')).toBe(true) expect(userHasFlag('flag_b', 'flag_a')).toBe(false) }) }) describe('user mutations', () => { const userId = 'user_aaaa11112222bbbb' it('user can update own profile', async () => { const { tx } = createMockTx({ user: [makeUser({ id: userId })], file: [], file_state: [], group: [], group_user: [], group_file: [], comment: [], comment_read: [], }) const m = createMutators(userId) await expectValid(() => m.user.update(tx, { id: userId, name: 'New Name' })) }) it('user cannot update another user', async () => { const otherId = 'user_other1234567890' const { tx } = createMockTx({ user: [makeUser({ id: userId }), makeUser({ id: otherId })], file: [], file_state: [], group: [], group_user: [], group_file: [], comment: [], comment_read: [], }) const m = createMutators(userId) await expectForbidden(() => m.user.update(tx, { id: otherId, name: 'Hacked' })) }) it('cannot change immutable field (email)', async () => { const { tx } = createMockTx({ user: [makeUser({ id: userId })], file: [], file_state: [], group: [], group_user: [], group_file: [], comment: [], comment_read: [], }) const m = createMutators(userId) await expectForbidden(() => m.user.update(tx, { id: userId, email: 'evil@evil.com' })) }) it('user can change own flags field', async () => { const { tx } = createMockTx({ user: [makeUser({ id: userId })], file: [], file_state: [], group: [], group_user: [], group_file: [], comment: [], comment_read: [], }) const m = createMutators(userId) // flags is NOT in immutableColumns.user, so this should succeed await expectValid(() => m.user.update(tx, { id: userId, flags: 'example_flag' })) }) }) describe('file mutations', () => { const userId = 'user_aaaa11112222bbbb' const groupId = 'group_aaa11112222bbb' function baseStore() { return { user: [makeUser({ id: userId })], file: [] as TlaFile[], file_state: [] as TlaFileState[], group: [makeGroup({ id: groupId })], group_user: [makeGroupUser({ userId, groupId, role: 'member' })], group_file: [] as TlaGroupFile[], comment: [], comment_read: [], } } it('workspace member can update file', async () => { const s = baseStore() const f = makeFile({ id: 'file_aaaa11112222bbbb', owningGroupId: groupId }) s.file.push(f) const { tx } = createMockTx(s) const m = createMutators(userId) await expectValid(() => m.file.update(tx, { id: f.id, name: 'Renamed' })) }) it('shared user without workspace membership cannot update file', async () => { const otherId = 'user_other1234567890' const s = baseStore() const f = makeFile({ id: 'file_aaaa11112222bbbb', owningGroupId: groupId, shared: true, }) s.file.push(f) // otherId is NOT in the group const { tx } = createMockTx(s) const m = createMutators(otherId) await expectForbidden(() => m.file.update(tx, { id: f.id, name: 'Hacked' })) }) it('cannot change immutable field (ownerId)', async () => { const s = baseStore() const f = makeFile({ id: 'file_aaaa11112222bbbb', owningGroupId: groupId }) s.file.push(f) const { tx } = createMockTx(s) const m = createMutators(userId) await expectForbidden(() => m.file.update(tx, { id: f.id, ownerId: 'evil' })) }) it('cannot change immutable field (owningGroupId)', async () => { const s = baseStore() const f = makeFile({ id: 'file_aaaa11112222bbbb', owningGroupId: groupId }) s.file.push(f) const { tx } = createMockTx(s) const m = createMutators(userId) await expectForbidden(() => m.file.update(tx, { id: f.id, owningGroupId: 'evil_group_1234567' }) ) }) it('cannot change immutable field (isDeleted)', async () => { const s = baseStore() const f = makeFile({ id: 'file_aaaa11112222bbbb', owningGroupId: groupId }) s.file.push(f) const { tx } = createMockTx(s) const m = createMutators(userId) await expectForbidden(() => m.file.update(tx, { id: f.id, isDeleted: true })) }) it('unrelated user cannot update file', async () => { const strangerId = 'user_stranger12345678' const s = baseStore() const f = makeFile({ id: 'file_aaaa11112222bbbb', owningGroupId: groupId, shared: false }) s.file.push(f) const { tx } = createMockTx(s) const m = createMutators(strangerId) await expectForbidden(() => m.file.update(tx, { id: f.id, name: 'Nope' })) }) }) describe('file creation', () => { const userId = 'user_aaaa11112222bbbb' const groupId = 'group_aaa11112222bbb' it('migrated user can create file in own workspace', async () => { const s = { user: [makeUser({ id: userId })], file: [], file_state: [], group: [makeGroup({ id: groupId })], group_user: [makeGroupUser({ userId, groupId })], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await expectValid(() => m.createFile(tx, { fileId: 'file_aaaa11112222bbbb', workspaceId: groupId, name: 'New File', time: Date.now(), createSource: null, }) ) }) it('migrated user cannot create file in another workspace', async () => { const otherGroup = 'group_other123456789' const s = { user: [makeUser({ id: userId })], file: [], file_state: [], group: [makeGroup({ id: groupId }), makeGroup({ id: otherGroup })], group_user: [makeGroupUser({ userId, groupId })], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await expectForbidden(() => m.createFile(tx, { fileId: 'file_aaaa11112222bbbb', workspaceId: otherGroup, name: 'Nope', time: Date.now(), createSource: null, }) ) }) it('migrated user can create file in their home workspace without a group_user row', async () => { // The home workspace (id === userId) is implicitly owned, so it may have no // group_user row. createFile must still allow it — otherwise an empty home is // un-switchable, since selecting it creates-then-opens a file. Regression test: // authorize via getRole (home => owner), not a raw group_user lookup. const s = { user: [makeUser({ id: userId })], file: [], file_state: [], group: [makeGroup({ id: userId })], group_user: [], // no explicit home membership row group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await expectValid(() => m.createFile(tx, { fileId: 'file_aaaa11112222bbbb', workspaceId: userId, // home workspace name: 'New File', time: Date.now(), createSource: null, }) ) }) }) describe('file_state mutations', () => { const userId = 'user_aaaa11112222bbbb' const groupId = 'group_aaa11112222bbb' const fileId = 'file_aaaa11112222bbbb' it('user can update own file_state', async () => { const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: fileId, owningGroupId: groupId, shared: true })], file_state: [makeFileState({ userId, fileId })], group: [makeGroup({ id: groupId })], group_user: [makeGroupUser({ userId, groupId })], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await expectValid(() => m.file_state.update(tx, { userId, fileId, lastVisitAt: Date.now() })) }) it("user cannot update another user's file_state", async () => { const otherId = 'user_other1234567890' const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: fileId, owningGroupId: groupId })], file_state: [makeFileState({ userId: otherId, fileId })], group: [], group_user: [], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await expectForbidden(() => m.file_state.update(tx, { userId: otherId, fileId, lastVisitAt: Date.now() }) ) }) it('server cannot update file_state for inaccessible file', async () => { const inaccessibleFile = makeFile({ id: 'file_inaccessible12345', owningGroupId: groupId, shared: false, }) const s = { user: [makeUser({ id: userId })], file: [inaccessibleFile], file_state: [makeFileState({ userId, fileId: inaccessibleFile.id })], group: [makeGroup({ id: groupId })], group_user: [], // userId NOT a member group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s, { location: 'server' }) const m = createMutators(userId) await expectForbidden(() => m.file_state.update(tx, { userId, fileId: inaccessibleFile.id, lastVisitAt: Date.now() }) ) }) }) describe('onEnterFile', () => { const userId = 'user_aaaa11112222bbbb' const groupId = 'group_aaa11112222bbb' const fileId = 'file_aaaa11112222bbbb' it('user with access can enter file', async () => { const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: fileId, owningGroupId: groupId })], file_state: [], group: [makeGroup({ id: groupId })], group_user: [makeGroupUser({ userId, groupId })], group_file: [makeGroupFile({ fileId, groupId })], comment: [], comment_read: [], } const { tx } = createMockTx(s, { location: 'server' }) const m = createMutators(userId) await m.onEnterFile(tx, { fileId, time: Date.now() }) // file_state should be upserted expect(s.file_state.length).toBe(1) }) it('user without access cannot enter file', async () => { const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: fileId, owningGroupId: groupId, shared: false })], file_state: [], group: [makeGroup({ id: groupId })], group_user: [], // not a member group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s, { location: 'server' }) const m = createMutators(userId) await expectForbidden(() => m.onEnterFile(tx, { fileId, time: Date.now() })) }) it('entering file already in workspace does not create duplicate group_file', async () => { const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: fileId, owningGroupId: groupId, shared: true })], file_state: [], group: [makeGroup({ id: groupId })], group_user: [makeGroupUser({ userId, groupId })], group_file: [makeGroupFile({ fileId, groupId })], comment: [], comment_read: [], } const { tx } = createMockTx(s, { location: 'server' }) const m = createMutators(userId) await m.onEnterFile(tx, { fileId, time: Date.now() }) // Should NOT create a new group_file since it's already in user's group expect(s.group_file.length).toBe(1) }) it("mirrors another group's shared file into home as a guest file", async () => { // Opening a shared file owned by a group the user is NOT a member of links it into // their home group, which is what surfaces it as a "guest file" in the sidebar. const otherGroup = 'group_other123456789' const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: fileId, owningGroupId: otherGroup, shared: true })], file_state: [], group: [makeGroup({ id: userId }), makeGroup({ id: otherGroup })], group_user: [makeGroupUser({ userId, groupId: userId, role: 'owner' })], // home only group_file: [makeGroupFile({ fileId, groupId: otherGroup })], comment: [], comment_read: [], } const { tx } = createMockTx(s, { location: 'server' }) const m = createMutators(userId) await m.onEnterFile(tx, { fileId, time: Date.now() }) expect((s.group_file as TlaGroupFile[]).some((gf) => gf.groupId === userId)).toBe(true) }) it('does not mirror a file the user already has via a group they belong to', async () => { const workspaceId = 'group_workspace1234ab' const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: fileId, owningGroupId: workspaceId, shared: true })], file_state: [], group: [makeGroup({ id: userId }), makeGroup({ id: workspaceId })], group_user: [ makeGroupUser({ userId, groupId: userId, role: 'owner' }), makeGroupUser({ userId, groupId: workspaceId, role: 'member' }), ], group_file: [makeGroupFile({ fileId, groupId: workspaceId })], comment: [], comment_read: [], } const { tx } = createMockTx(s, { location: 'server' }) const m = createMutators(userId) await m.onEnterFile(tx, { fileId, time: Date.now() }) expect((s.group_file as TlaGroupFile[]).some((gf) => gf.groupId === userId)).toBe(false) }) it('mirrors a shared file into home even when a mislinked group_file row points at one of my workspaces', async () => { // Regression: a leftover "link" group_file row (from the removed drag-to-link feature) // puts the file in a workspace the user belongs to WITHOUT that workspace owning it. // The sidebar only lists a non-home workspace's file when it owns it, so this row shows // the file nowhere. Entering the file must still create the home link so it's visible. const ownerHome = 'user_fileowner1234567' const workspaceB = 'group_workspaceB12345' const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: fileId, owningGroupId: ownerHome, shared: true })], file_state: [], group: [ makeGroup({ id: userId }), makeGroup({ id: workspaceB }), makeGroup({ id: ownerHome }), ], group_user: [ makeGroupUser({ userId, groupId: userId, role: 'owner' }), makeGroupUser({ userId, groupId: workspaceB, role: 'member' }), ], // mislinked row: workspaceB does not own the file (owningGroupId is ownerHome) group_file: [makeGroupFile({ fileId, groupId: workspaceB })], comment: [], comment_read: [], } const { tx } = createMockTx(s, { location: 'server' }) const m = createMutators(userId) await m.onEnterFile(tx, { fileId, time: Date.now() }) expect((s.group_file as TlaGroupFile[]).some((gf) => gf.groupId === userId)).toBe(true) }) it('mirrors a group-less (legacy) file into home so it stays findable', async () => { const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: fileId, owningGroupId: null, ownerId: userId, shared: true })], file_state: [], group: [makeGroup({ id: userId })], group_user: [makeGroupUser({ userId, groupId: userId, role: 'owner' })], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s, { location: 'server' }) const m = createMutators(userId) await m.onEnterFile(tx, { fileId, time: Date.now() }) expect((s.group_file as TlaGroupFile[]).some((gf) => gf.groupId === userId)).toBe(true) }) }) describe('workspace mutations', () => { const userId = 'user_aaaa11112222bbbb' it('migrated user can create workspace', async () => { const s = { user: [makeUser({ id: userId })], file: [], file_state: [], group: [], group_user: [], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) const groupId = 'group_new123456789ab' await m.createWorkspace(tx, { id: groupId, name: 'My Group' }) expect(s.group.length).toBe(1) expect(s.group_user.length).toBe(1) expect((s.group_user as TlaGroupUser[])[0]?.role).toBe('owner') }) it('cannot create workspace with an empty name', async () => { const s = { user: [makeUser({ id: userId })], file: [], file_state: [], group: [], group_user: [], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) const groupId = 'group_new123456789ab' await expectBadRequest(() => m.createWorkspace(tx, { id: groupId, name: ' ' })) expect(s.group.length).toBe(0) }) it('owner can update workspace name', async () => { const groupId = 'group_aaa11112222bbb' const s = { user: [makeUser({ id: userId })], file: [], file_state: [], group: [makeGroup({ id: groupId })], group_user: [makeGroupUser({ userId, groupId, role: 'owner' })], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await expectValid(() => m.updateWorkspace(tx, { id: groupId, name: 'Renamed' })) }) it('member cannot update workspace name', async () => { const groupId = 'group_aaa11112222bbb' const s = { user: [makeUser({ id: userId })], file: [], file_state: [], group: [makeGroup({ id: groupId })], group_user: [makeGroupUser({ userId, groupId, role: 'member' })], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await expectForbidden(() => m.updateWorkspace(tx, { id: groupId, name: 'Renamed' })) }) it('owner can delete workspace', async () => { const groupId = 'group_aaa11112222bbb' const fileId = 'file_aaaa11112222bbbb' const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: fileId, owningGroupId: groupId })], file_state: [], group: [makeGroup({ id: groupId })], group_user: [makeGroupUser({ userId, groupId, role: 'owner' })], group_file: [makeGroupFile({ fileId, groupId })], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await m.deleteWorkspace(tx, { id: groupId }) expect(s.group[0]?.isDeleted).toBe(true) expect(s.file[0]?.isDeleted).toBe(true) expect(s.group_file.length).toBe(0) }) it('non-owner cannot delete workspace', async () => { const groupId = 'group_aaa11112222bbb' const s = { user: [makeUser({ id: userId })], file: [], file_state: [], group: [makeGroup({ id: groupId })], group_user: [makeGroupUser({ userId, groupId, role: 'member' })], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await expectForbidden(() => m.deleteWorkspace(tx, { id: groupId })) }) }) describe('membership', () => { const userId = 'user_aaaa11112222bbbb' const groupId = 'group_aaa11112222bbb' const memberId = 'user_member12345678ab' it('owner can set member roles', async () => { const s = { user: [makeUser({ id: userId })], file: [], file_state: [], group: [makeGroup({ id: groupId })], group_user: [ makeGroupUser({ userId, groupId, role: 'owner' }), makeGroupUser({ userId: memberId, groupId, role: 'member' }), ], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await m.setWorkspaceMemberRole(tx, { workspaceId: groupId, targetUserId: memberId, role: 'owner', }) expect(s.group_user.find((gu) => gu.userId === memberId)?.role).toBe('owner') }) it('member cannot set member roles', async () => { const s = { user: [makeUser({ id: userId })], file: [], file_state: [], group: [makeGroup({ id: groupId })], group_user: [ makeGroupUser({ userId, groupId, role: 'member' }), makeGroupUser({ userId: memberId, groupId, role: 'member' }), ], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await expectForbidden(() => m.setWorkspaceMemberRole(tx, { workspaceId: groupId, targetUserId: memberId, role: 'owner' }) ) }) it('cannot demote last owner to member', async () => { const s = { user: [makeUser({ id: userId })], file: [], file_state: [], group: [makeGroup({ id: groupId })], group_user: [ makeGroupUser({ userId, groupId, role: 'owner' }), makeGroupUser({ userId: memberId, groupId, role: 'member' }), ], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await expectForbidden(() => m.setWorkspaceMemberRole(tx, { workspaceId: groupId, targetUserId: userId, role: 'member' }) ) }) it('last owner cannot leave workspace', async () => { const s = { user: [makeUser({ id: userId })], file: [], file_state: [], group: [makeGroup({ id: groupId })], group_user: [makeGroupUser({ userId, groupId, role: 'owner' })], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await expectForbidden(() => m.leaveWorkspace(tx, { workspaceId: groupId })) }) it('non-owner member can leave workspace', async () => { const s = { user: [makeUser({ id: userId })], file: [], file_state: [], group: [makeGroup({ id: groupId })], group_user: [ makeGroupUser({ userId: 'user_owner12345678ab', groupId, role: 'owner' }), makeGroupUser({ userId, groupId, role: 'member' }), ], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await m.leaveWorkspace(tx, { workspaceId: groupId }) expect(s.group_user.find((gu) => gu.userId === userId)).toBeUndefined() }) it('owner can remove a member', async () => { const s = { user: [makeUser({ id: userId })], file: [], file_state: [], group: [makeGroup({ id: groupId })], group_user: [ makeGroupUser({ userId, groupId, role: 'owner' }), makeGroupUser({ userId: memberId, groupId, role: 'member' }), ], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await m.removeWorkspaceMember(tx, { workspaceId: groupId, targetUserId: memberId }) expect(s.group_user.find((gu) => gu.userId === memberId)).toBeUndefined() }) it('member cannot remove members', async () => { const s = { user: [makeUser({ id: userId })], file: [], file_state: [], group: [makeGroup({ id: groupId })], group_user: [ makeGroupUser({ userId, groupId, role: 'member' }), makeGroupUser({ userId: memberId, groupId, role: 'member' }), ], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await expectForbidden(() => m.removeWorkspaceMember(tx, { workspaceId: groupId, targetUserId: memberId }) ) }) it('cannot remove the last owner', async () => { const s = { user: [makeUser({ id: userId })], file: [], file_state: [], group: [makeGroup({ id: groupId })], group_user: [ makeGroupUser({ userId, groupId, role: 'owner' }), makeGroupUser({ userId: memberId, groupId, role: 'member' }), ], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await expectForbidden(() => m.removeWorkspaceMember(tx, { workspaceId: groupId, targetUserId: userId }) ) }) }) describe('file operations across workspaces', () => { const userId = 'user_aaaa11112222bbbb' const groupA = 'group_aaa11112222bbb' const groupB = 'group_bbb11112222ccc' const fileId = 'file_aaaa11112222bbbb' it('member of both workspaces can move file between them', async () => { const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: fileId, owningGroupId: groupA })], file_state: [], group: [makeGroup({ id: groupA }), makeGroup({ id: groupB })], group_user: [ makeGroupUser({ userId, groupId: groupA }), makeGroupUser({ userId, groupId: groupB }), ], group_file: [makeGroupFile({ fileId, groupId: groupA })], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await m.moveFileToWorkspace(tx, { fileId, workspaceId: groupB }) expect(s.file[0]?.owningGroupId).toBe(groupB) }) it('member of source workspace only cannot move file to other workspace', async () => { const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: fileId, owningGroupId: groupA })], file_state: [], group: [makeGroup({ id: groupA }), makeGroup({ id: groupB })], group_user: [makeGroupUser({ userId, groupId: groupA })], group_file: [makeGroupFile({ fileId, groupId: groupA })], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await expectForbidden(() => m.moveFileToWorkspace(tx, { fileId, workspaceId: groupB })) }) it('member of target workspace only cannot move file from other workspace', async () => { const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: fileId, owningGroupId: groupA })], file_state: [], group: [makeGroup({ id: groupA }), makeGroup({ id: groupB })], group_user: [makeGroupUser({ userId, groupId: groupB })], group_file: [makeGroupFile({ fileId, groupId: groupA })], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await expectForbidden(() => m.moveFileToWorkspace(tx, { fileId, workspaceId: groupB })) }) it('member can remove file from workspace', async () => { const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: fileId, owningGroupId: groupA })], file_state: [makeFileState({ userId, fileId })], group: [makeGroup({ id: groupA })], group_user: [makeGroupUser({ userId, groupId: groupA, role: 'member' })], group_file: [makeGroupFile({ fileId, groupId: groupA })], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await m.removeFileFromWorkspace(tx, { fileId, workspaceId: groupA }) expect(s.file[0]?.isDeleted).toBe(true) }) it('non-member cannot remove file from workspace', async () => { const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: fileId, owningGroupId: groupA })], file_state: [makeFileState({ userId, fileId })], group: [makeGroup({ id: groupA })], group_user: [], group_file: [makeGroupFile({ fileId, groupId: groupA })], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await expectForbidden(() => m.removeFileFromWorkspace(tx, { fileId, workspaceId: groupA })) expect(s.file[0]?.isDeleted).toBe(false) }) it('removing a linked file deletes only the link, not the file', async () => { // the file is owned by workspace B but linked into workspace A const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: fileId, owningGroupId: groupB })], file_state: [makeFileState({ userId, fileId })], group: [makeGroup({ id: groupA }), makeGroup({ id: groupB })], group_user: [makeGroupUser({ userId, groupId: groupA, role: 'owner' })], group_file: [ makeGroupFile({ fileId, groupId: groupA }), makeGroupFile({ fileId, groupId: groupB }), ], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await m.removeFileFromWorkspace(tx, { fileId, workspaceId: groupA }) expect(s.file[0]?.isDeleted).toBe(false) expect(s.group_file.some((gf) => gf.groupId === groupA)).toBe(false) expect(s.group_file.some((gf) => gf.groupId === groupB)).toBe(true) }) it('pinning in a workspace computes the index against that workspace, not home', async () => { const otherFileId = 'file_bbbb11112222cccc' const homePinnedId = 'file_cccc11112222dddd' const s = { user: [makeUser({ id: userId })], file: [ makeFile({ id: fileId, owningGroupId: groupA }), makeFile({ id: otherFileId, owningGroupId: groupA }), makeFile({ id: homePinnedId, owningGroupId: userId }), ], file_state: [], group: [makeGroup({ id: groupA }), makeGroup({ id: userId })], group_user: [ makeGroupUser({ userId, groupId: groupA }), makeGroupUser({ userId, groupId: userId }), ], group_file: [ makeGroupFile({ fileId, groupId: groupA }), // an already-pinned sibling in the same workspace makeGroupFile({ fileId: otherFileId, groupId: groupA, index: 'a1' as IndexKey }), // a pinned file in the home workspace with the same index; it must not // influence (or collide with) the new pin's index makeGroupFile({ fileId: homePinnedId, groupId: userId, index: 'a1' as IndexKey }), ], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await m.pinFile(tx, { fileId, workspaceId: groupA }) const pinned = s.group_file.find((gf) => gf.fileId === fileId && gf.groupId === groupA) const sibling = s.group_file.find((gf) => gf.fileId === otherFileId && gf.groupId === groupA) expect(pinned?.index).toBeTruthy() // new pin goes above the workspace's existing pinned sibling expect(pinned!.index! < sibling!.index!).toBe(true) }) }) describe('home workspace special case', () => { const userId = 'user_aaaa11112222bbbb' it('home workspace shortcut passes membership check', async () => { // createFile with groupId === userId should pass the membership check // even without a group_user row, because of the userId === groupId shortcut const s = { user: [makeUser({ id: userId })], file: [], file_state: [], group: [makeGroup({ id: userId })], group_user: [makeGroupUser({ userId, groupId: userId, role: 'owner' })], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) // This exercises the userId === groupId shortcut in assertUserIsGroupMember await expectValid(() => m.createFile(tx, { fileId: 'file_home123456789ab', workspaceId: userId, name: 'Home file', time: Date.now(), createSource: null, }) ) }) // The home workspace (group id === userId) can't be invited to, left, deleted, // or have its members managed. (It can be renamed.) function homeState(extra?: { secondOwnerId?: string }) { const group_user: TlaGroupUser[] = [makeGroupUser({ userId, groupId: userId, role: 'owner' })] const user = [makeUser({ id: userId })] if (extra?.secondOwnerId) { group_user.push( makeGroupUser({ userId: extra.secondOwnerId, groupId: userId, role: 'owner' }) ) user.push(makeUser({ id: extra.secondOwnerId })) } return { user, file: [], file_state: [], group: [makeGroup({ id: userId })], group_user, group_file: [], comment: [], comment_read: [], } satisfies TableStore } it('can rename home workspace', async () => { const { tx } = createMockTx(homeState()) const m = createMutators(userId) await expectValid(() => m.updateWorkspace(tx, { id: userId, name: 'My Home' })) }) it('cannot regenerate invite secret on home workspace', async () => { const { tx } = createMockTx(homeState(), { location: 'server' }) const m = createMutators(userId) await expectForbidden(() => m.regenerateWorkspaceInviteSecret(tx, { id: userId })) }) it('cannot delete home workspace', async () => { const { tx } = createMockTx(homeState()) const m = createMutators(userId) await expectForbidden(() => m.deleteWorkspace(tx, { id: userId })) }) it('cannot leave home workspace, even with another owner', async () => { // A second owner would normally allow leaving; the home guard blocks it anyway. const { tx } = createMockTx(homeState({ secondOwnerId: 'user_second12345678' })) const m = createMutators(userId) await expectForbidden(() => m.leaveWorkspace(tx, { workspaceId: userId })) }) it('cannot change member roles in home workspace', async () => { const targetId = 'user_target123456789' const s = homeState() s.user.push(makeUser({ id: targetId })) s.group_user.push(makeGroupUser({ userId: targetId, groupId: userId, role: 'member' })) const { tx } = createMockTx(s) const m = createMutators(userId) await expectForbidden(() => m.setWorkspaceMemberRole(tx, { workspaceId: userId, targetUserId: targetId, role: 'owner' }) ) }) }) describe('regenerateWorkspaceInviteSecret', () => { const userId = 'user_aaaa11112222bbbb' const groupId = 'group_aaa11112222bbb' it('owner can regenerate invite secret', async () => { const s = { user: [makeUser({ id: userId })], file: [], file_state: [], group: [makeGroup({ id: groupId, inviteSecret: 'old_secret_1234567' })], group_user: [makeGroupUser({ userId, groupId, role: 'owner' })], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s, { location: 'server' }) const m = createMutators(userId) await m.regenerateWorkspaceInviteSecret(tx, { id: groupId }) // inviteSecret should have changed expect(s.group[0]?.inviteSecret).not.toBe('old_secret_1234567') }) it('member cannot regenerate invite secret', async () => { const s = { user: [makeUser({ id: userId })], file: [], file_state: [], group: [makeGroup({ id: groupId, inviteSecret: 'old_secret_1234567' })], group_user: [makeGroupUser({ userId, groupId, role: 'member' })], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s, { location: 'server' }) const m = createMutators(userId) await expectForbidden(() => m.regenerateWorkspaceInviteSecret(tx, { id: groupId })) }) it('non-member cannot regenerate invite secret', async () => { const nonMemberId = 'user_nonmember123456' const s = { user: [makeUser({ id: nonMemberId })], file: [], file_state: [], group: [makeGroup({ id: groupId })], // No group_user for nonMemberId — not a member at all group_user: [], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s, { location: 'server' }) const m = createMutators(nonMemberId) await expectForbidden(() => m.regenerateWorkspaceInviteSecret(tx, { id: groupId })) }) }) describe('setWorkspaceInviteLinkEnabled', () => { const userId = 'user_aaaa11112222bbbb' const groupId = 'group_aaa11112222bbb' it('owner can toggle the invite link', async () => { const s = { user: [makeUser({ id: userId })], file: [], file_state: [], group: [makeGroup({ id: groupId, inviteLinkEnabled: true })], group_user: [makeGroupUser({ userId, groupId, role: 'owner' })], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s, { location: 'server' }) const m = createMutators(userId) await m.setWorkspaceInviteLinkEnabled(tx, { id: groupId, enabled: false }) expect(s.group[0]?.inviteLinkEnabled).toBe(false) await m.setWorkspaceInviteLinkEnabled(tx, { id: groupId, enabled: true }) expect(s.group[0]?.inviteLinkEnabled).toBe(true) }) it('member cannot toggle the invite link', async () => { const s = { user: [makeUser({ id: userId })], file: [], file_state: [], group: [makeGroup({ id: groupId, inviteLinkEnabled: true })], group_user: [makeGroupUser({ userId, groupId, role: 'member' })], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s, { location: 'server' }) const m = createMutators(userId) await expectForbidden(() => m.setWorkspaceInviteLinkEnabled(tx, { id: groupId, enabled: false }) ) }) }) describe('immutable column bypass attempts', () => { const userId = 'user_aaaa11112222bbbb' const groupId = 'group_aaa11112222bbb' it('cannot change file.ownerId to self', async () => { const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: 'file_aaaa11112222bbbb', owningGroupId: groupId })], file_state: [], group: [makeGroup({ id: groupId })], group_user: [makeGroupUser({ userId, groupId })], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await expectForbidden(() => m.file.update(tx, { id: 'file_aaaa11112222bbbb', ownerId: userId })) }) it('cannot change file.owningGroupId', async () => { const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: 'file_aaaa11112222bbbb', owningGroupId: groupId })], file_state: [], group: [makeGroup({ id: groupId })], group_user: [makeGroupUser({ userId, groupId })], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await expectForbidden(() => m.file.update(tx, { id: 'file_aaaa11112222bbbb', owningGroupId: 'evil_group_12345' }) ) }) it('cannot set isDeleted:true', async () => { const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: 'file_aaaa11112222bbbb', owningGroupId: groupId })], file_state: [], group: [makeGroup({ id: groupId })], group_user: [makeGroupUser({ userId, groupId })], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await expectForbidden(() => m.file.update(tx, { id: 'file_aaaa11112222bbbb', isDeleted: true })) }) it('cannot set isDeleted:false (falsy value still blocked)', async () => { const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: 'file_aaaa11112222bbbb', owningGroupId: groupId, isDeleted: false })], file_state: [], group: [makeGroup({ id: groupId })], group_user: [makeGroupUser({ userId, groupId })], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await expectForbidden(() => m.file.update(tx, { id: 'file_aaaa11112222bbbb', isDeleted: false }) ) }) it('cannot update file_state for inaccessible file on server', async () => { const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: 'file_secret12345678', owningGroupId: groupId, shared: false })], file_state: [makeFileState({ userId, fileId: 'file_secret12345678' })], group: [makeGroup({ id: groupId })], group_user: [], // not a member group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s, { location: 'server' }) const m = createMutators(userId) await expectForbidden(() => m.file_state.update(tx, { userId, fileId: 'file_secret12345678', lastVisitAt: Date.now() }) ) }) it('cannot change immutable file_state field (firstVisitAt)', async () => { const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: 'file_aaaa11112222bbbb', owningGroupId: groupId })], file_state: [makeFileState({ userId, fileId: 'file_aaaa11112222bbbb', firstVisitAt: 1 })], group: [makeGroup({ id: groupId })], group_user: [makeGroupUser({ userId, groupId })], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s) const m = createMutators(userId) await expectForbidden(() => m.file_state.update(tx, { userId, fileId: 'file_aaaa11112222bbbb', firstVisitAt: 999, }) ) }) }) describe('file access control logic', () => { const userId = 'user_aaaa11112222bbbb' const groupId = 'group_aaa11112222bbb' it('non-member can enter shared file (read access)', async () => { const otherId = 'user_other1234567890' const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: 'file_shared123456789', owningGroupId: groupId, shared: true })], file_state: [], group: [makeGroup({ id: groupId })], group_user: [makeGroupUser({ userId: otherId, groupId })], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s, { location: 'server' }) const m = createMutators(userId) // onEnterFile uses assertUserCanAccessFile (allowGuestAccess=true) await expectValid(() => m.onEnterFile(tx, { fileId: 'file_shared123456789', time: Date.now() })) }) it('cannot enter deleted file', async () => { const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: 'file_deleted1234567a', owningGroupId: groupId, isDeleted: true })], file_state: [], group: [makeGroup({ id: groupId })], group_user: [makeGroupUser({ userId, groupId })], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s, { location: 'server' }) const m = createMutators(userId) await expectBadRequest(() => m.onEnterFile(tx, { fileId: 'file_deleted1234567a', time: Date.now() }) ) }) it('cannot enter file with neither ownerId nor owningGroupId', async () => { const s = { user: [makeUser({ id: userId })], file: [ makeFile({ id: 'file_orphan12345678a', ownerId: null, owningGroupId: null, shared: false, }), ], file_state: [], group: [], group_user: [], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s, { location: 'server' }) const m = createMutators(userId) await expectBadRequest(() => m.onEnterFile(tx, { fileId: 'file_orphan12345678a', time: Date.now() }) ) }) }) describe('cross-user isolation', () => { const userA = 'user_aaaa11112222bbbb' const userB = 'user_bbbb22223333cccc' const groupA = 'group_aaa11112222bbb' it('user A files never accessible to unrelated user B', async () => { const s = { user: [makeUser({ id: userA }), makeUser({ id: userB })], file: [makeFile({ id: 'file_userA123456789a', owningGroupId: groupA, shared: false })], file_state: [], group: [makeGroup({ id: groupA })], group_user: [makeGroupUser({ userId: userA, groupId: groupA })], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s, { location: 'server' }) const mB = createMutators(userB) // User B cannot update A's file await expectForbidden(() => mB.file.update(tx, { id: 'file_userA123456789a', name: 'Stolen' })) // User B cannot enter A's file await expectForbidden(() => mB.onEnterFile(tx, { fileId: 'file_userA123456789a', time: Date.now() }) ) }) }) describe('createFile from source (duplicate) access control', () => { const userId = 'user_aaaa11112222bbbb' const otherGroup = 'group_other123456789' const sourceId = 'file_source123456789' const newFileId = 'file_dup1234567890ab' // migrated user whose target group is their home group (groupId === userId) function baseStore(sourceFile: TlaFile) { return { user: [makeUser({ id: userId })], file: [sourceFile], file_state: [], group: [makeGroup({ id: userId }), makeGroup({ id: otherGroup })], group_user: [makeGroupUser({ userId, groupId: userId, role: 'owner' })], group_file: [], comment: [], comment_read: [], } } function duplicate(m: ReturnType<typeof createMutators>, tx: any, createSource: string | null) { return m.createFile(tx, { fileId: newFileId, workspaceId: userId, name: 'Copy', time: Date.now(), createSource, }) } it('can duplicate a file shared with you', async () => { const s = baseStore(makeFile({ id: sourceId, owningGroupId: otherGroup, shared: true })) const { tx } = createMockTx(s, { location: 'server' }) const m = createMutators(userId) await expectValid(() => duplicate(m, tx, `${FILE_PREFIX}/${sourceId}`)) expect(s.file.find((f) => f.id === newFileId)).toBeDefined() }) it('can duplicate your own file', async () => { // source owned by the user's home group → accessible as a member const s = baseStore(makeFile({ id: sourceId, owningGroupId: userId, shared: false })) const { tx } = createMockTx(s, { location: 'server' }) const m = createMutators(userId) await expectValid(() => duplicate(m, tx, `${FILE_PREFIX}/${sourceId}`)) }) it('cannot duplicate a file you cannot access (e.g. access revoked, shared:false)', async () => { // the core bug: source no longer shared and user is not owner/member const s = baseStore(makeFile({ id: sourceId, owningGroupId: otherGroup, shared: false })) const { tx } = createMockTx(s, { location: 'server' }) const m = createMutators(userId) await expectForbidden(() => duplicate(m, tx, `${FILE_PREFIX}/${sourceId}`)) // and no new file was created expect(s.file.find((f) => f.id === newFileId)).toBeUndefined() }) it('cannot duplicate a file you only know the id of (source not present)', async () => { const s = { user: [makeUser({ id: userId })], file: [] as TlaFile[], file_state: [], group: [makeGroup({ id: userId })], group_user: [makeGroupUser({ userId, groupId: userId, role: 'owner' })], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s, { location: 'server' }) const m = createMutators(userId) await expectBadRequest(() => duplicate(m, tx, `${FILE_PREFIX}/${sourceId}`)) }) it('cannot duplicate an inaccessible legacy (ownerId-owned) file', async () => { const s = { user: [makeUser({ id: userId })], file: [makeFile({ id: sourceId, ownerId: 'user_someoneElse1234', shared: false })], file_state: [], group: [], group_user: [], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s, { location: 'server' }) const m = createMutators(userId) await expectForbidden(() => duplicate(m, tx, `${FILE_PREFIX}/${sourceId}`)) }) it('does not gate non-file createSource prefixes (e.g. published)', async () => { // a published-doc copy uses a different prefix and must still work even // when there is no readable `file` row for the source id const s = { user: [makeUser({ id: userId })], file: [] as TlaFile[], file_state: [], group: [makeGroup({ id: userId })], group_user: [makeGroupUser({ userId, groupId: userId, role: 'owner' })], group_file: [], comment: [], comment_read: [], } const { tx } = createMockTx(s, { location: 'server' }) const m = createMutators(userId) await expectValid(() => duplicate(m, tx, `${PUBLISH_PREFIX}/${sourceId}`)) }) }) describe('file creation security', () => { const userId = 'user_aaaa11112222bbbb' it('file.insertWithFileState is not exposed as a callable mutator', () => { // It was demoted to a private helper so a client cannot insert an // arbitrary file row (with an attacker-controlled createSource) directly. const m = createMutators(userId) expect((m.file as Record<string, unknown>).insertWithFileState).toBeUndefined() }) }) describe('comment.markRead / comment.markUnread', () => { // owner1 owns file1; comment c1 by author-1 lives on file1 function commentState(): TableStore { return { user: [makeUser({ id: 'owner1' }), makeUser({ id: 'other1' })], file: [makeFile({ id: 'file1', ownerId: 'owner1' })], file_state: [], group: [], group_user: [], group_file: [], comment: [makeComment({ id: 'c1', fileId: 'file1' })], comment_read: [], } } it('markRead upserts a read row scoped to the calling user', async () => { const { tx, store } = createMockTx(commentState(), { location: 'server' }) const mutators = createMutators('owner1') await mutators.comment.markRead(tx, { commentId: 'c1', readAt: Date.now() }) expect(store.comment_read).toHaveLength(1) expect(store.comment_read[0]).toMatchObject({ userId: 'owner1', commentId: 'c1' }) }) it('markRead is idempotent and updates readAt', async () => { const { tx, store } = createMockTx(commentState(), { location: 'server' }) const mutators = createMutators('owner1') const t1 = Date.now() - 1000 const t2 = Date.now() await mutators.comment.markRead(tx, { commentId: 'c1', readAt: t1 }) await mutators.comment.markRead(tx, { commentId: 'c1', readAt: t2 }) expect(store.comment_read).toHaveLength(1) expect(store.comment_read[0].readAt).toBe(t2) }) it('markRead clamps unreasonable timestamps to server time', async () => { const { tx, store } = createMockTx(commentState(), { location: 'server' }) const mutators = createMutators('owner1') const before = Date.now() await mutators.comment.markRead(tx, { commentId: 'c1', readAt: before + 60_000 }) expect(store.comment_read[0].readAt).toBeGreaterThanOrEqual(before) expect(store.comment_read[0].readAt).toBeLessThanOrEqual(Date.now()) }) it('markRead rejects forbidden when the user cannot access the file', async () => { const { tx } = createMockTx(commentState(), { location: 'server' }) const mutators = createMutators('other1') await expectForbidden(() => mutators.comment.markRead(tx, { commentId: 'c1', readAt: Date.now() }) ) }) it('markRead rejects bad_request on a missing comment', async () => { const { tx } = createMockTx(commentState(), { location: 'server' }) const mutators = createMutators('owner1') await expectBadRequest(() => mutators.comment.markRead(tx, { commentId: 'nope', readAt: Date.now() }) ) }) it('markRead skips the access check on the client', async () => { // optimistic client writes go through; the server re-run is authoritative const { tx, store } = createMockTx(commentState(), { location: 'client' }) const mutators = createMutators('other1') await mutators.comment.markRead(tx, { commentId: 'c1', readAt: Date.now() }) expect(store.comment_read).toHaveLength(1) }) it('markUnread deletes the row and is a no-op when absent', async () => { const { tx, store } = createMockTx(commentState(), { location: 'server' }) const mutators = createMutators('owner1') await mutators.comment.markRead(tx, { commentId: 'c1', readAt: Date.now() }) await mutators.comment.markUnread(tx, { commentId: 'c1' }) expect(store.comment_read).toHaveLength(0) await expectValid(() => mutators.comment.markUnread(tx, { commentId: 'c1' })) }) it("markUnread only deletes the calling user's row", async () => { const s = commentState() s.comment_read.push({ userId: 'other1', commentId: 'c1', readAt: 5 }) const { tx, store } = createMockTx(s, { location: 'server' }) const mutators = createMutators('owner1') await mutators.comment.markRead(tx, { commentId: 'c1', readAt: Date.now() }) await mutators.comment.markUnread(tx, { commentId: 'c1' }) expect(store.comment_read).toEqual([{ userId: 'other1', commentId: 'c1', readAt: 5 }]) }) }) describe('comment.markManyRead', () => { // owner1 owns file1 (comments c1, c2) and file2 (comment c3); other1 owns nothing function manyCommentState(): TableStore { return { user: [makeUser({ id: 'owner1' }), makeUser({ id: 'other1' })], file: [ makeFile({ id: 'file1', ownerId: 'owner1' }), makeFile({ id: 'file2', ownerId: 'owner1' }), ], file_state: [], group: [], group_user: [], group_file: [], comment: [ makeComment({ id: 'c1', fileId: 'file1' }), makeComment({ id: 'c2', fileId: 'file1' }), makeComment({ id: 'c3', fileId: 'file2' }), ], comment_read: [], } } it('upserts one read row per comment, scoped to the calling user, across files', async () => { const { tx, store } = createMockTx(manyCommentState(), { location: 'server' }) const mutators = createMutators('owner1') await mutators.comment.markManyRead(tx, { commentIds: ['c1', 'c2', 'c3'], readAt: Date.now() }) expect(store.comment_read).toHaveLength(3) expect(store.comment_read.map((r) => r.commentId).sort()).toEqual(['c1', 'c2', 'c3']) expect(store.comment_read.every((r) => r.userId === 'owner1')).toBe(true) }) it('dedupes repeated ids and is idempotent, updating readAt', async () => { const { tx, store } = createMockTx(manyCommentState(), { location: 'server' }) const mutators = createMutators('owner1') const t1 = Date.now() - 1000 const t2 = Date.now() await mutators.comment.markManyRead(tx, { commentIds: ['c1', 'c1', 'c2'], readAt: t1 }) await mutators.comment.markManyRead(tx, { commentIds: ['c1', 'c2'], readAt: t2 }) expect(store.comment_read).toHaveLength(2) expect(store.comment_read.every((r) => r.readAt === t2)).toBe(true) }) it('clamps unreasonable timestamps to server time', async () => { const { tx, store } = createMockTx(manyCommentState(), { location: 'server' }) const mutators = createMutators('owner1') const before = Date.now() await mutators.comment.markManyRead(tx, { commentIds: ['c1'], readAt: before + 60_000 }) expect(store.comment_read[0].readAt).toBeGreaterThanOrEqual(before) expect(store.comment_read[0].readAt).toBeLessThanOrEqual(Date.now()) }) it('is a no-op on an empty batch', async () => { const { tx, store, mutations } = createMockTx(manyCommentState(), { location: 'server' }) const mutators = createMutators('owner1') await mutators.comment.markManyRead(tx, { commentIds: [], readAt: Date.now() }) expect(store.comment_read).toHaveLength(0) expect(mutations).toHaveLength(0) }) it('rejects forbidden when the user cannot access an involved file', async () => { const { tx, store } = createMockTx(manyCommentState(), { location: 'server' }) const mutators = createMutators('other1') await expectForbidden(() => mutators.comment.markManyRead(tx, { commentIds: ['c1', 'c2'], readAt: Date.now() }) ) expect(store.comment_read).toHaveLength(0) }) it('rejects bad_request when any comment is missing', async () => { const { tx, store } = createMockTx(manyCommentState(), { location: 'server' }) const mutators = createMutators('owner1') await expectBadRequest(() => mutators.comment.markManyRead(tx, { commentIds: ['c1', 'nope'], readAt: Date.now() }) ) expect(store.comment_read).toHaveLength(0) }) it('skips the access check on the client', async () => { // optimistic client writes go through; the server re-run is authoritative const { tx, store } = createMockTx(manyCommentState(), { location: 'client' }) const mutators = createMutators('other1') await mutators.comment.markManyRead(tx, { commentIds: ['c1', 'c2'], readAt: Date.now() }) expect(store.comment_read).toHaveLength(2) }) })