Files
zpan/server/services/notification.integration.test.ts
T
Jasper Van 7bad8d2aea fix: audit must-fixes + product gaps (SSRF, move-quota, multipart, password-reset, trash retention, notifications) (#428)
* fix(downloads): block SSRF targets in remote-download source URL

The remote-download source URI was only length-validated, so an
authenticated editor could point a task at the cloud metadata endpoint,
loopback, or RFC 1918 hosts and have the response exfiltrated to their
own drive. Add a shared isSafeHttpUrl/isBlockedUrlHost guard (scheme
allowlist + private/loopback/link-local/metadata/IPv6 blocking) and
cross-check source type vs uri in createDownloadTaskSchema.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(api): cover 9 untested src/lib/api.ts wrappers

Adds api.test.ts coverage (RPC path, method, payload, success + ApiError
paths) for listObjectsByPath, isNameConflictError, listAdminAuthProviders,
upsertAuthProvider, deleteAuthProvider, listInviteCodes, generateInviteCodes,
deleteInviteCode, and listTeamActivities — satisfying the CLAUDE.md coverage
gate that otherwise blocks PRs touching api.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(spaces): release source quota on cross-space move

A cross-space "move" copied bytes into the target (reserving quota there)
but only trashed the source. Trashed files still count toward usage, so the
moved bytes were billed in both spaces and the source never freed — contrary
to the design doc ("copy + delete source, quota effectively transfers").

Purge the source subtree (independent S3 copy already exists in the target)
instead of trashing it, which deletes the objects, cascades share cleanup,
and reconciles usage. Rename the response field sourceTrashed -> sourceDeleted
and update the move hint copy accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(upload): wire S3 multipart for large files

The upload UI only ever did a single presigned PUT, which caps at S3's
5 GiB limit and fails the whole transfer on any network blip — despite a
complete multipart backend (object-upload-sessions) sitting unused.

Add uploadPartToS3 (PUTs a part, returns its ETag) and a multipart-upload
orchestrator: open session -> presign parts in batches of 100 -> PUT parts
with bounded concurrency and per-part retry -> complete. Files over 100 MiB
take this path; smaller files keep the single-PUT flow. Cancellation aborts
the multipart and the draft. Also fixes the presignObjectUploadParts wrapper
type to match the server's actual `url` field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(auth): add password-reset flow

There was no self-service password recovery — a forgotten password needed
admin intervention. SMTP/email sending was already built; this wires the
last mile: better-auth sendResetPassword (reset email), a "Forgot password?"
link on sign-in, and /forgot-password + /reset-password pages. The
forgot-password page never reveals whether an account exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(trash): auto-purge trashed items past a retention window

Trashed files counted toward quota forever — trash never auto-emptied, so
storage was never reclaimed without a manual "empty trash". Add a daily cron
(CF Workers 0 4 * * * + Node setInterval) that purges trashed items older than
ZPAN_TRASH_RETENTION_DAYS (default 30, 0 disables) across all orgs, reusing the
existing purge path so S3 objects, share references, and quota are all cleaned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(notifications): typed NotificationType, i18n rendering, team-join

Notifications were a bare-string type with only 3 producers, and server copy
was stored as hardcoded English (zh users saw English).

- Add a NotificationType union in shared/ and type the notification service.
- Render notification title/body client-side from type + metadata via i18n,
  falling back to stored strings for older rows (fixes the hardcoded-English gap).
- Notify users when they join a team (team_join).

(Login auditing was intentionally dropped: reusing the activity-events feed for
sign_in events would spam every user's per-org activity timeline. Proper auth
auditing belongs in a dedicated log and can be added separately.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: cover SSRF guard and multipart upload branches

Raise patch coverage on the new code: uploadPartToS3 pre-aborted-signal and
network-error paths, the url-safety octet-overflow and public-IPv6 branches,
and the invalid-magnet rejection in the download-task schema.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:06:25 -04:00

215 lines
6.8 KiB
TypeScript

import { nanoid } from 'nanoid'
import { describe, expect, it } from 'vitest'
import * as authSchema from '../db/auth-schema.js'
import {
createNotification,
listNotifications,
markAllAsRead,
markAsRead,
unreadCount,
} from '../services/notification.js'
import { createTestApp } from '../test/setup.js'
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
async function insertUser(db: TestDb, overrides: Partial<{ id: string; email: string }> = {}) {
const id = overrides.id ?? nanoid()
await db.insert(authSchema.user).values({
id,
name: 'Test User',
email: overrides.email ?? `${id}@example.com`,
emailVerified: false,
createdAt: new Date(),
updatedAt: new Date(),
})
return id
}
describe('createNotification', () => {
it('writes a row and returns it', async () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
const n = await createNotification(db, { userId, type: 'share_received', title: 'You got a share' })
expect(n.id).toBeDefined()
expect(n.userId).toBe(userId)
expect(n.type).toBe('share_received')
expect(n.title).toBe('You got a share')
expect(n.body).toBe('')
expect(n.readAt).toBeNull()
expect(n.createdAt).toBeInstanceOf(Date)
})
it('stores optional fields', async () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
const n = await createNotification(db, {
userId,
type: 'share_received',
title: 'Test',
body: 'body text',
refType: 'share',
refId: 'ref-1',
metadata: JSON.stringify({ token: 'abc' }),
})
expect(n.body).toBe('body text')
expect(n.refType).toBe('share')
expect(n.refId).toBe('ref-1')
expect(n.metadata).toBe(JSON.stringify({ token: 'abc' }))
})
})
describe('listNotifications', () => {
it('returns empty list for new user', async () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
const result = await listNotifications(db, userId, { page: 1, pageSize: 20 })
expect(result.items).toHaveLength(0)
expect(result.total).toBe(0)
expect(result.unreadCount).toBe(0)
})
it('paginates correctly', async () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
for (let i = 0; i < 5; i++) {
await createNotification(db, { userId, type: 'share_received', title: `Notification ${i}` })
}
const page1 = await listNotifications(db, userId, { page: 1, pageSize: 3 })
expect(page1.items).toHaveLength(3)
expect(page1.total).toBe(5)
const page2 = await listNotifications(db, userId, { page: 2, pageSize: 3 })
expect(page2.items).toHaveLength(2)
})
it('returns accurate unreadCount regardless of filter', async () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
const n1 = await createNotification(db, { userId, type: 'share_received', title: 'A' })
await createNotification(db, { userId, type: 'share_received', title: 'B' })
await markAsRead(db, userId, n1.id)
const result = await listNotifications(db, userId, { page: 1, pageSize: 20 })
expect(result.total).toBe(2)
expect(result.unreadCount).toBe(1)
})
it('filters unread only when requested', async () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
const n1 = await createNotification(db, { userId, type: 'share_received', title: 'A' })
await createNotification(db, { userId, type: 'share_received', title: 'B' })
await markAsRead(db, userId, n1.id)
const result = await listNotifications(db, userId, { page: 1, pageSize: 20, unreadOnly: true })
expect(result.items).toHaveLength(1)
expect(result.items[0].title).toBe('B')
})
it('isolates between users', async () => {
const { db } = await createTestApp()
const user1 = await insertUser(db)
const user2 = await insertUser(db)
await createNotification(db, { userId: user1, type: 'share_received', title: 'For user1' })
const result = await listNotifications(db, user2, { page: 1, pageSize: 20 })
expect(result.items).toHaveLength(0)
})
})
describe('markAsRead', () => {
it('marks a notification as read (idempotent)', async () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
const n = await createNotification(db, { userId, type: 'share_received', title: 'Test' })
const first = await markAsRead(db, userId, n.id)
expect(first).toBe(true)
const second = await markAsRead(db, userId, n.id)
expect(second).toBe(true)
const count = await unreadCount(db, userId)
expect(count).toBe(0)
})
it('returns false for a cross-user attempt', async () => {
const { db } = await createTestApp()
const owner = await insertUser(db)
const other = await insertUser(db)
const n = await createNotification(db, { userId: owner, type: 'share_received', title: 'Test' })
const result = await markAsRead(db, other, n.id)
expect(result).toBe(false)
const count = await unreadCount(db, owner)
expect(count).toBe(1)
})
})
describe('markAllAsRead', () => {
it('marks all unread notifications and returns count', async () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
await createNotification(db, { userId, type: 'share_received', title: 'A' })
await createNotification(db, { userId, type: 'share_received', title: 'B' })
const result = await markAllAsRead(db, userId)
expect(result.count).toBe(2)
const count = await unreadCount(db, userId)
expect(count).toBe(0)
})
it('only affects the requesting user', async () => {
const { db } = await createTestApp()
const user1 = await insertUser(db)
const user2 = await insertUser(db)
await createNotification(db, { userId: user1, type: 'share_received', title: 'A' })
await createNotification(db, { userId: user2, type: 'share_received', title: 'B' })
await markAllAsRead(db, user1)
expect(await unreadCount(db, user1)).toBe(0)
expect(await unreadCount(db, user2)).toBe(1)
})
it('returns 0 when nothing to mark', async () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
const result = await markAllAsRead(db, userId)
expect(result.count).toBe(0)
})
})
describe('unreadCount', () => {
it('returns correct count', async () => {
const { db } = await createTestApp()
const userId = await insertUser(db)
expect(await unreadCount(db, userId)).toBe(0)
const n = await createNotification(db, { userId, type: 'share_received', title: 'A' })
await createNotification(db, { userId, type: 'share_received', title: 'B' })
expect(await unreadCount(db, userId)).toBe(2)
await markAsRead(db, userId, n.id)
expect(await unreadCount(db, userId)).toBe(1)
})
})