diff --git a/.github/workflows/backend-tests.yaml b/.github/workflows/backend-tests.yaml new file mode 100644 index 000000000..a3299295b --- /dev/null +++ b/.github/workflows/backend-tests.yaml @@ -0,0 +1,46 @@ +name: Backend Tests + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run backend tests with coverage + run: npm run test:backend -- --coverage + env: + CI: 'true' + + - name: Report coverage on PR + if: always() + uses: davelosert/vitest-coverage-report-action@v2 + with: + json-summary-path: src/backend/coverage/coverage-summary.json + json-final-path: src/backend/coverage/coverage-final.json + + - name: Upload coverage artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: backend-coverage + path: src/backend/coverage/ + retention-days: 14 diff --git a/extensions/devWatcher.ts b/extensions/devWatcher.ts index dd48e5a8a..76abac06c 100644 --- a/extensions/devWatcher.ts +++ b/extensions/devWatcher.ts @@ -1,11 +1,11 @@ +import { extension } from '@heyputer/backend/src/extensions'; +import { PuterService } from '@heyputer/backend/src/services/types.js'; +import { nativeImport } from '@heyputer/backend/src/util/nativeImport.js'; import { spawn, type ChildProcess } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { extension } from '@heyputer/backend/src/extensions'; -import { PuterService } from '@heyputer/backend/src/services/types.js'; -import { nativeImport } from '@heyputer/backend/src/util/nativeImport.js'; const requireFromHere = createRequire(__filename); const webpack = requireFromHere('webpack') as typeof import('webpack'); @@ -121,9 +121,7 @@ const defaultWebpackEntries: WebpackEntry[] = [ class DevWatcherService extends PuterService { #processes: Array<{ name: string; proc: ChildProcess }> = []; - #watchers: Array<{ - close: (handler: (err?: Error | null) => void) => void; - }> = []; + #watchers: ReturnType['watch']>[] = []; #started = false; #packageRoot = findPackageRoot(); @@ -159,7 +157,7 @@ class DevWatcherService extends PuterService { this.#watchers.map( (watcher) => new Promise((resolve) => { - watcher.close((err) => { + watcher?.close((err) => { if (err) { console.warn( '[devwatch] failed to close webpack watcher:', diff --git a/extensions/thumbnails.test.ts b/extensions/thumbnails.test.ts new file mode 100644 index 000000000..2b7c7237a --- /dev/null +++ b/extensions/thumbnails.test.ts @@ -0,0 +1,89 @@ +import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { + afterAll, + beforeAll, + describe, + expect, + it, +} from 'vitest'; +import { PuterServer } from '../src/backend/server.ts'; +import { setupTestServer } from '../src/backend/testUtil.ts'; +import { handleThumbnailCreated } from './thumbnails.ts'; + +// 1x1 transparent PNG — smallest valid image sharp will accept. +const TINY_PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + +const BUCKET = 'puter-local'; + +const streamToBuffer = async ( + body: { transformToByteArray: () => Promise } | undefined, +): Promise => { + if (!body) throw new Error('s3 GetObject returned no body'); + return Buffer.from(await body.transformToByteArray()); +}; + +describe('thumbnails extension — handleThumbnailCreated', () => { + let server: PuterServer; + + beforeAll(async () => { + server = await setupTestServer(); + }); + + afterAll(async () => { + await server.shutdown(); + }); + + it('uploads a valid data: URL thumbnail to S3 and rewrites event.url to an s3:// pointer', async () => { + const s3 = server.clients.s3.get(); + const event: Record = { + url: `data:image/png;base64,${TINY_PNG_BASE64}`, + }; + + await handleThumbnailCreated(event, { s3, bucketName: BUCKET }); + + expect(typeof event.url).toBe('string'); + const newUrl = event.url as string; + expect(newUrl.startsWith(`s3://${BUCKET}/`)).toBe(true); + + const key = newUrl.slice(`s3://${BUCKET}/`.length); + const obj = await s3.send( + new GetObjectCommand({ Bucket: BUCKET, Key: key }), + ); + expect(obj.ContentType).toBe('image/png'); + + const expected = Buffer.from(TINY_PNG_BASE64, 'base64'); + const actual = await streamToBuffer(obj.Body as never); + expect(actual.equals(expected)).toBe(true); + }); + + it('sets event.url to null when the data: URL does not decode to a valid image', async () => { + const s3 = server.clients.s3.get(); + const event: Record = { + url: `data:image/png;base64,${Buffer.from('not an image').toString('base64')}`, + }; + + await handleThumbnailCreated(event, { s3, bucketName: BUCKET }); + + expect(event.url).toBeNull(); + }); + + it('leaves event.url untouched when the URL is not a data: URL', async () => { + const s3 = server.clients.s3.get(); + const original = 'https://example.com/thumb.png'; + const event: Record = { url: original }; + + await handleThumbnailCreated(event, { s3, bucketName: BUCKET }); + + expect(event.url).toBe(original); + }); + + it('returns without writing to S3 when event.url is missing', async () => { + const s3 = server.clients.s3.get(); + const event: Record = {}; + + await handleThumbnailCreated(event, { s3, bucketName: BUCKET }); + + expect(event.url).toBeUndefined(); + }); +}); diff --git a/extensions/thumbnails.ts b/extensions/thumbnails.ts index 76c82911b..6174aff75 100644 --- a/extensions/thumbnails.ts +++ b/extensions/thumbnails.ts @@ -99,29 +99,39 @@ async function decodeAndValidateThumbnail( // Intercept data-URL thumbnails before they hit the DB: upload to S3 // and replace the URL with an s3:// pointer. +export async function handleThumbnailCreated( + event: Record, + deps: { s3: S3Client; bucketName: string }, +): Promise { + const url = event.url; + if (typeof url !== 'string' || !url.startsWith('data:')) return; + + const decoded = await decodeAndValidateThumbnail(url); + if (!decoded) { + event.url = null; + return; + } + + const key = crypto.randomUUID(); + event.url = `s3://${deps.bucketName}/${key}`; + + await deps.s3.send( + new PutObjectCommand({ + Bucket: deps.bucketName, + Key: key, + Body: decoded.data, + ContentType: decoded.mimeType, + }), + ); +} + extension.on( 'thumbnail.created', async (_key, event: Record) => { - const url = event.url; - if (typeof url !== 'string' || !url.startsWith('data:')) return; - - const decoded = await decodeAndValidateThumbnail(url); - if (!decoded) { - event.url = null; - return; - } - - const key = crypto.randomUUID(); - event.url = `s3://${thumbnailBucketName}/${key}`; - - await getClient().send( - new PutObjectCommand({ - Bucket: thumbnailBucketName, - Key: key, - Body: decoded.data, - ContentType: decoded.mimeType, - }), - ); + await handleThumbnailCreated(event, { + s3: getClient(), + bucketName: thumbnailBucketName, + }); }, ); diff --git a/package-lock.json b/package-lock.json index 53ef01078..94324a104 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "chalk": "^4.1.0", "clean-css": "^5.3.2", "dotenv": "^16.4.5", + "esbuild": "^0.28.0", "eslint": "^9.35.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.5", @@ -1845,6 +1846,278 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/linux-x64": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz", @@ -1861,6 +2134,159 @@ "node": ">=18" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -9947,6 +10373,65 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -18244,6 +18729,70 @@ "@esbuild/linux-x64": "0.25.11" } }, + "src/docs/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", + "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "src/docs/node_modules/@esbuild/android-arm": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz", + "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "src/docs/node_modules/@esbuild/android-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz", + "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "src/docs/node_modules/@esbuild/android-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz", + "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, "src/docs/node_modules/@esbuild/darwin-arm64": { "version": "0.25.11", "cpu": [ @@ -18258,6 +18807,326 @@ "node": ">=18" } }, + "src/docs/node_modules/@esbuild/darwin-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz", + "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "src/docs/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz", + "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "src/docs/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz", + "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "src/docs/node_modules/@esbuild/linux-arm": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz", + "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "src/docs/node_modules/@esbuild/linux-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz", + "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "src/docs/node_modules/@esbuild/linux-ia32": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz", + "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "src/docs/node_modules/@esbuild/linux-loong64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz", + "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "src/docs/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz", + "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "src/docs/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz", + "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "src/docs/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz", + "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "src/docs/node_modules/@esbuild/linux-s390x": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz", + "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "src/docs/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz", + "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "src/docs/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz", + "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "src/docs/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz", + "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "src/docs/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz", + "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "src/docs/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz", + "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "src/docs/node_modules/@esbuild/sunos-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", + "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "src/docs/node_modules/@esbuild/win32-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz", + "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "src/docs/node_modules/@esbuild/win32-ia32": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", + "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "src/docs/node_modules/@esbuild/win32-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz", + "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "src/docs/node_modules/agent-base": { "version": "7.1.4", "license": "MIT", diff --git a/package.json b/package.json index 3512aadd7..9ffeda796 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "chalk": "^4.1.0", "clean-css": "^5.3.2", "dotenv": "^16.4.5", + "esbuild": "^0.28.0", "eslint": "^9.35.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.5", @@ -43,7 +44,7 @@ "yaml": "^2.8.1" }, "scripts": { - "test": "vitest run", + "test:backend": "vitest run --config src/backend/vitest.config.ts ", "start=gui": "nodemon --exec \"node dev-server.js\" ", "start": "node --enable-source-maps -r ./dist/src/backend/telemetry.js ./dist/src/backend/index.js", "prestart": "npm run build:ts", diff --git a/src/backend/clients/EventClient.test.ts b/src/backend/clients/EventClient.test.ts new file mode 100644 index 000000000..b76bad727 --- /dev/null +++ b/src/backend/clients/EventClient.test.ts @@ -0,0 +1,203 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PuterServer } from '../server.ts'; +import { setupTestServer } from '../testUtil.ts'; +import type { EventClient } from './EventClient.js'; + +describe('EventClient', () => { + let server: PuterServer; + let target: EventClient; + + beforeAll(async () => { + server = await setupTestServer(); + target = server.clients.event as unknown as EventClient; + }); + + afterAll(async () => { + await server.shutdown(); + }); + + // Each test gets a fresh key so listeners registered by earlier tests + // never collide with later ones (the client has no public way to + // unsubscribe). + let key: string; + beforeEach(() => { + key = `test.${Math.random().toString(36).slice(2)}`; + }); + + describe('on / emit', () => { + it('invokes a listener registered for the exact key', () => { + const listener = vi.fn(); + target.on(key, listener); + target.emit(key, { hello: 'world' }, { source: 'test' }); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith( + key, + { hello: 'world' }, + { source: 'test' }, + ); + }); + + it('does not invoke listeners registered under a different key', () => { + const other = vi.fn(); + target.on(`${key}.other`, other); + target.emit(key, {}, {}); + expect(other).not.toHaveBeenCalled(); + }); + + it('invokes every listener registered for the same key', () => { + const a = vi.fn(); + const b = vi.fn(); + target.on(key, a); + target.on(key, b); + target.emit(key, {}, {}); + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); + }); + + it('does nothing when emitting a key with no listeners', () => { + // Just asserts that no exception is thrown. + expect(() => target.emit(`${key}.nobody-home`, {}, {})).not.toThrow(); + }); + + it('continues firing later listeners after one throws', () => { + const errSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const bad = vi.fn(() => { + throw new Error('boom'); + }); + const good = vi.fn(); + target.on(key, bad); + target.on(key, good); + target.emit(key, {}, {}); + expect(bad).toHaveBeenCalledTimes(1); + expect(good).toHaveBeenCalledTimes(1); + errSpy.mockRestore(); + }); + }); + + describe('wildcard subscriptions', () => { + it('matches every dot-extended descendant of a wildcard prefix', () => { + const listener = vi.fn(); + target.on(`${key}.*`, listener); + target.emit(`${key}.foo`, { v: 1 }, {}); + target.emit(`${key}.foo.bar`, { v: 2 }, {}); + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenNthCalledWith( + 1, + `${key}.foo`, + { v: 1 }, + {}, + ); + expect(listener).toHaveBeenNthCalledWith( + 2, + `${key}.foo.bar`, + { v: 2 }, + {}, + ); + }); + + it('fires both wildcard and exact-key subscribers for one emit', () => { + const wild = vi.fn(); + const exact = vi.fn(); + target.on(`${key}.*`, wild); + target.on(`${key}.thing`, exact); + target.emit(`${key}.thing`, {}, {}); + expect(wild).toHaveBeenCalledTimes(1); + expect(exact).toHaveBeenCalledTimes(1); + }); + + it('does not fire a wildcard listener for the prefix itself', () => { + const wild = vi.fn(); + target.on(`${key}.*`, wild); + target.emit(key, {}, {}); + expect(wild).not.toHaveBeenCalled(); + }); + + it('fires wildcards at every nesting level for a deep emit', () => { + const top = vi.fn(); + const mid = vi.fn(); + target.on(`${key}.*`, top); + target.on(`${key}.a.*`, mid); + target.emit(`${key}.a.b.c`, {}, {}); + expect(top).toHaveBeenCalledTimes(1); + expect(mid).toHaveBeenCalledTimes(1); + }); + }); + + describe('emitAndWait', () => { + it('awaits async listeners before resolving', async () => { + let resolved = false; + target.on(key, async () => { + await new Promise((r) => setTimeout(r, 10)); + resolved = true; + }); + await target.emitAndWait(key, {}, {}); + expect(resolved).toBe(true); + }); + + it('runs listeners sequentially so later ones see earlier mutations', async () => { + target.on(key, (_k, data) => { + (data as { steps: string[] }).steps.push('first'); + }); + target.on(key, async (_k, data) => { + await new Promise((r) => setTimeout(r, 5)); + (data as { steps: string[] }).steps.push('second'); + }); + const data = { steps: [] as string[] }; + await target.emitAndWait(key, data, {}); + expect(data.steps).toEqual(['first', 'second']); + }); + + it('continues the chain when a listener throws', async () => { + const errSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const after = vi.fn(); + target.on(key, () => { + throw new Error('boom'); + }); + target.on(key, after); + await target.emitAndWait(key, {}, {}); + expect(after).toHaveBeenCalledTimes(1); + errSpy.mockRestore(); + }); + + it('awaits wildcard listeners too', async () => { + let resolved = false; + target.on(`${key}.*`, async () => { + await new Promise((r) => setTimeout(r, 10)); + resolved = true; + }); + await target.emitAndWait(`${key}.child`, {}, {}); + expect(resolved).toBe(true); + }); + }); + + describe('lifecycle hooks', () => { + it('onServerStart emits a serverStart event', () => { + const listener = vi.fn(); + target.on('serverStart', listener); + target.onServerStart(); + expect(listener).toHaveBeenCalledWith('serverStart', {}, {}); + }); + + it('onServerPrepareShutdown emits a serverPrepareShutdown event', () => { + const listener = vi.fn(); + target.on('serverPrepareShutdown', listener); + target.onServerPrepareShutdown(); + expect(listener).toHaveBeenCalledWith( + 'serverPrepareShutdown', + {}, + {}, + ); + }); + + it('onServerShutdown emits a serverShutdown event', () => { + const listener = vi.fn(); + target.on('serverShutdown', listener); + target.onServerShutdown(); + expect(listener).toHaveBeenCalledWith('serverShutdown', {}, {}); + }); + }); +}); diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts index d94ca7d9d..cd8f45f4b 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.ts @@ -20,8 +20,8 @@ import { existsSync, readFileSync } from 'fs'; import { basename, extname, join, resolve } from 'path'; import { createContext, runInContext } from 'vm'; -import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient'; import type { IConfig } from '../../types'; +import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient'; const MIGRATIONS_DIR = resolve(__dirname, './migrations/sqlite'); @@ -76,6 +76,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [ [41, ['0045_user_oidc_providers.sql']], [42, ['0046_is-private-apps.sql']], [43, ['0047_app-url-updates.sql']], + [44, ['0048_old-app-names-unique-tuple.sql']], ]; export class SqliteDatabaseClient extends AbstractDatabaseClient { @@ -95,7 +96,9 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient { override async onServerStart(): Promise { const Database = (await import('better-sqlite3')).default; - const dbPath = this.config.database?.path ?? ':memory:'; + const dbPath = this.config.database?.inMemory + ? ':memory:' + : (this.config.database?.path ?? ':memory:'); const isNew = dbPath === ':memory:' || !existsSync(dbPath); this.db = new Database(dbPath); @@ -453,6 +456,13 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient { version: 43, check: () => this.hasColumn('apps', 'is_private'), }, + { + version: 44, + check: () => + this.hasRow( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'old_app_names' AND sql LIKE '%UNIQUE%app_uid%name%'", + ), + }, ]; let inferredUserVersion = 0; diff --git a/src/backend/clients/dynamodb/DDBClient.ts b/src/backend/clients/dynamodb/DDBClient.ts index 68f9935db..b3f38076f 100644 --- a/src/backend/clients/dynamodb/DDBClient.ts +++ b/src/backend/clients/dynamodb/DDBClient.ts @@ -36,33 +36,43 @@ import { UpdateCommand, } from '@aws-sdk/lib-dynamodb'; import { NodeHttpHandler } from '@smithy/node-http-handler'; +//@ts-expect-error - no types available for dynalite import dynalite from 'dynalite'; +import { randomUUID } from 'node:crypto'; import { once } from 'node:events'; import { Agent as httpsAgent } from 'node:https'; -import { PuterClient } from '../types'; -import type { IConfig, IDynamoConfig } from '../../types'; import { HttpError } from '../../core/http'; +import type { IConfig, IDynamoConfig } from '../../types'; +import { PuterClient } from '../types'; -const LOCAL_DYNAMO_PATH_KEY = ':memory:'; +const LOCAL_DYNAMO_MEMORY_PREFIX = ':memory:'; const localDynaliteEndpointPromises = new Map>(); const MAX_BATCH_WRITE_ITEMS = 25; const MAX_BATCH_WRITE_RETRIES = 8; const BATCH_WRITE_RETRY_BASE_MS = 25; -const getDynalitePathKey = (path?: string) => { - if (path === ':memory:') return LOCAL_DYNAMO_PATH_KEY; +// In-memory mode gives each DDBClient its own unique key, which keys +// into a fresh dynalite server. This is what test parallelism needs: +// two clients in the same Node process don't share state. Persistent +// paths still share by `path`, so prod-like reuse keeps working. +const getDynalitePathKey = (path?: string, inMemory?: boolean) => { + if (inMemory) return `${LOCAL_DYNAMO_MEMORY_PREFIX}#${randomUUID()}`; + if (path === ':memory:') + return `${LOCAL_DYNAMO_MEMORY_PREFIX}#${randomUUID()}`; return path || './volatile/runtime/puter-ddb'; }; +const isMemoryPathKey = (pathKey: string) => + pathKey.startsWith(LOCAL_DYNAMO_MEMORY_PREFIX); + const getOrCreateLocalDynaliteEndpoint = async (pathKey: string) => { let endpointPromise = localDynaliteEndpointPromises.get(pathKey); if (endpointPromise) return endpointPromise; endpointPromise = (async () => { - const dynaliteOptions = - pathKey === LOCAL_DYNAMO_PATH_KEY - ? { createTableMs: 0 } - : { createTableMs: 0, path: pathKey }; + const dynaliteOptions = isMemoryPathKey(pathKey) + ? { createTableMs: 0 } + : { createTableMs: 0, path: pathKey }; const dynaliteInstance = dynalite(dynaliteOptions); const dynaliteServer = dynaliteInstance.listen(0, '127.0.0.1'); @@ -107,10 +117,18 @@ export class DDBClient extends PuterClient { #documentClient: DynamoDBDocumentClient | null = null; #localInitPromise: Promise | null = null; #ddbConfig: IDynamoConfig; + // Resolved once at construction. In-memory mode generates a unique + // key per instance so parallel clients don't share state, but + // `recreateClient()` reuses this same key and so reuses the server. + #localPathKey: string; constructor(config: IConfig) { super(config); this.#ddbConfig = config.dynamo ?? {}; + this.#localPathKey = getDynalitePathKey( + this.#ddbConfig.path, + this.#ddbConfig.inMemory, + ); if (this.#ddbConfig.aws) { this.#bindAwsClient(); @@ -388,13 +406,8 @@ export class DDBClient extends PuterClient { ReturnConsumedCapacity: 'TOTAL', }); - try { - const client = await this.#getDocumentClient(); - return await client.send(command); - } catch (error) { - console.error('DDB Update Error', error); - throw error; - } + const client = await this.#getDocumentClient(); + return client.send(command); } async createTableIfNotExists( @@ -480,8 +493,9 @@ export class DDBClient extends PuterClient { } async #bindLocalClient() { - const pathKey = getDynalitePathKey(this.#ddbConfig.path); - const endpoint = await getOrCreateLocalDynaliteEndpoint(pathKey); + const endpoint = await getOrCreateLocalDynaliteEndpoint( + this.#localPathKey, + ); const ddbClient = new DynamoDBClient({ credentials: { diff --git a/src/backend/drivers/kv/KVStoreDriver.test.ts b/src/backend/drivers/kv/KVStoreDriver.test.ts new file mode 100644 index 000000000..e3eeac7ad --- /dev/null +++ b/src/backend/drivers/kv/KVStoreDriver.test.ts @@ -0,0 +1,799 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from 'vitest'; +import { Actor } from '../../core/actor.ts'; +import { runWithContext } from '../../core/context.ts'; +import { PuterServer } from '../../server.ts'; +import { setupTestServer } from '../../testUtil.ts'; +import { KV_COSTS } from './costs.ts'; +import type { KVStoreDriver } from './KVStoreDriver.ts'; + +describe('KVStoreDriver', () => { + let server: PuterServer; + let target: KVStoreDriver; + + beforeAll(async () => { + server = await setupTestServer(); + target = server.drivers.kvStore; + }); + + afterAll(async () => { + await server.shutdown(); + }); + + // Each test runs against a unique actor namespace so state from one test + // never leaks into another. Mirrors the pattern used by SystemKVStore.test. + let actor: Actor; + const makeActor = (overrides: Partial = {}): Actor => ({ + user: { + uuid: `test-user-${Math.random().toString(36).slice(2)}`, + id: 1, + username: 'test-user', + email: 'test@test.com', + email_confirmed: true, + }, + app: { uid: 'test-app', id: 1 }, + ...overrides, + }); + beforeEach(() => { + actor = makeActor(); + }); + const inCtx = (fn: () => T | Promise, withActor: Actor = actor) => + runWithContext({ actor: withActor }, fn); + + describe('get', () => { + it('returns the value previously stored under the same key', async () => { + const res = await inCtx(async () => { + await target.set({ key: 'k', value: 'v' }); + return target.get({ key: 'k' }); + }); + expect(res).toBe('v'); + }); + + it('returns null for a missing key', async () => { + const res = await inCtx(() => target.get({ key: 'absent' })); + expect(res).toBeNull(); + }); + + it('returns an array of values when called with an array of keys', async () => { + const res = await inCtx(async () => { + await target.set({ key: 'a', value: 1 }); + await target.set({ key: 'b', value: 2 }); + return target.get({ key: ['a', 'b', 'missing'] }); + }); + expect(res).toEqual([1, 2, null]); + }); + + it('returns [] for an empty array of keys without hitting the store', async () => { + const res = await inCtx(() => target.get({ key: [] })); + expect(res).toEqual([]); + }); + + it('coerces a non-string key to a string before lookup', async () => { + const res = await inCtx(async () => { + await target.set({ key: 123 as unknown as string, value: 'numeric' }); + return target.get({ key: '123' }); + }); + expect(res).toBe('numeric'); + }); + + it('rejects when key is undefined', async () => { + await expect( + inCtx(() => target.get({ key: undefined })), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects when key is null', async () => { + await expect( + inCtx(() => target.get({ key: null })), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects when any key in an array is empty', async () => { + await expect( + inCtx(() => target.get({ key: ['ok', ''] })), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + }); + + describe('set', () => { + it('returns true on success', async () => { + const res = await inCtx(() => target.set({ key: 'k', value: 'v' })); + expect(res).toBe(true); + }); + + it('overwrites a previously-set value', async () => { + const res = await inCtx(async () => { + await target.set({ key: 'k', value: 'first' }); + await target.set({ key: 'k', value: 'second' }); + return target.get({ key: 'k' }); + }); + expect(res).toBe('second'); + }); + + it('stores complex object values', async () => { + const value = { nested: { count: 1 }, items: [1, 2, 3] }; + const res = await inCtx(async () => { + await target.set({ key: 'obj', value }); + return target.get({ key: 'obj' }); + }); + expect(res).toEqual(value); + }); + + it('stores null as a real value (distinct from missing)', async () => { + const res = await inCtx(async () => { + await target.set({ key: 'nullable', value: null }); + return target.get({ key: 'nullable' }); + }); + expect(res).toBeNull(); + }); + + it('honours expireAt — past timestamps make the value invisible', async () => { + const past = Math.floor(Date.now() / 1000) - 10; + const res = await inCtx(async () => { + await target.set({ key: 'gone', value: 'soon', expireAt: past }); + return target.get({ key: 'gone' }); + }); + expect(res).toBeNull(); + }); + + it('rejects an empty key', async () => { + await expect( + inCtx(() => target.set({ key: '', value: 'v' })), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects a missing key', async () => { + await expect( + inCtx(() => target.set({ key: undefined, value: 'v' })), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects when value is undefined', async () => { + await expect( + inCtx(() => target.set({ key: 'k', value: undefined })), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + }); + + describe('batchPut', () => { + it('writes multiple items and they read back', async () => { + const res = await inCtx(async () => { + await target.batchPut({ + items: [ + { key: 'bp1', value: 'v1' }, + { key: 'bp2', value: 'v2' }, + { key: 'bp3', value: { nested: true } }, + ], + }); + return target.get({ key: ['bp1', 'bp2', 'bp3'] }); + }); + expect(res).toEqual(['v1', 'v2', { nested: true }]); + }); + + it('coerces non-string keys', async () => { + const res = await inCtx(async () => { + await target.batchPut({ + items: [ + { key: 1 as unknown as string, value: 'one' }, + { key: 2 as unknown as string, value: 'two' }, + ], + }); + return target.get({ key: ['1', '2'] }); + }); + expect(res).toEqual(['one', 'two']); + }); + + it('rejects a missing items array', async () => { + await expect( + inCtx(() => + target.batchPut({ + items: undefined as unknown as [], + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects an empty items array', async () => { + await expect( + inCtx(() => target.batchPut({ items: [] })), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects when any item has an empty key', async () => { + await expect( + inCtx(() => + target.batchPut({ + items: [ + { key: 'ok', value: 1 }, + { key: '', value: 2 }, + ], + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + }); + + describe('del', () => { + it('removes a previously-set key', async () => { + const res = await inCtx(async () => { + await target.set({ key: 'gone', value: 'bye' }); + await target.del({ key: 'gone' }); + return target.get({ key: 'gone' }); + }); + expect(res).toBeNull(); + }); + + it('returns true even when the key never existed', async () => { + const res = await inCtx(() => + target.del({ key: 'never-existed' }), + ); + expect(res).toBe(true); + }); + + it('rejects a missing key', async () => { + await expect( + inCtx(() => target.del({ key: undefined })), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects an empty key', async () => { + await expect( + inCtx(() => target.del({ key: '' })), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + }); + + describe('list', () => { + const seed = () => + target.batchPut({ + items: [ + { key: 'fruit:apple', value: 'red' }, + { key: 'fruit:banana', value: 'yellow' }, + { key: 'veg:carrot', value: 'orange' }, + ], + }); + + it('returns key/value entries by default', async () => { + const res = (await inCtx(async () => { + await seed(); + return target.list({}); + })) as { key: string; value: unknown }[]; + expect(res).toEqual( + expect.arrayContaining([ + { key: 'fruit:apple', value: 'red' }, + { key: 'fruit:banana', value: 'yellow' }, + { key: 'veg:carrot', value: 'orange' }, + ]), + ); + }); + + it('returns just keys when as=keys', async () => { + const res = (await inCtx(async () => { + await seed(); + return target.list({ as: 'keys' }); + })) as string[]; + expect(res).toEqual( + expect.arrayContaining([ + 'fruit:apple', + 'fruit:banana', + 'veg:carrot', + ]), + ); + }); + + it('returns just values when as=values', async () => { + const res = (await inCtx(async () => { + await seed(); + return target.list({ as: 'values' }); + })) as unknown[]; + expect(res).toEqual( + expect.arrayContaining(['red', 'yellow', 'orange']), + ); + }); + + it('filters by wildcard prefix pattern', async () => { + const res = (await inCtx(async () => { + await seed(); + return target.list({ as: 'keys', pattern: 'fruit:*' }); + })) as string[]; + expect(res).toEqual( + expect.arrayContaining(['fruit:apple', 'fruit:banana']), + ); + expect(res).not.toContain('veg:carrot'); + }); + + it('returns a paginated envelope with cursor when limit is supplied', async () => { + const res = (await inCtx(async () => { + await seed(); + return target.list({ limit: 1 }); + })) as { items: unknown[]; cursor?: string }; + expect(res.items.length).toBe(1); + expect(typeof res.cursor).toBe('string'); + }); + + it('paginates across pages using the returned cursor', async () => { + const collected = await inCtx(async () => { + await seed(); + const page1 = (await target.list({ limit: 2 })) as { + items: { key: string }[]; + cursor?: string; + }; + const page2 = (await target.list({ + limit: 2, + cursor: page1.cursor, + })) as { items: { key: string }[]; cursor?: string }; + return [...page1.items, ...page2.items].map((e) => e.key); + }); + expect(collected.sort()).toEqual([ + 'fruit:apple', + 'fruit:banana', + 'veg:carrot', + ]); + }); + + it('rejects an unsupported as value', async () => { + await expect( + inCtx(() => + target.list({ + as: 'bogus' as 'keys', + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + }); + + describe('flush', () => { + it('removes every key in the actor namespace', async () => { + const res = (await inCtx(async () => { + await target.batchPut({ + items: [ + { key: 'f1', value: 1 }, + { key: 'f2', value: 2 }, + ], + }); + await target.flush({}); + return target.list({ as: 'keys' }); + })) as string[]; + expect(res).toEqual([]); + }); + + it('only flushes the calling actor namespace', async () => { + const otherActor = makeActor(); + await inCtx(() => target.set({ key: 'mine', value: 1 })); + await inCtx( + () => target.set({ key: 'theirs', value: 2 }), + otherActor, + ); + await inCtx(() => target.flush({})); + + const mine = await inCtx(() => target.get({ key: 'mine' })); + const theirs = await inCtx( + () => target.get({ key: 'theirs' }), + otherActor, + ); + expect(mine).toBeNull(); + expect(theirs).toBe(2); + }); + }); + + describe('incr / decr', () => { + it('increments a top-level numeric counter from zero', async () => { + const res = await inCtx(() => + target.incr({ key: 'c', pathAndAmountMap: { hits: 1 } }), + ); + expect(res).toMatchObject({ hits: 1 }); + }); + + it('accumulates across calls', async () => { + const res = await inCtx(async () => { + await target.incr({ key: 'c', pathAndAmountMap: { hits: 2 } }); + return target.incr({ key: 'c', pathAndAmountMap: { hits: 3 } }); + }); + expect(res).toMatchObject({ hits: 5 }); + }); + + it('decr subtracts via the same machinery', async () => { + const res = await inCtx(async () => { + await target.incr({ key: 'c', pathAndAmountMap: { hits: 10 } }); + return target.decr({ key: 'c', pathAndAmountMap: { hits: 3 } }); + }); + expect(res).toMatchObject({ hits: 7 }); + }); + + it('coerces non-string keys', async () => { + const res = await inCtx(() => + target.incr({ + key: 7 as unknown as string, + pathAndAmountMap: { n: 1 }, + }), + ); + expect(res).toMatchObject({ n: 1 }); + }); + + it.each([ + ['incr' as const], + ['decr' as const], + ])('%s rejects a missing key', async (op) => { + await expect( + inCtx(() => + target[op]({ + key: undefined, + pathAndAmountMap: { n: 1 }, + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it.each([ + ['incr' as const], + ['decr' as const], + ])('%s rejects a missing pathAndAmountMap', async (op) => { + await expect( + inCtx(() => + target[op]({ + key: 'k', + pathAndAmountMap: undefined as unknown as Record< + string, + number + >, + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it.each([ + ['incr' as const], + ['decr' as const], + ])('%s rejects a non-object pathAndAmountMap', async (op) => { + await expect( + inCtx(() => + target[op]({ + key: 'k', + pathAndAmountMap: 'nope' as unknown as Record< + string, + number + >, + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + }); + + describe('expireAt / expire', () => { + it('expireAt makes a key invisible once the timestamp has passed', async () => { + const past = Math.floor(Date.now() / 1000) - 5; + const res = await inCtx(async () => { + await target.set({ key: 'fade', value: 'soon' }); + await target.expireAt({ key: 'fade', timestamp: past }); + return target.get({ key: 'fade' }); + }); + expect(res).toBeNull(); + }); + + it('expire computes the TTL relative to now (negative TTL = expired)', async () => { + const res = await inCtx(async () => { + await target.set({ key: 'fade2', value: 'soon' }); + await target.expire({ key: 'fade2', ttl: -10 }); + return target.get({ key: 'fade2' }); + }); + expect(res).toBeNull(); + }); + + it('expireAt rejects a non-number timestamp', async () => { + await expect( + inCtx(() => + target.expireAt({ + key: 'k', + timestamp: 'soon' as unknown as number, + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('expire rejects a non-number ttl', async () => { + await expect( + inCtx(() => + target.expire({ + key: 'k', + ttl: 'soon' as unknown as number, + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it.each([ + ['expireAt' as const, { timestamp: 0 }], + ['expire' as const, { ttl: 0 }], + ])('%s rejects an empty key', async (op, args) => { + await expect( + inCtx(() => + (target[op] as (a: unknown) => Promise)({ + key: '', + ...args, + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + }); + + describe('update', () => { + it('sets a top-level path on a fresh key', async () => { + const res = await inCtx(() => + target.update({ + key: 'doc', + pathAndValueMap: { name: 'puter' }, + }), + ); + expect(res).toMatchObject({ name: 'puter' }); + }); + + it('writes nested paths and creates intermediate maps', async () => { + const res = await inCtx(() => + target.update({ + key: 'doc', + pathAndValueMap: { 'profile.email': 'a@b.com' }, + }), + ); + expect(res).toMatchObject({ profile: { email: 'a@b.com' } }); + }); + + it('preserves untouched fields when updating a single path', async () => { + const res = await inCtx(async () => { + await target.update({ + key: 'doc', + pathAndValueMap: { name: 'first', age: 1 }, + }); + return target.update({ + key: 'doc', + pathAndValueMap: { age: 2 }, + }); + }); + expect(res).toMatchObject({ name: 'first', age: 2 }); + }); + + it('applies a TTL when ttl is supplied', async () => { + const res = await inCtx(async () => { + await target.update({ + key: 'doc', + pathAndValueMap: { name: 'temp' }, + ttl: -10, + }); + return target.get({ key: 'doc' }); + }); + expect(res).toBeNull(); + }); + + it('rejects a missing key', async () => { + await expect( + inCtx(() => + target.update({ + key: undefined, + pathAndValueMap: { x: 1 }, + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects a missing pathAndValueMap', async () => { + await expect( + inCtx(() => + target.update({ + key: 'k', + pathAndValueMap: undefined as unknown as Record< + string, + unknown + >, + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects a non-object pathAndValueMap', async () => { + await expect( + inCtx(() => + target.update({ + key: 'k', + pathAndValueMap: 'bogus' as unknown as Record< + string, + unknown + >, + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + }); + + describe('add', () => { + it('appends a single element to an empty path, creating a new list', async () => { + const res = await inCtx(() => + target.add({ + key: 'list', + pathAndValueMap: { items: 'a' }, + }), + ); + expect(res).toMatchObject({ items: ['a'] }); + }); + + it('appends an array to an existing list', async () => { + const res = await inCtx(async () => { + await target.add({ + key: 'list', + pathAndValueMap: { items: ['a'] }, + }); + return target.add({ + key: 'list', + pathAndValueMap: { items: ['b', 'c'] }, + }); + }); + expect(res).toMatchObject({ items: ['a', 'b', 'c'] }); + }); + + it('rejects a missing pathAndValueMap', async () => { + await expect( + inCtx(() => + target.add({ + key: 'k', + pathAndValueMap: undefined as unknown as Record< + string, + unknown + >, + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects an empty key', async () => { + await expect( + inCtx(() => + target.add({ key: '', pathAndValueMap: { x: 1 } }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + }); + + describe('remove', () => { + it('removes a path that exists', async () => { + const res = await inCtx(async () => { + await target.update({ + key: 'doc', + pathAndValueMap: { keep: 1, drop: 2 }, + }); + return target.remove({ key: 'doc', paths: ['drop'] }); + }); + expect(res).toMatchObject({ keep: 1 }); + expect(res).not.toHaveProperty('drop'); + }); + + it('rejects a missing paths array', async () => { + await expect( + inCtx(() => + target.remove({ + key: 'k', + paths: undefined as unknown as string[], + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects an empty paths array', async () => { + await expect( + inCtx(() => target.remove({ key: 'k', paths: [] })), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects a missing key', async () => { + await expect( + inCtx(() => + target.remove({ key: undefined, paths: ['x'] }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + }); + + describe('actor scoping', () => { + it('isolates values between actors with different user uuids', async () => { + const otherActor = makeActor(); + await inCtx(() => target.set({ key: 'shared', value: 'mine' })); + const otherSees = await inCtx( + () => target.get({ key: 'shared' }), + otherActor, + ); + expect(otherSees).toBeNull(); + }); + + it('isolates values between two app actors with the same user but different apps', async () => { + const baseUser = `user-${Math.random().toString(36).slice(2)}`; + const appA: Actor = { + user: { uuid: baseUser }, + app: { uid: 'app-A', id: 100 }, + }; + const appB: Actor = { + user: { uuid: baseUser }, + app: { uid: 'app-B', id: 200 }, + }; + + await inCtx(() => target.set({ key: 'k', value: 'A' }), appA); + const fromB = await inCtx(() => target.get({ key: 'k' }), appB); + expect(fromB).toBeNull(); + + const fromA = await inCtx(() => target.get({ key: 'k' }), appA); + expect(fromA).toBe('A'); + }); + + it('ignores optConfig.appUuid when the actor already has an app uid', async () => { + // App-actor sets a value, then tries to read with an appUuid override + // pointing somewhere else — driver must scrub the override. + const baseUser = `user-${Math.random().toString(36).slice(2)}`; + const appActor: Actor = { + user: { uuid: baseUser }, + app: { uid: 'real-app', id: 1 }, + }; + await inCtx( + () => target.set({ key: 'k', value: 'real' }), + appActor, + ); + const res = await inCtx( + () => + target.get({ + key: 'k', + optConfig: { appUuid: 'spoof-app' }, + }), + appActor, + ); + expect(res).toBe('real'); + }); + + it('uses optConfig.appUuid for a user-only (root) actor', async () => { + // User-only actor is allowed to scope reads/writes to a target + // app namespace via optConfig.appUuid. Verify by reading the same + // entry via a real app-actor for that app. + const baseUser = `user-${Math.random().toString(36).slice(2)}`; + const userOnly: Actor = { user: { uuid: baseUser } }; + const asApp: Actor = { + user: { uuid: baseUser }, + app: { uid: 'target-app', id: 1 }, + }; + + await inCtx( + () => + target.set({ + key: 'k', + value: 'set-by-root', + optConfig: { appUuid: 'target-app' }, + }), + userOnly, + ); + const res = await inCtx(() => target.get({ key: 'k' }), asApp); + expect(res).toBe('set-by-root'); + }); + }); + + describe('getReportedCosts', () => { + it('reports a row per KV usage type with the configured rate', () => { + const rows = target.getReportedCosts(); + expect(rows).toEqual( + expect.arrayContaining([ + { + usageType: 'kv:read', + ucentsPerUnit: KV_COSTS['kv:read'], + unit: 'capacity-unit', + source: 'driver:kvStore', + }, + { + usageType: 'kv:write', + ucentsPerUnit: KV_COSTS['kv:write'], + unit: 'capacity-unit', + source: 'driver:kvStore', + }, + ]), + ); + expect(rows.length).toBe(Object.keys(KV_COSTS).length); + }); + }); +}); diff --git a/src/backend/server.ts b/src/backend/server.ts index 7263d51d0..b7b2952ee 100644 --- a/src/backend/server.ts +++ b/src/backend/server.ts @@ -93,11 +93,11 @@ export class PuterServer { constructor( config: IConfig, - clients: typeof puterClients, - stores: typeof puterStores, - services: typeof puterServices, - controllers: typeof puterControllers, - drivers: typeof puterDrivers, + clients: typeof puterClients = puterClients, + stores: typeof puterStores = puterStores, + services: typeof puterServices = puterServices, + controllers: typeof puterControllers = puterControllers, + drivers: typeof puterDrivers = puterDrivers, ) { this.#config = config; // Expose config to the extension API (extension.config) @@ -1053,41 +1053,7 @@ export class PuterServer { '************************************************************\n', ); - for (const client of Object.values( - this.clients, - ) as WithLifecycle[]) { - if (client.onServerStart) { - await client.onServerStart(); - } - } - for (const store of Object.values( - this.stores, - ) as WithLifecycle[]) { - if (store.onServerStart) { - await store.onServerStart(); - } - } - for (const service of Object.values( - this.services, - ) as WithLifecycle[]) { - if (service.onServerStart) { - await service.onServerStart(); - } - } - for (const controller of Object.values( - this.controllers, - ) as WithLifecycle[]) { - if (controller.onServerStart) { - await controller.onServerStart(); - } - } - for (const driver of Object.values( - this.drivers, - ) as WithLifecycle[]) { - if (driver.onServerStart) { - await driver.onServerStart(); - } - } + await this.#fireOnServerStart(); console.log('PuterServer has fully booted.'); // Auto-launch the browser on dev boot (matches v1 WebServerService). // Opt out via `no_browser_launch: true` in config. @@ -1115,6 +1081,29 @@ export class PuterServer { ); }, } as unknown as http.Server; + // Tests still need onServerStart to fire so stores can + // bootstrap (e.g. SystemKVStore creates its dynalite table). + await this.#fireOnServerStart(); + } + } + + async #fireOnServerStart() { + for (const client of Object.values(this.clients) as WithLifecycle[]) { + if (client.onServerStart) await client.onServerStart(); + } + for (const store of Object.values(this.stores) as WithLifecycle[]) { + if (store.onServerStart) await store.onServerStart(); + } + for (const service of Object.values(this.services) as WithLifecycle[]) { + if (service.onServerStart) await service.onServerStart(); + } + for (const controller of Object.values( + this.controllers, + ) as WithLifecycle[]) { + if (controller.onServerStart) await controller.onServerStart(); + } + for (const driver of Object.values(this.drivers) as WithLifecycle[]) { + if (driver.onServerStart) await driver.onServerStart(); } } diff --git a/src/backend/services/metering/MeteringService.test.ts b/src/backend/services/metering/MeteringService.test.ts new file mode 100644 index 000000000..0f3674af7 --- /dev/null +++ b/src/backend/services/metering/MeteringService.test.ts @@ -0,0 +1,754 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { Actor } from '../../core/actor.ts'; +import { SYSTEM_ACTOR } from '../../core/actor.ts'; +import { PuterServer } from '../../server.ts'; +import { setupTestServer } from '../../testUtil.ts'; +import { + DEFAULT_FREE_SUBSCRIPTION, + DEFAULT_TEMP_SUBSCRIPTION, + GLOBAL_APP_KEY, + METRICS_PREFIX, + PERIOD_ESCAPE, + POLICY_PREFIX, +} from './consts.ts'; +import type { MeteringService } from './MeteringService.ts'; +import { toMicroCents } from './utils.ts'; + +const escape = (usageType: string) => usageType.replace(/\./g, PERIOD_ESCAPE); + +describe('MeteringService', () => { + let server: PuterServer; + let target: MeteringService; + let originalShardCount: number; + + // Resolvers and extra policies are stored on private fields of the service + // and there's no public reset. Tests that register hooks pollute later + // tests, so we snapshot the originals once and restore after each test. + type Internals = { + subscriptionResolvers: unknown[]; + defaultSubscriptionResolvers: unknown[]; + extraPolicies: unknown[]; + }; + let internals: Internals; + let snapshot: { + subs: unknown[]; + defs: unknown[]; + pols: unknown[]; + }; + + beforeAll(async () => { + server = await setupTestServer(); + target = server.services.metering; + // Smaller shard count makes getGlobalUsage cheap in tests; the + // production value (10000) means ~100 batchGet round-trips per call. + originalShardCount = (target.constructor as typeof MeteringService) + .GLOBAL_SHARD_COUNT; + (target.constructor as typeof MeteringService).GLOBAL_SHARD_COUNT = 4; + (target.constructor as typeof MeteringService).APP_SHARD_COUNT = 4; + + internals = target as unknown as Internals; + snapshot = { + subs: [...internals.subscriptionResolvers], + defs: [...internals.defaultSubscriptionResolvers], + pols: [...internals.extraPolicies], + }; + }); + + afterEach(() => { + internals.subscriptionResolvers.length = 0; + internals.subscriptionResolvers.push(...snapshot.subs); + internals.defaultSubscriptionResolvers.length = 0; + internals.defaultSubscriptionResolvers.push(...snapshot.defs); + internals.extraPolicies.length = 0; + internals.extraPolicies.push(...snapshot.pols); + }); + + afterAll(async () => { + (target.constructor as typeof MeteringService).GLOBAL_SHARD_COUNT = + originalShardCount; + (target.constructor as typeof MeteringService).APP_SHARD_COUNT = + originalShardCount; + await server.shutdown(); + }); + + // Each test uses a fresh user so KV state from one test never leaks into + // the next. Email present → registered-user policy; absent → temp. + let actor: Actor; + const makeUser = ( + overrides: Partial = {}, + ): Actor['user'] => ({ + uuid: `meter-user-${Math.random().toString(36).slice(2)}`, + username: 'meter-user', + email: 'meter@test.com', + ...overrides, + }); + const makeActor = (overrides: Partial = {}): Actor => ({ + user: makeUser(), + ...overrides, + }); + beforeEach(() => { + actor = makeActor(); + }); + + // Aux KV writes inside increment paths are fire-and-forget; this helper + // polls until the assertion passes so tests stay deterministic without + // arbitrary sleeps. + const waitFor = (fn: () => unknown | Promise) => + vi.waitFor(fn, { timeout: 2000, interval: 10 }); + + // ── Subscriptions ──────────────────────────────────────────────── + + describe('getActorSubscription', () => { + it('returns the registered-user free policy for a user with email', async () => { + const policy = await target.getActorSubscription(actor); + expect(policy.id).toBe(DEFAULT_FREE_SUBSCRIPTION); + expect(policy.monthUsageAllowance).toBeGreaterThan(0); + }); + + it('returns the temp policy for a user without email', async () => { + const tempActor: Actor = { + user: makeUser({ email: null }), + }; + const policy = await target.getActorSubscription(tempActor); + expect(policy.id).toBe(DEFAULT_TEMP_SUBSCRIPTION); + }); + + it('uses the first non-empty subscription resolver', async () => { + const customPolicy = { + id: 'custom-paid', + monthUsageAllowance: toMicroCents(10), + monthlyStorageAllowance: 1024 * 1024 * 1024, + }; + target.registerPolicy(customPolicy); + const stub = vi.fn(async () => 'custom-paid'); + target.registerSubscriptionResolver(stub); + + const policy = await target.getActorSubscription(actor); + expect(policy.id).toBe('custom-paid'); + expect(stub).toHaveBeenCalledWith(actor); + }); + + it('falls through to the default resolver when the primary returns nothing', async () => { + const customDefault = { + id: 'custom-default', + monthUsageAllowance: toMicroCents(2), + monthlyStorageAllowance: 1024 * 1024 * 1024, + }; + target.registerPolicy(customDefault); + target.registerSubscriptionResolver(async () => null); + target.registerDefaultSubscriptionResolver( + async () => 'custom-default', + ); + const policy = await target.getActorSubscription(actor); + expect(policy.id).toBe('custom-default'); + }); + + it('rejects an actor with no user uuid', async () => { + await expect( + target.getActorSubscription({ + user: { uuid: '' }, + }), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + }); + + // ── Addons ─────────────────────────────────────────────────────── + + describe('getActorAddons / updateAddonCredit', () => { + it('returns an empty addon map for a fresh user', async () => { + const addons = await target.getActorAddons(actor); + expect(addons).toEqual({}); + }); + + it('updateAddonCredit increments purchasedCredits', async () => { + await target.updateAddonCredit(actor.user.uuid, 1000); + const addons = await target.getActorAddons(actor); + expect(addons.purchasedCredits).toBe(1000); + + await target.updateAddonCredit(actor.user.uuid, 500); + const updated = await target.getActorAddons(actor); + expect(updated.purchasedCredits).toBe(1500); + }); + + it('updateAddonCredit throws without a userId', async () => { + await expect(target.updateAddonCredit('', 100)).rejects.toThrow(); + }); + + it('rejects getActorAddons for an actor with no user uuid', async () => { + await expect( + target.getActorAddons({ user: { uuid: '' } }), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + }); + + // ── incrementUsage ─────────────────────────────────────────────── + + describe('incrementUsage', () => { + it('records cost, units, and count for a single usage type', async () => { + const cost = 250; + const result = await target.incrementUsage( + actor, + 'kv:read', + 4, + cost, + ); + expect(result.total).toBe(cost); + const record = result['kv:read']; + expect(record).toMatchObject({ cost, units: 4, count: 1 }); + }); + + it('escapes dots in usage type names so KV nested paths do not collide', async () => { + await target.incrementUsage(actor, 'driver.foo.bar', 2, 100); + const { usage } = + await target.getActorCurrentMonthUsageDetails(actor); + // Returned shape uses the escaped key (raw KV layout). + const record = (usage as Record)[ + escape('driver.foo.bar') + ]; + expect(record).toMatchObject({ cost: 100, units: 2, count: 1 }); + }); + + it('accumulates across calls', async () => { + await target.incrementUsage(actor, 'kv:read', 1, 10); + const second = await target.incrementUsage(actor, 'kv:read', 3, 20); + expect(second.total).toBe(30); + expect(second['kv:read']).toMatchObject({ + cost: 30, + units: 4, + count: 2, + }); + }); + + it('returns a zero result for a system actor and writes nothing', async () => { + const result = await target.incrementUsage( + SYSTEM_ACTOR, + 'kv:read', + 1, + 100, + ); + expect(result).toEqual({ total: 0 }); + }); + + it.each([ + ['zero amount', 'kv:read', 0], + ['empty usage type', '', 1], + ])('skips when %s', async (_label, type, amount) => { + const result = await target.incrementUsage(actor, type, amount, 5); + expect(result).toEqual({ total: 0 }); + + const { usage } = + await target.getActorCurrentMonthUsageDetails(actor); + expect(usage.total ?? 0).toBe(0); + }); + + it('normalizes a negative usageAmount to 1', async () => { + const result = await target.incrementUsage( + actor, + 'kv:read', + -5, + 10, + ); + expect(result['kv:read']).toMatchObject({ units: 1 }); + }); + + it('normalizes a negative costOverride to 1 and raises an alarm', async () => { + const alarmSpy = vi.spyOn(server.clients.alarm, 'create'); + const result = await target.incrementUsage( + actor, + 'kv:read', + 1, + -42, + ); + expect(result['kv:read']).toMatchObject({ cost: 1, units: 1 }); + expect(alarmSpy).toHaveBeenCalledWith( + expect.stringContaining('negative cost'), + expect.any(String), + expect.objectContaining({ usageType: 'kv:read' }), + ); + alarmSpy.mockRestore(); + }); + + it('treats a missing costOverride as zero cost', async () => { + const result = await target.incrementUsage(actor, 'kv:read', 2); + expect(result.total).toBe(0); + expect(result['kv:read']).toMatchObject({ + cost: 0, + units: 2, + count: 1, + }); + }); + + it('writes the per-actor / per-app aux record', async () => { + const appActor: Actor = { + user: makeUser(), + app: { uid: 'my-app', id: 1 }, + }; + await target.incrementUsage(appActor, 'kv:read', 1, 100); + await waitFor(async () => { + const u = await target.getActorAppUsage(appActor, 'my-app'); + expect(u.total).toBe(100); + }); + }); + + it('consumes purchased credits once monthly allowance is exceeded', async () => { + const overActor: Actor = { user: makeUser() }; + const sub = await target.getActorSubscription(overActor); + await target.updateAddonCredit(overActor.user.uuid, 5_000_000); + + // Spend the entire monthly allowance — no overage yet. + await target.incrementUsage( + overActor, + 'kv:read', + 1, + sub.monthUsageAllowance, + ); + // First overage of 1_000_000 micro-cents should pull from credits. + await target.incrementUsage(overActor, 'kv:read', 1, 1_000_000); + + await waitFor(async () => { + const addons = await target.getActorAddons(overActor); + expect(addons.consumedPurchaseCredits).toBe(1_000_000); + }); + }); + }); + + // ── batchIncrementUsages ───────────────────────────────────────── + + describe('batchIncrementUsages', () => { + it('aggregates multiple usages into a single actor record', async () => { + const result = await target.batchIncrementUsages(actor, [ + { usageType: 'kv:read', usageAmount: 2, costOverride: 100 }, + { usageType: 'kv:write', usageAmount: 1, costOverride: 50 }, + { usageType: 'kv:read', usageAmount: 3, costOverride: 30 }, + ]); + expect(result.total).toBe(180); + expect(result['kv:read']).toMatchObject({ + cost: 130, + units: 5, + count: 2, + }); + expect(result['kv:write']).toMatchObject({ + cost: 50, + units: 1, + count: 1, + }); + }); + + it('returns zero for an empty list', async () => { + const result = await target.batchIncrementUsages(actor, []); + expect(result).toEqual({ total: 0 }); + }); + + it('returns zero for a system actor and writes nothing', async () => { + const result = await target.batchIncrementUsages(SYSTEM_ACTOR, [ + { usageType: 'kv:read', usageAmount: 1, costOverride: 100 }, + ]); + expect(result).toEqual({ total: 0 }); + }); + + it('skips items with missing fields but still writes the rest', async () => { + const result = await target.batchIncrementUsages(actor, [ + { usageType: 'kv:read', usageAmount: 1, costOverride: 10 }, + { usageType: '', usageAmount: 1, costOverride: 999 }, + { usageType: 'kv:write', usageAmount: 0, costOverride: 999 }, + { usageType: 'kv:write', usageAmount: 2, costOverride: 20 }, + ]); + expect(result.total).toBe(30); + expect(result['kv:read']).toMatchObject({ count: 1, units: 1 }); + expect(result['kv:write']).toMatchObject({ count: 1, units: 2 }); + }); + + it('raises an alarm for any negative costOverride in the batch', async () => { + const alarmSpy = vi.spyOn(server.clients.alarm, 'create'); + await target.batchIncrementUsages(actor, [ + { usageType: 'kv:read', usageAmount: 1, costOverride: -7 }, + ]); + expect(alarmSpy).toHaveBeenCalledWith( + expect.stringContaining('negative cost'), + expect.any(String), + expect.objectContaining({ usageType: 'kv:read' }), + ); + alarmSpy.mockRestore(); + }); + }); + + // ── utilRecordUsageObject ──────────────────────────────────────── + + describe('utilRecordUsageObject', () => { + it('prefixes each usage kind with the modelPrefix and applies overrides', async () => { + const result = await target.utilRecordUsageObject( + { prompt_tokens: 100, completion_tokens: 50 }, + actor, + 'gpt-4', + { prompt_tokens: 1000 }, + ); + expect(result['gpt-4:prompt_tokens']).toMatchObject({ + cost: 1000, + units: 100, + count: 1, + }); + // No override → cost defaults to 0 + expect(result['gpt-4:completion_tokens']).toMatchObject({ + cost: 0, + units: 50, + count: 1, + }); + expect(result.total).toBe(1000); + }); + + it('ignores non-numeric override values', async () => { + const result = await target.utilRecordUsageObject( + { prompt_tokens: 1 }, + actor, + 'm', + { prompt_tokens: Number.NaN }, + ); + expect(result['m:prompt_tokens']).toMatchObject({ cost: 0 }); + }); + }); + + // ── getActorCurrentMonthUsageDetails ───────────────────────────── + + describe('getActorCurrentMonthUsageDetails', () => { + it('returns an empty envelope for a fresh user', async () => { + const result = await target.getActorCurrentMonthUsageDetails(actor); + expect(result.usage).toEqual({ total: 0 }); + expect(result.appTotals).toEqual({}); + }); + + it('returns the recorded usage and app totals after increments', async () => { + const userId = actor.user.uuid; + const appA: Actor = { + user: { uuid: userId }, + app: { uid: 'A', id: 1 }, + }; + const appB: Actor = { + user: { uuid: userId }, + app: { uid: 'B', id: 2 }, + }; + await target.incrementUsage(appA, 'kv:read', 1, 100); + await target.incrementUsage(appB, 'kv:read', 1, 50); + + await waitFor(async () => { + const r = await target.getActorCurrentMonthUsageDetails({ + user: { uuid: userId }, + }); + expect(r.appTotals.A?.total).toBe(100); + expect(r.appTotals.B?.total).toBe(50); + }); + + const result = await target.getActorCurrentMonthUsageDetails({ + user: { uuid: userId }, + }); + expect(result.usage.total).toBe(150); + }); + + it('filters appTotals by actor.app.uid and rolls others into "others"', async () => { + const userId = actor.user.uuid; + const appA: Actor = { + user: { uuid: userId }, + app: { uid: 'A', id: 1 }, + }; + const appB: Actor = { + user: { uuid: userId }, + app: { uid: 'B', id: 2 }, + }; + await target.incrementUsage(appA, 'kv:read', 1, 100); + await target.incrementUsage(appB, 'kv:read', 1, 50); + + await waitFor(async () => { + const r = await target.getActorCurrentMonthUsageDetails(appA); + expect(r.appTotals.A?.total).toBe(100); + expect(r.appTotals.others?.total).toBe(50); + expect(r.appTotals).not.toHaveProperty('B'); + }); + }); + + it('rejects an actor with no user uuid', async () => { + await expect( + target.getActorCurrentMonthUsageDetails({ + user: { uuid: '' }, + }), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + }); + + // ── getActorCurrentMonthAppUsageDetails ────────────────────────── + + describe('getActorCurrentMonthAppUsageDetails', () => { + it('returns the per-app record for an explicit appId', async () => { + const appActor: Actor = { + user: makeUser(), + app: { uid: 'my-app', id: 1 }, + }; + await target.incrementUsage(appActor, 'kv:read', 1, 250); + await waitFor(async () => { + const r = await target.getActorCurrentMonthAppUsageDetails( + appActor, + 'my-app', + ); + expect(r.total).toBe(250); + }); + }); + + it('defaults to the actor app id when none is supplied', async () => { + const appActor: Actor = { + user: makeUser(), + app: { uid: 'my-app', id: 1 }, + }; + await target.incrementUsage(appActor, 'kv:read', 1, 75); + await waitFor(async () => { + const r = + await target.getActorCurrentMonthAppUsageDetails(appActor); + expect(r.total).toBe(75); + }); + }); + + it('allows an app actor to query the global namespace', async () => { + const userOnly: Actor = { user: makeUser() }; + await target.incrementUsage(userOnly, 'kv:read', 1, 60); + const appActor: Actor = { + user: userOnly.user, + app: { uid: 'my-app', id: 1 }, + }; + await waitFor(async () => { + const r = await target.getActorCurrentMonthAppUsageDetails( + appActor, + GLOBAL_APP_KEY, + ); + expect(r.total).toBe(60); + }); + }); + + it('forbids an app actor from querying another app', async () => { + const appActor: Actor = { + user: makeUser(), + app: { uid: 'mine', id: 1 }, + }; + await expect( + target.getActorCurrentMonthAppUsageDetails( + appActor, + 'someone-else', + ), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + + it('rejects an actor with no user uuid', async () => { + await expect( + target.getActorCurrentMonthAppUsageDetails({ + user: { uuid: '' }, + }), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + }); + + // ── setActorCurrentMonthUsageTotal ─────────────────────────────── + + describe('setActorCurrentMonthUsageTotal', () => { + it('sets the total via a manual_adjustment delta when no usage exists', async () => { + const result = await target.setActorCurrentMonthUsageTotal( + actor, + 500, + ); + expect(result.total).toBe(500); + const adj = (result as Record) + .manual_adjustment as + | { cost: number; units: number; count: number } + | undefined; + expect(adj).toMatchObject({ cost: 500, units: 500, count: 1 }); + }); + + it('applies a delta against an existing total', async () => { + await target.incrementUsage(actor, 'kv:read', 1, 100); + const result = await target.setActorCurrentMonthUsageTotal( + actor, + 300, + ); + expect(result.total).toBe(300); + }); + + it('is a no-op when delta is zero', async () => { + await target.incrementUsage(actor, 'kv:read', 1, 100); + const result = await target.setActorCurrentMonthUsageTotal( + actor, + 100, + ); + expect(result.total).toBe(100); + }); + + it('rejects a negative total', async () => { + await expect( + target.setActorCurrentMonthUsageTotal(actor, -1), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects a non-finite total', async () => { + await expect( + target.setActorCurrentMonthUsageTotal(actor, Number.NaN), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects an actor with no user uuid', async () => { + await expect( + target.setActorCurrentMonthUsageTotal( + { user: { uuid: '' } }, + 100, + ), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + }); + + // ── getActorAppUsage ───────────────────────────────────────────── + + describe('getActorAppUsage', () => { + it('returns zero for an app the user has no usage in', async () => { + const result = await target.getActorAppUsage(actor, 'untouched'); + expect(result.total).toBe(0); + }); + + it('forbids an app actor from reading another app', async () => { + const appActor: Actor = { + user: makeUser(), + app: { uid: 'mine', id: 1 }, + }; + await expect( + target.getActorAppUsage(appActor, 'theirs'), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + + it('rejects an actor with no user uuid', async () => { + await expect( + target.getActorAppUsage({ user: { uuid: '' } }, 'app'), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + }); + + // ── allowance / credits ────────────────────────────────────────── + + describe('getRemainingUsage / getAllowedUsage / hasAnyUsage / hasEnoughCredits', () => { + it('a fresh user has the full subscription allowance remaining', async () => { + const allowed = await target.getAllowedUsage(actor); + expect(allowed.remaining).toBe(allowed.monthUsageAllowance); + expect(allowed.monthUsageAllowance).toBeGreaterThan(0); + expect(allowed.addons).toEqual({}); + }); + + it('subtracts spent usage from remaining', async () => { + await target.incrementUsage(actor, 'kv:read', 1, 1_000); + const allowed = await target.getAllowedUsage(actor); + expect(allowed.remaining).toBe(allowed.monthUsageAllowance - 1_000); + }); + + it('adds purchased credits to remaining', async () => { + await target.updateAddonCredit(actor.user.uuid, 5_000); + const allowed = await target.getAllowedUsage(actor); + expect(allowed.remaining).toBe(allowed.monthUsageAllowance + 5_000); + }); + + it('clamps remaining at zero when over allowance with no credits', async () => { + const sub = await target.getActorSubscription(actor); + await target.incrementUsage( + actor, + 'kv:read', + 1, + sub.monthUsageAllowance + 5_000, + ); + const remaining = await target.getRemainingUsage(actor); + expect(remaining).toBe(0); + }); + + it('hasAnyUsage tracks remaining', async () => { + const sub = await target.getActorSubscription(actor); + expect(await target.hasAnyUsage(actor)).toBe(true); + await target.incrementUsage( + actor, + 'kv:read', + 1, + sub.monthUsageAllowance, + ); + expect(await target.hasAnyUsage(actor)).toBe(false); + }); + + it('hasEnoughCredits compares remaining against the requested amount', async () => { + await target.updateAddonCredit(actor.user.uuid, 1_000); + expect(await target.hasEnoughCredits(actor, 100)).toBe(true); + expect( + await target.hasEnoughCredits(actor, Number.MAX_SAFE_INTEGER), + ).toBe(false); + }); + }); + + // ── getGlobalUsage ─────────────────────────────────────────────── + + describe('getGlobalUsage', () => { + it('aggregates increments across actors into the same global view', async () => { + const before = await target.getGlobalUsage(); + const user1: Actor = { user: makeUser() }; + const user2: Actor = { user: makeUser() }; + await target.incrementUsage(user1, 'kv:read', 1, 100); + await target.incrementUsage(user2, 'kv:read', 1, 200); + + await waitFor(async () => { + const now = await target.getGlobalUsage(); + expect(now.total - before.total).toBe(300); + const beforeRead = (before['kv:read']?.cost ?? 0) as number; + const nowRead = (now['kv:read']?.cost ?? 0) as number; + expect(nowRead - beforeRead).toBe(300); + }); + }); + }); + + // ── KV layout sanity check ─────────────────────────────────────── + + describe('KV layout', () => { + it('writes the actor monthly record at the expected key shape', async () => { + await target.incrementUsage(actor, 'kv:read', 1, 100); + const month = `${new Date().getUTCFullYear()}-${String( + new Date().getUTCMonth() + 1, + ).padStart(2, '0')}`; + const key = `${METRICS_PREFIX}:actor:${actor.user.uuid}:${month}`; + const { res } = await server.stores.kv.get({ key }); + expect(res).toMatchObject({ total: 100 }); + }); + + it('persists addons under the policy prefix', async () => { + await target.updateAddonCredit(actor.user.uuid, 250); + const key = `${POLICY_PREFIX}:actor:${actor.user.uuid}:addons`; + const { res } = await server.stores.kv.get({ key }); + expect(res).toMatchObject({ purchasedCredits: 250 }); + }); + + it('persists lastUpdated after an increment', async () => { + const userId = actor.user.uuid; + await target.incrementUsage(actor, 'kv:read', 1, 50); + await waitFor(async () => { + const { res } = await server.stores.kv.get({ + key: `${METRICS_PREFIX}:actor:${userId}:lastUpdated`, + }); + expect(typeof res).toBe('number'); + expect(res).toBeGreaterThan(0); + }); + }); + }); + + // ── Resolver registration ──────────────────────────────────────── + + describe('resolver registration', () => { + it('a default resolver that throws does not break subscription resolution', async () => { + target.registerDefaultSubscriptionResolver(async () => { + throw new Error('boom'); + }); + const policy = await target.getActorSubscription(actor); + expect(policy.id).toBe(DEFAULT_FREE_SUBSCRIPTION); + }); + }); +}); diff --git a/src/backend/services/metering/MeteringService.ts b/src/backend/services/metering/MeteringService.ts index ef83e0753..48f5c4c80 100644 --- a/src/backend/services/metering/MeteringService.ts +++ b/src/backend/services/metering/MeteringService.ts @@ -18,10 +18,10 @@ */ import murmurhash from 'murmurhash'; -import { PuterService } from '../types'; import type { Actor } from '../../core/actor'; import { isSystemActor } from '../../core/actor'; import { HttpError } from '../../core/http/HttpError.js'; +import { PuterService } from '../types'; import { DEFAULT_FREE_SUBSCRIPTION, DEFAULT_TEMP_SUBSCRIPTION, @@ -697,7 +697,7 @@ export class MeteringService extends PuterService { ...this.extraPolicies, ...SUB_POLICIES, ...(this.config.unlimitedMetering ? [UNLIMITED_SUBSCRIPTION] : []), - ]; + ] as SubscriptionPolicy[]; return ( availablePolicies.find((p) => p.id === resolvedUser) ?? availablePolicies.find((p) => p.id === resolvedDefault)! diff --git a/src/backend/services/selfhosted/DefaultUserService.ts b/src/backend/services/selfhosted/DefaultUserService.ts index 7a6ee6151..41ade8d31 100644 --- a/src/backend/services/selfhosted/DefaultUserService.ts +++ b/src/backend/services/selfhosted/DefaultUserService.ts @@ -43,6 +43,7 @@ const ADMIN_STORAGE_BYTES = 10 * 1024 * 1024 * 1024; */ export class DefaultUserService extends PuterService { override async onServerStart(): Promise { + if (this.config.no_default_user) return; let user = await this.stores.user.getByUsername(USERNAME); let tmpPassword: string; diff --git a/src/backend/stores/systemKv/SystemKVStore.test.ts b/src/backend/stores/systemKv/SystemKVStore.test.ts new file mode 100644 index 000000000..5c6cb69c4 --- /dev/null +++ b/src/backend/stores/systemKv/SystemKVStore.test.ts @@ -0,0 +1,559 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { setupTestServer } from '../../testUtil.ts'; +import type { SystemKVStore } from './SystemKVStore.ts'; +import { PuterServer } from '../../server.ts'; +import type { Actor } from '../../core/actor.ts'; + +describe('SystemKVStore', () => { + let server: PuterServer; + let target: SystemKVStore; + + beforeAll(async () => { + server = await setupTestServer(); + target = server.stores.kv; + }); + + afterAll(async () => { + await server.shutdown(); + }); + + // Each test runs against a fresh actor namespace so state from one test + // never leaks into another. Actors are cheap; creating a unique uuid per + // test gives full isolation without flush() teardown ceremony. + let actor: Actor; + let opts: { actor: Actor }; + beforeEach(() => { + actor = { + user: { uuid: `test-user-${Math.random().toString(36).slice(2)}` }, + }; + opts = { actor }; + }); + + describe('set / get', () => { + it('round-trips a value through the system namespace', async () => { + await target.set({ key: 'systemKey', value: 'systemValue' }); + const value = await target.get({ key: 'systemKey' }); + expect(value.res).toBe('systemValue'); + }); + + it('returns null for a missing key', async () => { + const result = await target.get({ key: 'doesNotExist' }, opts); + expect(result.res).toBeNull(); + }); + + it('overwrites a previously-set value', async () => { + await target.set({ key: 'k', value: 'first' }, opts); + await target.set({ key: 'k', value: 'second' }, opts); + const result = await target.get({ key: 'k' }, opts); + expect(result.res).toBe('second'); + }); + + it('stores complex object values', async () => { + const value = { nested: { count: 1 }, items: [1, 2, 3] }; + await target.set({ key: 'obj', value }, opts); + const result = await target.get({ key: 'obj' }, opts); + expect(result.res).toEqual(value); + }); + + it('rejects an empty key', async () => { + await expect( + target.set({ key: '', value: 'x' }, opts), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects a key over 1024 bytes', async () => { + const oversized = 'a'.repeat(1025); + await expect( + target.set({ key: oversized, value: 'x' }, opts), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects a value over the size limit', async () => { + const huge = 'a'.repeat(400 * 1024); + await expect( + target.set({ key: 'big', value: huge }, opts), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('treats a value with an already-elapsed TTL as missing on read', async () => { + const past = Math.floor(Date.now() / 1000) - 10; + await target.set( + { key: 'expired', value: 'gone', expireAt: past }, + opts, + ); + const result = await target.get({ key: 'expired' }, opts); + expect(result.res).toBeNull(); + }); + + it('isolates values by actor namespace', async () => { + const otherActor: Actor = { user: { uuid: 'other-user-uuid' } }; + await target.set({ key: 'shared', value: 'mine' }, opts); + const otherResult = await target.get( + { key: 'shared' }, + { actor: otherActor }, + ); + expect(otherResult.res).toBeNull(); + }); + + it('returns an array of values when called with an array of keys', async () => { + await target.set({ key: 'a', value: 1 }, opts); + await target.set({ key: 'b', value: 2 }, opts); + const result = await target.get( + { key: ['a', 'b', 'missing'] }, + opts, + ); + expect(result.res).toEqual([1, 2, null]); + }); + }); + + describe('batchPut', () => { + it('writes multiple items and they read back', async () => { + await target.batchPut( + { + items: [ + { key: 'bp1', value: 'v1' }, + { key: 'bp2', value: 'v2' }, + { key: 'bp3', value: { nested: true } }, + ], + }, + opts, + ); + const result = await target.get( + { key: ['bp1', 'bp2', 'bp3'] }, + opts, + ); + expect(result.res).toEqual(['v1', 'v2', { nested: true }]); + }); + + it('is a no-op for an empty items array', async () => { + const result = await target.batchPut({ items: [] }, opts); + expect(result.res).toBe(true); + }); + + it('deduplicates by key, keeping the last value for repeated keys', async () => { + await target.batchPut( + { + items: [ + { key: 'dup', value: 'first' }, + { key: 'dup', value: 'last' }, + ], + }, + opts, + ); + const result = await target.get({ key: 'dup' }, opts); + expect(result.res).toBe('last'); + }); + + it('rejects when any item has an oversized key', async () => { + await expect( + target.batchPut( + { + items: [ + { key: 'ok', value: 1 }, + { key: 'a'.repeat(1025), value: 2 }, + ], + }, + opts, + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + }); + + describe('del', () => { + it('removes a previously-set key', async () => { + await target.set({ key: 'gone', value: 'bye' }, opts); + await target.del({ key: 'gone' }, opts); + const result = await target.get({ key: 'gone' }, opts); + expect(result.res).toBeNull(); + }); + + it('is idempotent when deleting a missing key', async () => { + const result = await target.del({ key: 'never-existed' }, opts); + expect(result.res).toBe(true); + }); + }); + + describe('list', () => { + beforeEach(async () => { + await target.batchPut( + { + items: [ + { key: 'fruit:apple', value: 'red' }, + { key: 'fruit:banana', value: 'yellow' }, + { key: 'veg:carrot', value: 'orange' }, + ], + }, + opts, + ); + }); + + it('returns key/value entries by default', async () => { + const result = await target.list({}, opts); + expect(Array.isArray(result.res)).toBe(true); + expect(result.res).toEqual( + expect.arrayContaining([ + { key: 'fruit:apple', value: 'red' }, + { key: 'fruit:banana', value: 'yellow' }, + { key: 'veg:carrot', value: 'orange' }, + ]), + ); + }); + + it('returns just keys when as=keys', async () => { + const result = await target.list({ as: 'keys' }, opts); + expect(result.res).toEqual( + expect.arrayContaining([ + 'fruit:apple', + 'fruit:banana', + 'veg:carrot', + ]), + ); + }); + + it('returns just values when as=values', async () => { + const result = await target.list({ as: 'values' }, opts); + expect(result.res).toEqual( + expect.arrayContaining(['red', 'yellow', 'orange']), + ); + }); + + it('rejects an unsupported as= value', async () => { + await expect( + // @ts-expect-error intentionally bad input + target.list({ as: 'bogus' }, opts), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('filters by a wildcard prefix pattern', async () => { + const result = await target.list( + { as: 'keys', pattern: 'fruit:*' }, + opts, + ); + expect(result.res).toEqual( + expect.arrayContaining(['fruit:apple', 'fruit:banana']), + ); + expect(result.res as string[]).not.toContain('veg:carrot'); + }); + + it('returns a paginated envelope when limit is supplied', async () => { + const result = await target.list({ limit: 1 }, opts); + const envelope = result.res as { + items: unknown[]; + cursor?: string; + }; + expect(envelope.items.length).toBe(1); + // With three items and limit 1 there should be a continuation cursor + expect(typeof envelope.cursor).toBe('string'); + }); + + it('rejects a non-positive limit', async () => { + await expect( + target.list({ limit: 0 }, opts), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects a malformed cursor', async () => { + await expect( + target.list({ cursor: 'not-base64-or-json' }, opts), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('omits TTL-expired entries', async () => { + await target.set( + { + key: 'short-lived', + value: 'old', + expireAt: Math.floor(Date.now() / 1000) - 10, + }, + opts, + ); + const result = await target.list({ as: 'keys' }, opts); + expect(result.res as string[]).not.toContain('short-lived'); + }); + }); + + describe('flush', () => { + it('removes every key in the actor namespace', async () => { + await target.batchPut( + { + items: [ + { key: 'f1', value: 1 }, + { key: 'f2', value: 2 }, + ], + }, + opts, + ); + await target.flush(opts); + const result = await target.list({ as: 'keys' }, opts); + expect(result.res).toEqual([]); + }); + + it('only flushes the calling actor namespace', async () => { + const otherActor: Actor = { user: { uuid: 'flush-other-user' } }; + await target.set({ key: 'mine', value: 1 }, opts); + await target.set( + { key: 'theirs', value: 2 }, + { actor: otherActor }, + ); + + await target.flush(opts); + + const mine = await target.get({ key: 'mine' }, opts); + const theirs = await target.get( + { key: 'theirs' }, + { actor: otherActor }, + ); + expect(mine.res).toBeNull(); + expect(theirs.res).toBe(2); + }); + }); + + describe('expireAt / expire', () => { + it('expireAt makes a key invisible once the timestamp passes', async () => { + await target.set({ key: 'fade', value: 'soon' }, opts); + await target.expireAt( + { key: 'fade', timestamp: Math.floor(Date.now() / 1000) - 5 }, + opts, + ); + const result = await target.get({ key: 'fade' }, opts); + expect(result.res).toBeNull(); + }); + + it('expire computes the TTL relative to now', async () => { + await target.set({ key: 'fade2', value: 'soon' }, opts); + // negative TTL is effectively expired + await target.expire({ key: 'fade2', ttl: -10 }, opts); + const result = await target.get({ key: 'fade2' }, opts); + expect(result.res).toBeNull(); + }); + + it('rejects an empty key', async () => { + await expect( + target.expireAt({ key: '', timestamp: 0 }, opts), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + }); + + describe('incr / decr', () => { + it('increments a top-level numeric counter from zero', async () => { + const result = await target.incr( + { key: 'counter', pathAndAmountMap: { hits: 1 } }, + opts, + ); + expect(result.res).toMatchObject({ hits: 1 }); + }); + + it('accumulates across calls', async () => { + await target.incr( + { key: 'counter2', pathAndAmountMap: { hits: 2 } }, + opts, + ); + const result = await target.incr( + { key: 'counter2', pathAndAmountMap: { hits: 3 } }, + opts, + ); + expect(result.res).toMatchObject({ hits: 5 }); + }); + + it('increments nested paths and creates intermediate maps', async () => { + const result = await target.incr( + { + key: 'metrics', + pathAndAmountMap: { 'page.views': 4 }, + }, + opts, + ); + expect(result.res).toMatchObject({ page: { views: 4 } }); + }); + + it('decr subtracts via the same machinery', async () => { + await target.incr( + { key: 'counter3', pathAndAmountMap: { hits: 10 } }, + opts, + ); + const result = await target.decr( + { key: 'counter3', pathAndAmountMap: { hits: 3 } }, + opts, + ); + expect(result.res).toMatchObject({ hits: 7 }); + }); + + it('rejects when pathAndAmountMap is missing', async () => { + await expect( + target.incr( + { + key: 'k', + // @ts-expect-error intentionally bad input + pathAndAmountMap: undefined, + }, + opts, + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects when any value in pathAndAmountMap is not a number', async () => { + await expect( + target.incr( + { + key: 'k', + // @ts-expect-error intentionally bad input + pathAndAmountMap: { x: 'nope' }, + }, + opts, + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + }); + + describe('add', () => { + it('appends a single element to an empty path, creating a new list', async () => { + const result = await target.add( + { key: 'list1', pathAndValueMap: { items: 'a' } }, + opts, + ); + expect(result.res).toMatchObject({ items: ['a'] }); + }); + + it('appends an array to an existing list', async () => { + await target.add( + { key: 'list2', pathAndValueMap: { items: ['a'] } }, + opts, + ); + const result = await target.add( + { key: 'list2', pathAndValueMap: { items: ['b', 'c'] } }, + opts, + ); + expect(result.res).toMatchObject({ items: ['a', 'b', 'c'] }); + }); + + it('rejects when pathAndValueMap is empty', async () => { + await expect( + target.add({ key: 'k', pathAndValueMap: {} }, opts), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + }); + + describe('update', () => { + it('sets a top-level path on a fresh key', async () => { + const result = await target.update( + { + key: 'doc', + pathAndValueMap: { name: 'puter' }, + }, + opts, + ); + expect(result.res).toMatchObject({ name: 'puter' }); + }); + + it('writes nested paths and creates intermediate maps', async () => { + const result = await target.update( + { + key: 'doc2', + pathAndValueMap: { 'profile.email': 'a@b.com' }, + }, + opts, + ); + expect(result.res).toMatchObject({ + profile: { email: 'a@b.com' }, + }); + }); + + it('preserves untouched fields when updating a single path', async () => { + await target.update( + { + key: 'doc3', + pathAndValueMap: { name: 'first', age: 1 }, + }, + opts, + ); + const result = await target.update( + { key: 'doc3', pathAndValueMap: { age: 2 } }, + opts, + ); + expect(result.res).toMatchObject({ name: 'first', age: 2 }); + }); + + it('applies a TTL when ttl is supplied', async () => { + await target.update( + { + key: 'doc4', + pathAndValueMap: { name: 'temp' }, + ttl: -10, + }, + opts, + ); + const result = await target.get({ key: 'doc4' }, opts); + expect(result.res).toBeNull(); + }); + + it('rejects an empty pathAndValueMap', async () => { + await expect( + target.update({ key: 'k', pathAndValueMap: {} }, opts), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects a non-numeric ttl', async () => { + await expect( + target.update( + { + key: 'k', + pathAndValueMap: { x: 1 }, + ttl: Number.NaN, + }, + opts, + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + }); + + describe('remove', () => { + it('removes a path that exists', async () => { + await target.update( + { + key: 'doc-rm', + pathAndValueMap: { keep: 1, drop: 2 }, + }, + opts, + ); + const result = await target.remove( + { key: 'doc-rm', paths: ['drop'] }, + opts, + ); + expect(result.res).toMatchObject({ keep: 1 }); + expect(result.res).not.toHaveProperty('drop'); + }); + + it('treats a missing path as a no-op and returns current value', async () => { + await target.update( + { key: 'doc-rm2', pathAndValueMap: { keep: 1 } }, + opts, + ); + const result = await target.remove( + { key: 'doc-rm2', paths: ['never.was.here'] }, + opts, + ); + expect(result.res).toMatchObject({ keep: 1 }); + }); + + it('rejects when paths is empty', async () => { + await expect( + target.remove({ key: 'k', paths: [] }, opts), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + }); + + describe('usage accounting', () => { + it('reports write usage on set and read usage on get', async () => { + const setRes = await target.set( + { key: 'usage-k', value: 'v' }, + opts, + ); + expect(setRes.usage.write).toBeGreaterThanOrEqual(0); + expect(setRes.usage.read).toBe(0); + + const getRes = await target.get({ key: 'usage-k' }, opts); + expect(getRes.usage.read).toBeGreaterThanOrEqual(0); + expect(getRes.usage.write).toBe(0); + }); + }); +}); diff --git a/src/backend/testUtil.ts b/src/backend/testUtil.ts new file mode 100644 index 000000000..77e149e9f --- /dev/null +++ b/src/backend/testUtil.ts @@ -0,0 +1,29 @@ +import { deepMerge } from '../../tools/lib/configMigration.mjs'; +import { PuterServer } from './server'; +import { IConfig } from './types'; + +export const setupTestServer = async (configOverrides?: IConfig) => { + // read default config json + const defaultConfig = await import('../../config.default.json', { + with: { + type: 'json', + }, + }); + // merge default config with overrides and test defaults + const config = deepMerge( + deepMerge(defaultConfig, { + extensions: [], + port: 0, + database: { engine: 'sqlite', inMemory: true }, + dynamo: { inMemory: true, bootstrapTables: true }, + redis: { useMock: true }, + s3: { localConfig: { inMemory: true } }, + no_default_user: true, + no_devwatch: true, + }), + configOverrides ?? {}, + ); + const server = new PuterServer(config); + await server.start(true); + return server; +}; diff --git a/src/backend/types.ts b/src/backend/types.ts index f9f2c28ff..14a27c8e9 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -28,7 +28,19 @@ export interface IAWSCredentials { export interface IDynamoConfig { aws?: IAWSCredentials; endpoint?: string; + /** + * Filesystem path for the local dynalite store. Defaults to + * `./volatile/runtime/puter-ddb`. Pass `':memory:'` (or set + * `inMemory: true`) to run dynalite without persistence — the + * recommended setup for unit/integration tests. + */ path?: string; + /** + * Run dynalite in-memory with no on-disk state. Equivalent to + * `path: ':memory:'`. Intended for tests so each suite gets a + * pristine in-process DynamoDB. + */ + inMemory?: boolean; /** * Create required tables on startup if they don't exist. Off by * default because real-AWS deployments provision tables externally @@ -48,6 +60,12 @@ export interface IRedisConfig { * ElastiCache). Set `false` for self-host plain-TCP Valkey/Redis. */ tls?: boolean; + /** + * Use ioredis-mock instead of a real Redis cluster — fully + * in-process, no network. Defaults to `true` when `startupNodes` + * is empty (so tests with no redis config get a mock for free). + * Intended for unit/integration tests. + */ useMock?: boolean; } @@ -245,6 +263,11 @@ export interface IServerHealthConfig { } export interface IS3LocalConfig { + /** + * Run fauxqs entirely in-memory: random port on `127.0.0.1`, no + * `dataDir` / `s3StorageDir`. Intended for tests so each suite + * gets a pristine in-process S3. + */ inMemory?: boolean; host?: string; port?: number; @@ -283,7 +306,18 @@ export interface IS3Config { export interface IDatabaseConfig { engine: 'sqlite' | 'mysql'; // sqlite + /** + * SQLite database file path. Defaults to `':memory:'` (the + * better-sqlite3 in-memory mode), which is also what tests should + * use. `inMemory: true` is an explicit alias for the same. + */ path?: string; + /** + * Force in-memory SQLite (ignores `path`). Equivalent to + * `path: ':memory:'`. Intended for tests so each suite gets a + * pristine in-process database. + */ + inMemory?: boolean; targetVersion?: number; // mysql host?: string; @@ -432,6 +466,11 @@ interface IConfigOptional { no_browser_launch: boolean; /** Disable dev-time frontend webpack watchers. */ no_devwatch: boolean; + /** + * Skip first-boot bootstrap of the `admin` user and the credentials + * banner that DefaultUserService prints. Intended for tests. + */ + no_default_user: boolean; /** Optional dev-time frontend watcher overrides. */ devwatch: IDevWatcherConfig; diff --git a/src/backend/util/nativeImport.ts b/src/backend/util/nativeImport.ts index 1dee3c137..195aa1453 100644 --- a/src/backend/util/nativeImport.ts +++ b/src/backend/util/nativeImport.ts @@ -17,7 +17,12 @@ * along with this program. If not, see . */ -export const nativeImport = new Function( - 'specifier', - 'return import(specifier)', -) as (specifier: string) => Promise; +// `new Function('return import(s)')` would defeat any static bundler +// analysis, but the resulting function inherits a vm context with no +// HostImportModuleDynamically callback under vitest, which throws +// ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING. A direct `import()` with +// `@vite-ignore` accomplishes the same thing — vite skips analysis, +// node performs the import — and works in tests. +export const nativeImport = ( + specifier: string, +): Promise => import(/* @vite-ignore */ specifier) as Promise; diff --git a/src/backend/vitest.config.ts b/src/backend/vitest.config.ts index 097f09c0f..92bebab1d 100644 --- a/src/backend/vitest.config.ts +++ b/src/backend/vitest.config.ts @@ -18,12 +18,58 @@ */ // vite.config.ts - Vite configuration for Puter API tests (TypeScript) +import path from 'node:path'; +import { transform } from 'esbuild'; import { loadEnv } from 'vite'; import { defineConfig } from 'vitest/config'; const isCi = process.env.CI === 'true'; +const backendDir = __dirname; +const repoRoot = path.resolve(backendDir, '../..'); + +// Vite 8's oxc transform leaves TC39 stage-3 decorators in place +// (used by `@Controller`/`@Post`), so they reach Node verbatim and +// crash with "SyntaxError: Invalid or unexpected token". Pre-transform +// `.ts`/`.mts` source through esbuild — which DOES lower stage-3 +// decorators — locked to `es2024` to match `tsconfig.json`'s target. +const lowerDecoratorsPlugin = { + name: 'puter:lower-decorators', + enforce: 'pre' as const, + async transform(code: string, id: string) { + if (id.includes('/node_modules/')) return null; + if (!/\.(m?ts)$/.test(id)) return null; + if (!code.includes('@')) return null; + const result = await transform(code, { + loader: 'ts', + target: 'es2024', + sourcefile: id, + sourcemap: 'inline', + }); + return { code: result.code, map: null }; + }, +}; export default defineConfig(({ mode }) => ({ + plugins: [lowerDecoratorsPlugin], + resolve: { + // Mirror the `@heyputer/backend` path aliases declared in + // tsconfig.json so backend code under test can use the same + // imports it does in production. + alias: [ + { + find: /^@heyputer\/backend\/src\/(.*)$/, + replacement: path.join(backendDir, '$1'), + }, + { + find: /^@heyputer\/backend\/(.*)$/, + replacement: path.join(backendDir, '$1'), + }, + { + find: /^@heyputer\/backend$/, + replacement: path.join(backendDir, 'exports.ts'), + }, + ], + }, test: { globals: true, coverage: { @@ -32,24 +78,22 @@ export default defineConfig(({ mode }) => ({ ? ['json', 'json-summary', 'lcov'] : ['text', 'json', 'json-summary', 'html', 'lcov'], excludeAfterRemap: true, - // Keep coverage focused on executed files to avoid high-memory - // uncovered-file remapping in CI. - exclude: [ - 'src/**/types/**', - 'src/**/constants/**', - 'src/**/*.d.ts', - 'src/**/*.d.mts', - 'src/**/*.d.cts', - 'src/**/dist/**', - 'src/**/*.min.*', - 'src/**/*.bench.{js,mjs,ts,mts}', - 'src/**/*.{test,spec}.{js,mjs,ts,mts}', - 'src/public/**', - 'src/services/worker/template/**', + // Listing both trees explicitly ensures untested files show + // as 0% instead of being silently dropped from the report. + include: [ + 'src/backend/**/*.{js,ts}', + 'extensions/**/*.{js,ts}', ], + reportsDirectory: path.join(backendDir, 'coverage'), }, env: loadEnv(mode, '', 'PUTER_'), - include: ['**/*.{test,spec}.{ts,js}'], - root: __dirname, // Ensures paths are relative to backend/ + include: [ + 'src/backend/**/*.test.{js,ts}', + 'extensions/**/*.test.{js,ts}', + ], + // Root is the repo root so that the file transformer (which + // applies `lowerDecoratorsPlugin`) sees both src/backend and + // extensions/ — vitest skips transform for files outside root. + root: repoRoot, }, }));