mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-30 17:50:07 +08:00
191ee0a07d
* refactor(server): rename routes/ to http/ (clean-arch step 1) The HTTP delivery layer was already split per-resource; align the directory name with the hono-cf-clean-arch standard. Pure mechanical move via git mv; updates the three server-side importers (app.ts, image-hosting-domain middleware, openapi/downloader). No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): add clean-arch backbone + migrate activity to a repo Introduce the composition root and dependency-injection seam: - usecases/ports.ts (barrel) + usecases/ports/<resource>.ts: framework-free port interfaces and DTOs - usecases/deps.ts: the Deps aggregate consumed via c.get('deps') - composition.ts: createDeps(platform) — the only place adapters are built - app.ts sets deps in request context after platform middleware First adapter: adapters/repos/activity.ts (ActivityRepo) replaces services/activity.ts. All 14 call sites rewired (routes use c.get('deps').activity.*; auth.ts and transitional services construct the repo from db). DTOs are now plain shapes, not drizzle $inferSelect. Behavior-preserving: typecheck + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract StorageRepo + migration tracker services/storage.ts -> adapters/repos/storage.ts (StorageRepo). All 14 callers rewired (http/middleware via c.get('deps').storages.*; transitional services via createStorageRepo(db)). Port DTO reuses the shared Storage contract with Date timestamps; the S3-credential 'Storage' type alias across 9 files now points at StorageRecord. Data-layer test moved next to the repo. Adds docs/clean-arch-migration.md as the living progress tracker. typecheck + lint + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract Profile/Announcement/Notification repos - profile -> ProfileRepo; the pure buildBreadcrumb moves to domain/breadcrumb.ts - announcement -> AnnouncementRepo; notification -> NotificationRepo - All callers rewired (routes via c.get('deps').*; auth.ts + services via create<X>Repo(db)); data-layer tests moved next to their repos - Test infra: createApp accepts an optional deps; createTestApp returns deps so tests fake a port by spying on testApp.deps.* (events SSE failure test no longer spies the service module) typecheck + lint + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract OrgRepo (authz) + InviteRepo - org -> OrgRepo (findPersonalOrg/getMemberRole/canReadOrg/canWriteToOrg/ isPersonalOrg); rewired across 4 routes + 2 auth middlewares + auth.ts - invite -> InviteRepo; rewired invite-codes route + auth.ts - data/unit tests for org & invite moved next to their repos typecheck + lint + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract BackgroundJobRepo (+ BackgroundJobError to ports) background-jobs -> adapters/repos/background-job.ts. The BackgroundJobError (caught by http for status mapping) moves to usecases/ports per the standard. Rewired: background-jobs route + events SSE (deps) + archive-processing (transitional repo). Unit + data tests relocated. typecheck + lint + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract QuotaRepo from effective-quota The foundational quota leaf. effective-quota.ts -> adapters/repos/quota.ts (QuotaRepo); the pure currentTrafficPeriod moves to domain/quota.ts; DTOs (EffectiveQuota, CurrentStoragePlan) move to ports. Rewired 14 callers (http -> deps.quota; services/auth/entry-node/workers.scheduled -> createQuotaRepo). scheduled-worker test now mocks the adapter (createQuotaRepo) instead of the service module. typecheck + lint + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract TeamRepo + TeamInviteRepo team -> adapters/repos/team.ts (TeamRepo; composes QuotaRepo for quota totals); team-invite -> adapters/repos/team-invite.ts. teams-admin + teams routes use c.get('deps').{teams,teamInvites}. Data tests relocated. typecheck + lint + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * build(arch): enforce clean architecture via dependency-cruiser (ratchet) in CI Adds .dependency-cruiser.cjs with the full hono-cf-clean-arch rule set and wires pnpm lint:arch into CI. The drizzle-only-in-repos rule uses a shrinking MIGRATION_PENDING allowlist so it passes today while still enforcing every already-migrated layer; each future migration commit removes an entry. platform/ (Database driver type) and auth.ts are permanent named exceptions. Currently green: 222 modules / 926 deps, 0 violations. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): combine user + org-entitlements into UserAdminRepo Resolves the pre-existing user <-> org-entitlements import cycle by merging both into adapters/repos/user-admin.ts (UserAdminRepo); shared types (UserWithOrg, QuotaEntitlementItem, UserOperationFailure, entitlement inputs) move to ports. users + teams-admin routes use c.get('deps').userAdmin. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract SiteInvitationRepo site-invitations -> adapters/repos/site-invitations.ts. Route uses c.get('deps').siteInvitations; the email helper now receives siteName from the handler (http stays out of adapters); auth.ts uses the repo. Result-type unions moved to ports. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(cf): fix storages.cf-test seed after StorageRepo extraction cf-tests are excluded from typecheck; biome had pruned the transiently-unused createStorageRepo import during the storage migration. Restore the import and convert the platform.db seed calls. test:cf green (57 passed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(spec): introduce BDD-lite spec/ + spec<->test traceability lint Adds the standard's product-spec layer: - spec/*.feature (Gherkin, no Cucumber runner) — one per capability, scenarios tagged @<capability>/<slug> + layer; spec/README.md documents the convention - [spec: <id>] breadcrumbs on home tests - scripts/lint-spec.mjs + pnpm lint:spec (wired into CI): every scenario id must have a referencing test and every breadcrumb must match a scenario Specced: storages, announcements, notifications, invite-codes, site-invitations (41 scenarios, all traced). Specs grow per capability as the migration proceeds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract changelog + cf-custom-hostnames providers Establishes adapters/providers/. changelog (GitHub releases/CHANGELOG) and cf-custom-hostnames (CF for SaaS) move to adapters/providers/ behind ChangelogProvider / CfHostnamesProvider ports (CfConflictError -> ports). system + ihost-config routes use c.get('deps').{changelog,cfHostnames}. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): move db-transaction -> db/, path-template -> lib/ Two framework-free utilities leave services/ for their proper homes: db/transaction.ts (the drizzle batch/transaction helper) and lib/path-template.ts (object-key builder). Importers updated. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): migrate licensing subsystem drizzle to repos license-state -> adapters/repos/license-binding.ts (LicenseBindingRepo); instance-id + instance-info DB reads -> adapters/repos/instance.ts (InstanceRepo). licensing/ (has-feature, refresh, entitlement, instance-info) now uses the repos and imports no drizzle, so ^server/licensing leaves the dependency-cruiser ratchet. licensing-admin route uses c.get('deps').{licenseBinding,instance}; service callers construct the repos; instance-telemetry test mocks the adapter. typecheck + lint + lint:arch + 3807 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): move S3Service to adapters/gateways behind S3Gateway port Establishes adapters/gateways/ + deps.s3. S3Service -> adapters/gateways/s3.ts (implements S3Gateway; S3StorageCredentials -> ports). A thin services/s3.ts re-export shim keeps the http routes (objects/webdav/ihost/share-utils) and the 21 prototype-spy tests working unchanged until those routes migrate to deps.s3; s3-dependent services can now move to usecases using deps.s3. typecheck + lint + lint:arch + 3807 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): drain inline drizzle from me route (avatar -> ProfileRepo) ProfileRepo gains setAvatar; the /api/me avatar handlers use c.get('deps').profiles instead of inline user-table updates. 'me' leaves the dependency-cruiser ratchet. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): drain inline drizzle from quotas route (-> QuotaRepo.listOrgQuotaOverview) The admin quota-overview join moves into QuotaRepo; the route uses c.get('deps').quota. 'quotas' leaves the ratchet. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): SystemOptionsRepo drains auth-providers/system/email-config routes New adapters/repos/system-options.ts (key-value access to systemOptions) + deps.systemOptions. auth-providers, system, email-config routes drop inline drizzle and use c.get('deps').systemOptions; all three leave the ratchet. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): drain inline drizzle from teams route (logo -> TeamRepo.setLogo) TeamRepo gains setLogo; teams route uses c.get('deps').teams for logo set/clear and drops its dead db locals. 'teams' leaves the ratchet. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): drain inline drizzle from ihost-config (-> ImageHostingConfigRepo) New adapters/repos/image-hosting-config.ts + deps.imageHostingConfigs. The ihost-config route's custom-domain CRUD uses c.get('deps').imageHostingConfigs (cf-hostnames already via deps). 'ihost-config' leaves the ratchet. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): loadBindingState -> usecase, hasFeature/effectiveFeatures -> domain Finishes the feature-gate path: domain/licensing.ts (pure hasFeature/effectiveFeatures), usecases/licensing.ts (loadBindingState(deps) using LicenseBindingRepo + cert verify). licensing/has-feature.ts deleted. Rewired 10 callers (routes/middleware via c.get('deps'); services via createLicenseBindingRepo(db)). Tests retargeted to the new modules (domain + usecases licensing). typecheck + lint + lint:arch + 3807 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract StorageUsageRepo + storage-usage reservation usecase The quota-reservation crown dependency. adapters/repos/storage-usage.ts (StorageUsageRepo: rollbackReservations + reconcile); usecases/storage-usage.ts (reserveStorageUsage/withStorageUsageReservation/StorageUsageMutationContext taking {quota,storageUsage} deps); StorageQuotaExceededError -> ports. Rewired 9 callers (objects/webdav/ihost routes via c.get('deps'); matter/image-hosting/archive/purge/ save-to-drive via constructed repos). Unblocks the matter/image-hosting clusters. typecheck + lint + lint:arch + 3807 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): migrate 5 leaf service clusters to clean-arch (parallel wave) Extracted 7 services via parallel agents on file-disjoint components: - instance-telemetry -> usecases/instance-telemetry (reuses instance + systemOptions ports) - image-upload -> adapters/gateways/image-upload (ImageUpload port, deps.imageUpload) - archive-jobs -> adapters/gateways/archive-jobs (ArchiveJobsGateway, deps.archiveJobs) - zip-compress + zip-extract -> adapters/gateways/zip + adapters/repos/zip (ZipGateway + ZipPlanRepo) - object-upload-sessions -> adapters/repos/object-upload-session (ObjectUploadSessionRepo) - purge -> usecases/purge (pure usecase over existing s3/storages/storageUsage) Routes (objects/teams/me/internal/background-jobs) now reach these via c.get('deps'); entry files + workers build deps via createDeps(platform). Barrels wired by hand. typecheck + lint:arch (240 modules) + 3810 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(spec): add quotas/profile/licensing feature specs + traceability 29 new scenarios traced to existing integration tests via [spec: id] breadcrumbs. lint:spec: 70 scenarios, all covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): migrate auth/webdav/cloud/branding/image-hosting clusters (parallel wave 2) 17 services extracted via 5 parallel agents on file-disjoint components: - auth-account: email->EmailGateway, share-notification->ShareNotificationRepo, member-count->MemberCountRepo, captcha->domain+usecase, signup-mode/team-count->usecases - webdav-middleware: api-keys/download-tokens gateways, webdav-state/webdav-path repos, webdav-xml->domain (pure) - cloud: licensing-cloud->LicensingCloudGateway, cloud-store/cloud-traffic-report/ remote-download-usage repos (cloud-traffic-metering + licensing-refresh-runner folded in) - branding: pure usecase over existing deps (no new port) - image-hosting: ImageHostingRepo 12 new deps fields wired by hand. WebDavMatterRow DTO moved into the webdav-path port (was importing services/matter, which cycled through the ports barrel); domain WebDavMatter dirtype widened to number|null to match the nullable column. Ratchet shrunk: ihost.ts + middleware/image-hosting-domain.ts no longer touch drizzle. services/ now 26->9 (matter crown). typecheck + lint:arch (261 modules, no cycles) + 3810 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(spec): add users/audit/teams/avatar/background-jobs/events/health specs 64 new scenarios traced to existing integration tests. lint:spec: 133 scenarios, all covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): migrate share/save-to-drive/archive-processing/trash-retention (parallel wave 3) - share -> ShareRepo (+ domain/share, transitional ShareMatterRow DTO); shares.ts now holds ZERO drizzle (dropped from the ratchet) - save-to-drive -> pure usecase over deps (s3/storages/storageUsage/quota/activity/share) - archive-processing -> usecase + ArchiveTargetFolderRepo (archive-jobs gateway self-assembles its deps subset from platform to avoid a composition cycle) - trash-retention -> pure usecase purge gains deps.share for share cascade-delete. 2 new deps fields wired. services/ now 9->5 (matter, matter-name-conflict, downloads, s3 shim, site-public-origin remain). typecheck + lint:arch (265 modules, no cycles) + 3810 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(spec): add branding/email-config/auth-providers/system/image-hosting/webdav/quota-store specs 128 new scenarios traced to existing integration tests. lint:spec: 261 scenarios, all covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): migrate the matter keystone + site-public-origin (wave 4) The crown. matter (644 lines, 17 exports) -> adapters/repos/matter.ts (MatterRepo: full drizzle CRUD + conflict resolution) + usecases/matter.ts (confirmUpload quota-guarded) + usecases/ports/matter.ts (Matter DTO + NameConflictError); matter-name-conflict -> domain. Fan-in of 10 rewired: objects/shares/trash routes now hold ZERO matter drizzle (via deps.matter); webdav + archive-processing/purge/save-to-drive/trash-retention usecases + zip/webdav-path repos repointed. site-public-origin -> domain (pure helpers) + usecase over deps.systemOptions. services/ now 5->2 (only downloads + the s3 shim remain). 1 new deps field (matter). typecheck + lint:arch (268 modules, no cycles) + 3810 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(spec): add redirect + download-tasks specs 44 new scenarios traced to existing integration tests. lint:spec: 305 scenarios, all covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): migrate downloads (remote-download) cluster (wave 5) downloads/{core,mappers,types} (915 lines) -> adapters/repos/{downloader,download-task} (DownloaderRepo + DownloadTaskRepo) + usecases/downloads.ts (assignment + task state machine + remote-download credit billing) + usecases/ports/downloads.ts (DownloadError + DTOs). Rewired download-tasks/downloaders/events routes + objects.ts upload handlers to c.get('deps'). 2 new deps fields. services/ now down to ONLY the s3 shim. typecheck + lint:arch (268 modules) + 3810 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(spec): add shares spec (32 scenarios) lint:spec: 337 scenarios, all covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): delete the s3 shim — services/ is empty, clean-arch complete Routed all 20 S3 call-sites in http (objects/webdav routes + share-utils consumers shares/redirect/ihost/image-hosting-domain) onto c.get('deps').s3; webdav's no-c helpers take an S3Gateway param. Repointed 17 test files off the shim onto adapters/gateways/s3. Deleted server/services/s3.ts — server/services/ is now empty and gone. Ratchet: dropped ^server/services (fully migrated); no-circular now fully enforced with no path exemptions. MIGRATION_PENDING is down to 2 deliberately-deferred files (http/webdav.ts listDescendants, middleware/auth.ts session lookup). Also adds the objects spec (39 scenarios) -> 376 scenarios across 26 capabilities. Final gates: typecheck + lint:arch (267 modules, no cycles) + lint:spec (376) + lint + 3810 tests + 57 cf-tests all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): migrate the last 2 ratchet files — architecture fully locked webdav.ts + middleware/auth.ts were the last files touching drizzle outside repos. - WebDAV: listDescendants/PROPPATCH-touch/PUT-overwrite/COPY-rollback + Basic-Auth username check moved to MatterRepo.{listActiveDescendants,trashByIds,restoreActiveByIds,touch,applyUpload} + UserAdminRepo.{isBanned,matchesUsername}. webdav.ts now imports no drizzle. - Auth middleware: disabled-user (banned) check -> deps.userAdmin.isBanned. Ratchet (MIGRATION_PENDING) is now empty and removed. no-circular + drizzle-only-in-repos are fully enforced with zero exemptions; only platform/, test/, auth.ts remain as permanent named exceptions. New methods covered by existing real-D1 webdav/auth integration tests. typecheck + lint:arch (267 modules) + lint:spec (376) + lint + 3810 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(spec): spec the 4 remaining admin/auth capabilities Closes the spec gaps for capabilities that had routes+tests but no .feature: image-hosting-config (domain/CF custom-hostname admin), licensing-admin (cloud pairing/binding/refresh), teams-admin (team admin + entitlements), auth-username (username sign-up). 42 new scenarios traced to existing integration tests. lint:spec: 418 scenarios across 30 capabilities, all covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(matter): listActiveDescendants uses exact-prefix (SUBSTR) not LIKE Folder names can contain '_'/'%', which LIKE treats as wildcards and would over-match descendants in WebDAV recursive COPY/MOVE. Reuse the repo's existing descendantParentCondition (SUBSTR), consistent with getDescendants/cascadeParentPath. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): address review follow-ups (DTO dedupe, composition, dead locals) - Dedupe transitional DTOs: ShareMatterRow + WebDavMatterRow -> the canonical Matter port DTO (removes hand-copied duplicates + schema-drift risk; no cycle reintroduced). - composition.ts: hoist shared stateless instances (one s3/storages/systemOptions instead of constructing duplicates inline). - Remove the 21 dead 'const db = c.get(platform).db' locals -> biome warning-free. typecheck + lint:arch (267 modules) + lint:spec (418) + 3810 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): dissolve server/licensing into domain + usecases layers server/licensing/ was a feature-grouped dir outside the layer taxonomy — its 3 orchestration files imported adapters directly, escaping usecases-no-infrastructure. Now classified + enforced: - public-keys -> domain/license-keys (pure) - verify + cloud-event-token -> usecases/license-certificate (paseto/zod crypto helpers) - entitlement/instance-info/refresh -> deps-first usecases (license-entitlement, instance-info, license-refresh), using existing deps.{licenseBinding,instance,licensingCloud} 11 consumers rewired to deps; dead db param dropped from runLicensingRefresh. No barrel changes. server/licensing/ deleted — every server file now sits in an enforced layer (or a named exception: platform/test/auth.ts/lib/middleware). typecheck + lint:arch (266 modules) + lint:spec (418) + 3810 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
808 lines
35 KiB
TypeScript
808 lines
35 KiB
TypeScript
import { eq } from 'drizzle-orm'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { createInviteRepo } from './adapters/repos/invite.js'
|
|
import { createSiteInvitationRepo } from './adapters/repos/site-invitations.js'
|
|
import { createApp } from './app.js'
|
|
import { createAuth } from './auth.js'
|
|
import * as authSchema from './db/auth-schema.js'
|
|
import * as schema from './db/schema.js'
|
|
import { inviteCodes, siteInvitations } from './db/schema.js'
|
|
import { createTestApp, seedProLicense } from './test/setup.js'
|
|
|
|
type TestCtx = Awaited<ReturnType<typeof createTestApp>>
|
|
|
|
async function signUp(ctx: TestCtx, email: string, extra?: Record<string, unknown>) {
|
|
return ctx.app.request('/api/auth/sign-up/email', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: 'Test User', email, password: 'password123456', ...extra }),
|
|
})
|
|
}
|
|
|
|
async function expectPlanEntitlement(ctx: TestCtx, resourceType: 'storage' | 'traffic', bytes: number) {
|
|
const rows = await ctx.db.select().from(schema.orgQuotaEntitlements)
|
|
expect(rows).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
resourceType,
|
|
entitlementType: 'plan',
|
|
source: 'free_plan',
|
|
bytes,
|
|
status: 'active',
|
|
}),
|
|
]),
|
|
)
|
|
}
|
|
|
|
describe('registration gate — first user always allowed', () => {
|
|
it('first user can register when auth_signup_mode is closed', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'closed' })
|
|
const res = await signUp(ctx, 'first@example.com')
|
|
expect(res.status).toBe(200)
|
|
})
|
|
|
|
it('first user can register when auth_signup_mode is invite_only without a code', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' })
|
|
const res = await signUp(ctx, 'first@example.com')
|
|
expect(res.status).toBe(200)
|
|
})
|
|
|
|
it('first user is promoted to admin when auth_signup_mode is invite_only', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' })
|
|
const res = await signUp(ctx, 'first@example.com')
|
|
const body = (await res.json()) as { user: { role: string } }
|
|
expect(body.user.role).toBe('admin')
|
|
})
|
|
|
|
it('first user can register when auth_signup_mode is open', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'open' })
|
|
const res = await signUp(ctx, 'first@example.com')
|
|
expect(res.status).toBe(200)
|
|
})
|
|
})
|
|
|
|
describe('registration gate — open mode', () => {
|
|
it('second user can register when auth_signup_mode is not set (defaults to open)', async () => {
|
|
const ctx = await createTestApp()
|
|
await signUp(ctx, 'first@example.com')
|
|
const res = await signUp(ctx, 'second@example.com')
|
|
expect(res.status).toBe(200)
|
|
})
|
|
|
|
it('second user can register when auth_signup_mode is explicitly open and instance has Pro license', async () => {
|
|
const ctx = await createTestApp()
|
|
await seedProLicense(ctx.db)
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'open' })
|
|
await signUp(ctx, 'first@example.com')
|
|
const res = await signUp(ctx, 'second@example.com')
|
|
expect(res.status).toBe(200)
|
|
})
|
|
|
|
it('second user is rejected when auth_signup_mode is explicitly open but instance has no Pro license', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'open' })
|
|
await signUp(ctx, 'first@example.com')
|
|
const res = await signUp(ctx, 'second@example.com')
|
|
expect(res.status).toBe(422)
|
|
})
|
|
})
|
|
|
|
describe('registration gate — closed mode', () => {
|
|
it('second user is rejected when auth_signup_mode is closed', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'closed' })
|
|
await signUp(ctx, 'first@example.com')
|
|
const res = await signUp(ctx, 'blocked@example.com')
|
|
expect(res.status).not.toBe(200)
|
|
})
|
|
|
|
it('third user is also rejected when auth_signup_mode is closed', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'closed' })
|
|
await signUp(ctx, 'first@example.com')
|
|
await signUp(ctx, 'second@example.com') // blocked
|
|
const res = await signUp(ctx, 'third@example.com')
|
|
expect(res.status).not.toBe(200)
|
|
})
|
|
|
|
it('closed mode returns 422 status code', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'closed' })
|
|
await signUp(ctx, 'first@example.com')
|
|
const res = await signUp(ctx, 'blocked@example.com')
|
|
expect(res.status).toBe(422)
|
|
})
|
|
|
|
it('second user can register with a valid site invitation token', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'closed' })
|
|
await signUp(ctx, 'first@example.com')
|
|
const [admin] = await ctx.db
|
|
.select({ id: authSchema.user.id })
|
|
.from(authSchema.user)
|
|
.where(eq(authSchema.user.email, 'first@example.com'))
|
|
.limit(1)
|
|
const invitation = await createSiteInvitationRepo(ctx.db).createSiteInvitation(admin.id, 'invited@example.com')
|
|
|
|
const res = await signUp(ctx, 'invited@example.com', { siteInvitationToken: invitation.token })
|
|
|
|
expect(res.status).toBe(200)
|
|
})
|
|
|
|
it('accepts the site invitation after successful registration', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'closed' })
|
|
await signUp(ctx, 'first@example.com')
|
|
const [admin] = await ctx.db
|
|
.select({ id: authSchema.user.id })
|
|
.from(authSchema.user)
|
|
.where(eq(authSchema.user.email, 'first@example.com'))
|
|
.limit(1)
|
|
const invitation = await createSiteInvitationRepo(ctx.db).createSiteInvitation(admin.id, 'invited@example.com')
|
|
|
|
const res = await signUp(ctx, 'invited@example.com', { siteInvitationToken: invitation.token })
|
|
const body = (await res.json()) as { user: { id: string } }
|
|
const [row] = await ctx.db
|
|
.select()
|
|
.from(siteInvitations)
|
|
.where(eq(siteInvitations.token, invitation.token))
|
|
.limit(1)
|
|
|
|
expect(row.acceptedBy).toBe(body.user.id)
|
|
expect(row.acceptedAt).not.toBeNull()
|
|
})
|
|
|
|
it('rejects site invitation token when email does not match', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'closed' })
|
|
await signUp(ctx, 'first@example.com')
|
|
const [admin] = await ctx.db
|
|
.select({ id: authSchema.user.id })
|
|
.from(authSchema.user)
|
|
.where(eq(authSchema.user.email, 'first@example.com'))
|
|
.limit(1)
|
|
const invitation = await createSiteInvitationRepo(ctx.db).createSiteInvitation(admin.id, 'invited@example.com')
|
|
|
|
const res = await signUp(ctx, 'other@example.com', { siteInvitationToken: invitation.token })
|
|
|
|
expect(res.status).toBe(422)
|
|
})
|
|
})
|
|
|
|
describe('registration gate — invite_only mode', () => {
|
|
it('second user is rejected when no invite code is provided', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' })
|
|
await signUp(ctx, 'first@example.com')
|
|
const res = await signUp(ctx, 'noinvite@example.com')
|
|
expect(res.status).not.toBe(200)
|
|
})
|
|
|
|
it('invite_only mode with no code returns 422 status code', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' })
|
|
await signUp(ctx, 'first@example.com')
|
|
const res = await signUp(ctx, 'noinvite@example.com')
|
|
expect(res.status).toBe(422)
|
|
})
|
|
|
|
it('second user is rejected when an invalid invite code is provided', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' })
|
|
await signUp(ctx, 'first@example.com')
|
|
const res = await signUp(ctx, 'badinvite@example.com', { inviteCode: 'BADCODE1' })
|
|
expect(res.status).not.toBe(200)
|
|
})
|
|
|
|
it('second user is rejected when invite code is expired', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' })
|
|
await signUp(ctx, 'first@example.com')
|
|
const pastDate = new Date(Date.now() - 1000)
|
|
const [codeRow] = await createInviteRepo(ctx.db).generate('admin-1', 1, pastDate)
|
|
const res = await signUp(ctx, 'expired@example.com', { inviteCode: codeRow.code })
|
|
expect(res.status).not.toBe(200)
|
|
})
|
|
|
|
it('second user registers successfully with a valid invite code', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' })
|
|
await signUp(ctx, 'first@example.com')
|
|
const [codeRow] = await createInviteRepo(ctx.db).generate('admin-1', 1)
|
|
const res = await signUp(ctx, 'invited@example.com', { inviteCode: codeRow.code })
|
|
expect(res.status).toBe(200)
|
|
})
|
|
|
|
it('invite code usedBy is set to the new user ID after successful registration', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' })
|
|
await signUp(ctx, 'first@example.com')
|
|
const [codeRow] = await createInviteRepo(ctx.db).generate('admin-1', 1)
|
|
const res = await signUp(ctx, 'invited2@example.com', { inviteCode: codeRow.code })
|
|
const body = (await res.json()) as { user: { id: string } }
|
|
const [row] = await ctx.db.select().from(inviteCodes).where(eq(inviteCodes.code, codeRow.code))
|
|
expect(row.usedBy).toBe(body.user.id)
|
|
expect(row.usedAt).not.toBeNull()
|
|
})
|
|
|
|
it('same invite code cannot be used twice', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' })
|
|
await signUp(ctx, 'first@example.com')
|
|
const [codeRow] = await createInviteRepo(ctx.db).generate('admin-1', 1)
|
|
await signUp(ctx, 'user1@example.com', { inviteCode: codeRow.code })
|
|
const res = await signUp(ctx, 'user2@example.com', { inviteCode: codeRow.code })
|
|
expect(res.status).not.toBe(200)
|
|
})
|
|
})
|
|
|
|
describe('getSignupMode — via auth_signup_mode system option', () => {
|
|
it('unknown value in auth_signup_mode falls back to open (second user succeeds)', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'unknown_value' })
|
|
await signUp(ctx, 'first@example.com')
|
|
const res = await signUp(ctx, 'second@example.com')
|
|
expect(res.status).toBe(200)
|
|
})
|
|
|
|
it('empty string in auth_signup_mode falls back to open', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: '' })
|
|
await signUp(ctx, 'first@example.com')
|
|
const res = await signUp(ctx, 'second@example.com')
|
|
expect(res.status).toBe(200)
|
|
})
|
|
})
|
|
|
|
describe('isEmailConfigured — via emailVerification conditional', () => {
|
|
it('createAuth succeeds when email_provider is not configured', async () => {
|
|
const ctx = await createTestApp()
|
|
expect(ctx.auth).toBeTruthy()
|
|
})
|
|
|
|
it('sign-up succeeds without email_provider configured', async () => {
|
|
const ctx = await createTestApp()
|
|
const res = await signUp(ctx, 'user@example.com')
|
|
expect(res.status).toBe(200)
|
|
})
|
|
|
|
it('send-verification-email is a no-op (returns early) when email_provider is not configured', async () => {
|
|
const ctx = await createTestApp()
|
|
// Sign up first so the user exists
|
|
await signUp(ctx, 'verify@example.com')
|
|
// Trigger the sendVerificationEmail callback — should not throw even without email config
|
|
const res = await ctx.app.request('/api/auth/send-verification-email', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: 'verify@example.com' }),
|
|
})
|
|
// The endpoint returns 200 regardless; the callback silently returns early
|
|
expect(res.status).toBe(200)
|
|
})
|
|
})
|
|
|
|
describe('buildVerificationEmailHtml — via send-verification-email with email_provider configured', () => {
|
|
it('send-verification-email triggers email send when email_provider is configured', async () => {
|
|
const { vi } = await import('vitest')
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values([
|
|
{ key: 'email_enabled', value: 'true' },
|
|
{ key: 'email_provider', value: 'http' },
|
|
{ key: 'email_from', value: 'no-reply@example.com' },
|
|
{ key: 'email_http_url', value: 'https://api.mail.example.com/send' },
|
|
{ key: 'email_http_api_key', value: 'my-api-key' },
|
|
])
|
|
|
|
await signUp(ctx, 'withmail@example.com')
|
|
const res = await ctx.app.request('/api/auth/send-verification-email', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: 'withmail@example.com' }),
|
|
})
|
|
expect(res.status).toBe(200)
|
|
// The email should have been sent via the HTTP provider
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://api.mail.example.com/send',
|
|
expect.objectContaining({ method: 'POST' }),
|
|
)
|
|
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
it('verification email HTML contains the verification URL', async () => {
|
|
const { vi } = await import('vitest')
|
|
let capturedHtml = ''
|
|
const fetchMock = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
|
|
const body = JSON.parse(init?.body as string)
|
|
capturedHtml = body.html
|
|
return { ok: true }
|
|
})
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values([
|
|
{ key: 'email_enabled', value: 'true' },
|
|
{ key: 'email_provider', value: 'http' },
|
|
{ key: 'email_from', value: 'no-reply@example.com' },
|
|
{ key: 'email_http_url', value: 'https://api.mail.example.com/send' },
|
|
{ key: 'email_http_api_key', value: 'my-api-key' },
|
|
])
|
|
|
|
await signUp(ctx, 'htmltest@example.com')
|
|
await ctx.app.request('/api/auth/send-verification-email', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: 'htmltest@example.com' }),
|
|
})
|
|
|
|
expect(capturedHtml).toContain('verify-email')
|
|
expect(capturedHtml).toContain('href=')
|
|
|
|
vi.unstubAllGlobals()
|
|
})
|
|
})
|
|
|
|
describe('loadProviderConfigs — createAuth with OIDC provider pre-configured', () => {
|
|
it('createAuth succeeds when a valid enabled OIDC provider config is present', async () => {
|
|
const ctx = await createTestApp()
|
|
const oidcConfig = JSON.stringify({
|
|
providerId: 'my-oidc',
|
|
type: 'oidc',
|
|
clientId: 'client-id',
|
|
clientSecret: 'client-secret',
|
|
enabled: true,
|
|
discoveryUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
scopes: ['openid', 'email'],
|
|
})
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'oauth_provider_my-oidc', value: oidcConfig })
|
|
const auth = await createAuth(ctx.db, 'test-secret', 'http://localhost:3000')
|
|
expect(auth).toBeTruthy()
|
|
})
|
|
|
|
it('createAuth succeeds when a disabled OIDC provider config is present', async () => {
|
|
const ctx = await createTestApp()
|
|
const oidcConfig = JSON.stringify({
|
|
providerId: 'disabled-oidc',
|
|
type: 'oidc',
|
|
clientId: 'client-id',
|
|
clientSecret: 'client-secret',
|
|
enabled: false,
|
|
discoveryUrl: 'https://auth.example.com/.well-known/openid-configuration',
|
|
})
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'oauth_provider_disabled-oidc', value: oidcConfig })
|
|
const auth = await createAuth(ctx.db, 'test-secret', 'http://localhost:3000')
|
|
expect(auth).toBeTruthy()
|
|
})
|
|
|
|
it('createAuth succeeds when a malformed (non-JSON) provider config row is present', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'oauth_provider_bad', value: 'not-valid-json' })
|
|
const auth = await createAuth(ctx.db, 'test-secret', 'http://localhost:3000')
|
|
expect(auth).toBeTruthy()
|
|
})
|
|
})
|
|
|
|
describe('loadProviderConfigs — builtin social provider resolution', () => {
|
|
it('social sign-in with an unconfigured provider returns non-200 (provider not registered)', async () => {
|
|
const ctx = await createTestApp()
|
|
// With no config in DB the provider is not registered with better-auth.
|
|
const res = await ctx.app.request('/api/auth/sign-in/social', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ provider: 'github', callbackURL: 'http://localhost:3000/callback' }),
|
|
})
|
|
expect(res.status).not.toBe(200)
|
|
})
|
|
|
|
it('social sign-in with a configured and enabled builtin provider returns a redirect', async () => {
|
|
const ctx = await createTestApp()
|
|
const builtinConfig = JSON.stringify({
|
|
providerId: 'github',
|
|
type: 'builtin',
|
|
clientId: 'gh-client',
|
|
clientSecret: 'gh-secret',
|
|
enabled: true,
|
|
})
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'oauth_provider_github', value: builtinConfig })
|
|
// Provider configs are snapshotted when the auth instance is created —
|
|
// build a fresh auth/app that sees the seeded config.
|
|
const auth = await createAuth(ctx.platform, 'test-secret', 'http://localhost:3000')
|
|
const app = createApp(ctx.platform, auth)
|
|
const res = await app.request('/api/auth/sign-in/social', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ provider: 'github', callbackURL: 'http://localhost:3000/callback' }),
|
|
})
|
|
// With a valid enabled provider, better-auth returns a redirect (302) to the OAuth provider
|
|
expect([200, 302]).toContain(res.status)
|
|
})
|
|
|
|
it('createAuth runs exactly one DB query during init (no per-provider I/O)', async () => {
|
|
const ctx = await createTestApp()
|
|
let selectCalls = 0
|
|
const countingDb = new Proxy(ctx.db, {
|
|
get(target, prop, receiver) {
|
|
if (prop === 'select') selectCalls++
|
|
const val = Reflect.get(target, prop, receiver)
|
|
return typeof val === 'function' ? val.bind(target) : val
|
|
},
|
|
})
|
|
await createAuth(countingDb as typeof ctx.db, 'test-secret', 'http://localhost:3000')
|
|
expect(selectCalls).toBe(1)
|
|
})
|
|
|
|
it('createAuth resolves better-auth $context before returning', async () => {
|
|
// A cached auth instance must never carry a pending init promise: on
|
|
// Cloudflare Workers a promise created in one request never settles when
|
|
// awaited from another, hanging every auth call in the isolate.
|
|
const ctx = await createTestApp()
|
|
let settled = false
|
|
void ctx.auth.$context.then(() => {
|
|
settled = true
|
|
})
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
expect(settled).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('session hook — activeOrganizationId is set on sign-in after sign-up', () => {
|
|
it('sign-in after sign-up succeeds and returns a session cookie', async () => {
|
|
const ctx = await createTestApp()
|
|
await signUp(ctx, 'session-user@example.com')
|
|
const res = await ctx.app.request('/api/auth/sign-in/email', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: 'session-user@example.com', password: 'password123456' }),
|
|
})
|
|
expect(res.status).toBe(200)
|
|
expect(res.headers.get('set-cookie')).toBeTruthy()
|
|
})
|
|
|
|
it('session record in DB has activeOrganizationId set after sign-in', async () => {
|
|
const ctx = await createTestApp()
|
|
await signUp(ctx, 'org-session@example.com')
|
|
await ctx.app.request('/api/auth/sign-in/email', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: 'org-session@example.com', password: 'password123456' }),
|
|
})
|
|
const sessions = await ctx.db.select().from(authSchema.session)
|
|
// At least one session should have activeOrganizationId set
|
|
const withOrg = sessions.filter((s) => s.activeOrganizationId != null)
|
|
expect(withOrg.length).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
describe('createPersonalOrg — org name and quota edge cases', () => {
|
|
it('sign-up with empty name creates org with fallback name "Personal Space"', async () => {
|
|
const ctx = await createTestApp()
|
|
const res = await ctx.app.request('/api/auth/sign-up/email', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: '', email: 'noname@example.com', password: 'password123456' }),
|
|
})
|
|
// sign-up should succeed
|
|
expect(res.status).toBe(200)
|
|
})
|
|
|
|
it('sign-up uses a custom finite default_org_quota when set', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'default_org_quota', value: '524288000' })
|
|
const res = await signUp(ctx, 'quota-user@example.com')
|
|
expect(res.status).toBe(200)
|
|
})
|
|
|
|
it('team creation uses default_team_quota while personal orgs keep default_org_quota', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'default_org_quota', value: '1000000' })
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'default_team_quota', value: '5000000' })
|
|
|
|
const res = await signUp(ctx, 'team-quota@example.com')
|
|
expect(res.status).toBe(200)
|
|
const cookies = res.headers.getSetCookie().join('; ')
|
|
|
|
const createOrg = await ctx.app.request('/api/auth/organization/create', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Cookie: cookies, Origin: 'http://localhost:3000' },
|
|
body: JSON.stringify({ name: 'My Team', slug: 'my-team', metadata: { type: 'team' } }),
|
|
})
|
|
expect(createOrg.status).toBe(200)
|
|
const org = (await createOrg.json()) as { id: string }
|
|
|
|
const rows = await ctx.db.select().from(schema.orgQuotaEntitlements)
|
|
const teamStorage = rows.find((row) => row.orgId === org.id && row.resourceType === 'storage')
|
|
expect(teamStorage?.bytes).toBe(5000000)
|
|
const personalStorage = rows.find((row) => row.orgId !== org.id && row.resourceType === 'storage')
|
|
expect(personalStorage?.bytes).toBe(1000000)
|
|
})
|
|
|
|
it('team creation falls back to default_org_quota when default_team_quota is unset', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'default_org_quota', value: '2000000' })
|
|
|
|
const res = await signUp(ctx, 'team-quota-fallback@example.com')
|
|
expect(res.status).toBe(200)
|
|
const cookies = res.headers.getSetCookie().join('; ')
|
|
|
|
const createOrg = await ctx.app.request('/api/auth/organization/create', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Cookie: cookies, Origin: 'http://localhost:3000' },
|
|
body: JSON.stringify({ name: 'Fallback Team', slug: 'fallback-team', metadata: { type: 'team' } }),
|
|
})
|
|
expect(createOrg.status).toBe(200)
|
|
const org = (await createOrg.json()) as { id: string }
|
|
|
|
const rows = await ctx.db.select().from(schema.orgQuotaEntitlements)
|
|
const teamStorage = rows.find((row) => row.orgId === org.id && row.resourceType === 'storage')
|
|
expect(teamStorage?.bytes).toBe(2000000)
|
|
})
|
|
|
|
it('sign-up falls back to DEFAULT_ORG_QUOTA when default_org_quota is non-numeric', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'default_org_quota', value: 'not-a-number' })
|
|
const res = await signUp(ctx, 'quota-fallback@example.com')
|
|
expect(res.status).toBe(200)
|
|
})
|
|
|
|
it('sign-up with default_org_quota set to zero falls back to DEFAULT_ORG_QUOTA', async () => {
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values({ key: 'default_org_quota', value: '0' })
|
|
const res = await signUp(ctx, 'zero-quota@example.com')
|
|
expect(res.status).toBe(200)
|
|
const quotas = await ctx.db.select().from(schema.orgQuotas)
|
|
expect(quotas).toHaveLength(1)
|
|
expect(quotas[0].quota).toBe(0)
|
|
expect(quotas[0].trafficQuota).toBe(0)
|
|
expect(quotas[0].trafficUsed).toBe(0)
|
|
expect(quotas[0].trafficPeriod).toMatch(/^\d{4}-\d{2}$/)
|
|
await expectPlanEntitlement(ctx, 'storage', 10485760)
|
|
await expectPlanEntitlement(ctx, 'traffic', 0)
|
|
})
|
|
})
|
|
|
|
describe('sendInvitationEmail — buildInvitationEmailHtml via invite-member with email_provider configured', () => {
|
|
const emailProviderOptions = [
|
|
{ key: 'email_enabled', value: 'true' },
|
|
{ key: 'email_provider', value: 'http' },
|
|
{ key: 'email_from', value: 'no-reply@example.com' },
|
|
{ key: 'email_http_url', value: 'https://api.mail.example.com/send' },
|
|
{ key: 'email_http_api_key', value: 'my-api-key' },
|
|
]
|
|
|
|
async function setupOwnerAndOrg(ctx: TestCtx, email: string) {
|
|
const signUpRes = await signUp(ctx, email)
|
|
const cookie = signUpRes.headers.getSetCookie().join('; ')
|
|
const body = (await signUpRes.json()) as { user: { id: string } }
|
|
const orgs = await ctx.db.select().from(authSchema.organization)
|
|
const orgId = orgs.find((o) => o.slug === `personal-${body.user.id}`)?.id ?? ''
|
|
return { cookie, orgId }
|
|
}
|
|
|
|
it('invitation email is sent when email_provider is configured', async () => {
|
|
const { vi } = await import('vitest')
|
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values(emailProviderOptions)
|
|
const { cookie, orgId } = await setupOwnerAndOrg(ctx, 'inviter@example.com')
|
|
|
|
const res = await ctx.app.request('/api/auth/organization/invite-member', {
|
|
method: 'POST',
|
|
// Cookie-bearing requests must carry an Origin, like real browser requests
|
|
headers: { 'Content-Type': 'application/json', Cookie: cookie, Origin: 'http://localhost:3000' },
|
|
body: JSON.stringify({ email: 'invitee@example.com', role: 'member', organizationId: orgId }),
|
|
})
|
|
expect(res.status).toBe(200)
|
|
expect(fetchMock).toHaveBeenCalled()
|
|
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
it('invitation email HTML contains accept-invitation link and role', async () => {
|
|
const { vi } = await import('vitest')
|
|
let capturedHtml = ''
|
|
const fetchMock = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => {
|
|
const b = JSON.parse(init?.body as string)
|
|
capturedHtml = b.html
|
|
return { ok: true }
|
|
})
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
const ctx = await createTestApp()
|
|
await ctx.db.insert(schema.systemOptions).values(emailProviderOptions)
|
|
const { cookie, orgId } = await setupOwnerAndOrg(ctx, 'orgowner@example.com')
|
|
|
|
await ctx.app.request('/api/auth/organization/invite-member', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Cookie: cookie, Origin: 'http://localhost:3000' },
|
|
body: JSON.stringify({ email: 'newmember@example.com', role: 'member', organizationId: orgId }),
|
|
})
|
|
|
|
expect(capturedHtml).toContain('accept-invitation')
|
|
expect(capturedHtml).toContain('member')
|
|
|
|
vi.unstubAllGlobals()
|
|
})
|
|
})
|
|
|
|
describe('email sign-up — username is required', () => {
|
|
it('email sign-up with username keeps the provided username', async () => {
|
|
const ctx = await createTestApp()
|
|
const res = await signUp(ctx, 'alice@example.com', { username: 'myalias' })
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as { user: { id: string } }
|
|
const [row] = await ctx.db
|
|
.select({ username: authSchema.user.username })
|
|
.from(authSchema.user)
|
|
.where(eq(authSchema.user.id, body.user.id))
|
|
expect(row.username).toBe('myalias')
|
|
})
|
|
})
|
|
|
|
// OAuth users are created by better-auth's internal adapter without a username.
|
|
// The before hook generates one from preferred_username/login or email prefix.
|
|
// We simulate this by calling sign-up without username (bypasses frontend validation).
|
|
describe('OAuth username generation — before hook', () => {
|
|
it('generates username from email prefix when no username provided', async () => {
|
|
const ctx = await createTestApp()
|
|
const res = await signUp(ctx, 'johndoe@example.com')
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as { user: { id: string } }
|
|
const [row] = await ctx.db
|
|
.select({ username: authSchema.user.username })
|
|
.from(authSchema.user)
|
|
.where(eq(authSchema.user.id, body.user.id))
|
|
expect(row.username).toBe('johndoe')
|
|
})
|
|
|
|
it('sanitizes special characters from email prefix', async () => {
|
|
const ctx = await createTestApp()
|
|
const res = await signUp(ctx, 'john.doe+tag@example.com')
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as { user: { id: string } }
|
|
const [row] = await ctx.db
|
|
.select({ username: authSchema.user.username })
|
|
.from(authSchema.user)
|
|
.where(eq(authSchema.user.id, body.user.id))
|
|
expect(row.username).toMatch(/^[a-z0-9]+$/)
|
|
expect(row.username).not.toContain('.')
|
|
expect(row.username).not.toContain('+')
|
|
})
|
|
|
|
it('adds random suffix when email prefix is shorter than 3 chars', async () => {
|
|
const ctx = await createTestApp()
|
|
const res = await signUp(ctx, 'ab@example.com')
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as { user: { id: string } }
|
|
const [row] = await ctx.db
|
|
.select({ username: authSchema.user.username })
|
|
.from(authSchema.user)
|
|
.where(eq(authSchema.user.id, body.user.id))
|
|
expect(row.username).toMatch(/^ab-[a-z0-9]{6}$/)
|
|
})
|
|
|
|
it('adds random suffix when email prefix is a single char', async () => {
|
|
const ctx = await createTestApp()
|
|
const res = await signUp(ctx, 'x@example.com')
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as { user: { id: string } }
|
|
const [row] = await ctx.db
|
|
.select({ username: authSchema.user.username })
|
|
.from(authSchema.user)
|
|
.where(eq(authSchema.user.id, body.user.id))
|
|
expect(row.username).toMatch(/^x-[a-z0-9]{6}$/)
|
|
})
|
|
|
|
it('adds random suffix when email prefix collides with existing username', async () => {
|
|
const ctx = await createTestApp()
|
|
// First user takes "bob" via explicit username
|
|
await signUp(ctx, 'bob@example.com', { username: 'bob' })
|
|
// Second user without username — email prefix "bob" is taken, gets "bob-xxxxxx"
|
|
const res = await signUp(ctx, 'bob@other.com')
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as { user: { id: string } }
|
|
const [row] = await ctx.db
|
|
.select({ username: authSchema.user.username })
|
|
.from(authSchema.user)
|
|
.where(eq(authSchema.user.id, body.user.id))
|
|
expect(row.username).toMatch(/^bob-[a-z0-9]{6}$/)
|
|
})
|
|
|
|
it('sets displayUsername to the same value as username', async () => {
|
|
const ctx = await createTestApp()
|
|
const res = await signUp(ctx, 'carol@example.com')
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as { user: { id: string } }
|
|
const [row] = await ctx.db
|
|
.select({ username: authSchema.user.username, displayUsername: authSchema.user.displayUsername })
|
|
.from(authSchema.user)
|
|
.where(eq(authSchema.user.id, body.user.id))
|
|
expect(row.displayUsername).toBe(row.username)
|
|
})
|
|
|
|
it('uses email prefix directly when it is exactly 3 chars', async () => {
|
|
const ctx = await createTestApp()
|
|
const res = await signUp(ctx, 'abc@example.com')
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as { user: { id: string } }
|
|
const [row] = await ctx.db
|
|
.select({ username: authSchema.user.username })
|
|
.from(authSchema.user)
|
|
.where(eq(authSchema.user.id, body.user.id))
|
|
expect(row.username).toBe('abc')
|
|
})
|
|
|
|
it('truncates email prefix to 30 chars', async () => {
|
|
const ctx = await createTestApp()
|
|
const longPrefix = 'averylongemailprefixthatiswaytolong'
|
|
const res = await signUp(ctx, `${longPrefix}@example.com`)
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as { user: { id: string } }
|
|
const [row] = await ctx.db
|
|
.select({ username: authSchema.user.username })
|
|
.from(authSchema.user)
|
|
.where(eq(authSchema.user.id, body.user.id))
|
|
expect(row.username!.length).toBeLessThanOrEqual(30)
|
|
})
|
|
|
|
it('email prefix consisting entirely of special characters falls back to user-suffix', async () => {
|
|
const ctx = await createTestApp()
|
|
// The sign-up endpoint requires a valid email, so use a prefix that sanitizes to empty
|
|
// Unfortunately standard email formats require at least one alphanumeric char in local part,
|
|
// but we can test with an email whose local part has only non-alphanumeric chars stripped
|
|
// We simulate by directly inserting a user with null username then querying
|
|
// Instead, test with prefix "___" which sanitizes to "" (hyphens and underscores removed)
|
|
// Actually the sanitizer removes [^a-z0-9] so underscores are also removed.
|
|
// Use a numeric-looking prefix that won't conflict — verify fallback via the DB check
|
|
// The most we can test through the API is a prefix that becomes too short.
|
|
// A prefix like "a_b" becomes "ab" (2 chars < 3) → gets suffix
|
|
const res = await signUp(ctx, 'a_b@example.com')
|
|
expect(res.status).toBe(200)
|
|
const body = (await res.json()) as { user: { id: string } }
|
|
const [row] = await ctx.db
|
|
.select({ username: authSchema.user.username })
|
|
.from(authSchema.user)
|
|
.where(eq(authSchema.user.id, body.user.id))
|
|
// "a_b" sanitizes to "ab" (2 chars) → appends random suffix
|
|
expect(row.username).toMatch(/^ab-[a-z0-9]{6}$/)
|
|
})
|
|
})
|
|
|
|
describe('origin check — loopback and LAN origins are trusted without config', () => {
|
|
// better-auth only enforces the Origin check on requests that carry cookies,
|
|
// so attach a dummy cookie to make validateOrigin run.
|
|
async function signInWithOrigin(ctx: TestCtx, origin: string) {
|
|
return ctx.app.request('/api/auth/sign-in/email', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Origin: origin, Cookie: 'zp.dummy=1' },
|
|
body: JSON.stringify({ email: 'origin@example.com', password: 'password123456' }),
|
|
})
|
|
}
|
|
|
|
it.each([
|
|
'http://127.0.0.1:3000',
|
|
'http://192.168.1.50:3000',
|
|
'http://10.0.0.5:8080',
|
|
])('allows sign-in with Origin %s when TRUSTED_ORIGINS is not set', async (origin) => {
|
|
const ctx = await createTestApp()
|
|
await signUp(ctx, 'origin@example.com')
|
|
const res = await signInWithOrigin(ctx, origin)
|
|
expect(res.status).toBe(200)
|
|
})
|
|
|
|
it('still rejects sign-in from an unknown public origin', async () => {
|
|
const ctx = await createTestApp()
|
|
await signUp(ctx, 'origin@example.com')
|
|
const res = await signInWithOrigin(ctx, 'https://evil.example.com')
|
|
expect(res.status).toBe(403)
|
|
})
|
|
})
|