1
0
mirror of https://github.com/pocket-id/pocket-id.git synced 2026-02-04 11:36:46 +00:00

feat: add CLI command for importing and exporting Pocket ID data (#998)

Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Elias Schneider
2025-11-26 10:38:15 +01:00
parent f0144584af
commit 3420a00073
56 changed files with 3178 additions and 643 deletions

View File

@@ -8,9 +8,13 @@
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@types/node": "^24.10.1",
"@types/adm-zip": "^0.5.7",
"@types/node": "^22.18.12",
"dotenv": "^17.2.3",
"jose": "^6.1.2",
"prettier": "^3.7.0"
},
"dependencies": {
"adm-zip": "^0.5.16"
}
}

View File

@@ -21,11 +21,15 @@ export default defineConfig({
trace: 'on-first-retry'
},
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{ name: 'cli', testMatch: /cli\.spec\.ts/ },
{ name: 'auth-setup', testMatch: /auth\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: '.auth/user.json' },
dependencies: ['setup']
name: 'browser-chrome',
use: { ...devices['Desktop Chrome'], storageState: '.tmp/auth/user.json' },
testIgnore: /cli\.spec\.ts/,
dependencies: ['auth-setup']
}
]
],
globalSetup: './specs/fixtures/global.setup.ts',
globalTeardown: './specs/fixtures/global.teardown.ts'
});

View File

@@ -0,0 +1,312 @@
{
"provider": "sqlite",
"version": 20251117141000,
"tableOrder": ["users", "user_groups", "oidc_clients"],
"tables": {
"api_keys": [
{
"created_at": "2025-11-25T12:39:02Z",
"description": null,
"expiration_email_sent": false,
"expires_at": "2025-12-25T12:39:02Z",
"id": "5f1fa856-c164-4295-961e-175a0d22d725",
"key": "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20",
"last_used_at": null,
"name": "Test API Key",
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
}
],
"app_config_variables": [
{
"key": "instanceId",
"value": "test-instance-id"
}
],
"kv": [
{
"key": "jwt_private_key.json",
"value": "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"
}
],
"oidc_authorization_codes": [
{
"client_id": "3654a746-35d4-4321-ac61-0bdcff2b4055",
"code": "auth-code",
"code_challenge": null,
"code_challenge_method_sha256": null,
"created_at": "2025-11-25T12:39:02Z",
"expires_at": "2025-11-25T13:39:02Z",
"id": "6bdd221e-d9f7-4e3d-92c0-4be125802ba2",
"nonce": "nonce",
"scope": "openid profile",
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
},
{
"client_id": "7c21a609-96b5-4011-9900-272b8d31a9d1",
"code": "federated",
"code_challenge": null,
"code_challenge_method_sha256": null,
"created_at": "2025-11-25T12:39:02Z",
"expires_at": "2025-11-25T13:39:02Z",
"id": "37e914bd-ff2c-4653-8cd8-550f0213e430",
"nonce": "nonce",
"scope": "openid profile",
"user_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036"
}
],
"oidc_clients": [
{
"callback_urls": "WyJodHRwOi8vbmV4dGNsb3VkL2F1dGgvY2FsbGJhY2siXQ==",
"created_at": "2025-11-25T12:39:02Z",
"created_by_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
"credentials": "e30=",
"dark_image_type": null,
"id": "3654a746-35d4-4321-ac61-0bdcff2b4055",
"image_type": "png",
"is_public": false,
"launch_url": "https://nextcloud.local",
"logout_callback_urls": "WyJodHRwOi8vbmV4dGNsb3VkL2F1dGgvbG9nb3V0L2NhbGxiYWNrIl0=",
"name": "Nextcloud",
"pkce_enabled": false,
"requires_reauthentication": false,
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC"
},
{
"callback_urls": "WyJodHRwOi8vaW1taWNoL2F1dGgvY2FsbGJhY2siXQ==",
"created_at": "2025-11-25T12:39:02Z",
"created_by_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
"credentials": "e30=",
"dark_image_type": null,
"id": "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
"image_type": null,
"is_public": false,
"launch_url": null,
"logout_callback_urls": "bnVsbA==",
"name": "Immich",
"pkce_enabled": false,
"requires_reauthentication": false,
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe"
},
{
"callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlL2F1dGgvY2FsbGJhY2siXQ==",
"created_at": "2025-11-25T12:39:02Z",
"created_by_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
"credentials": "e30=",
"dark_image_type": null,
"id": "7c21a609-96b5-4011-9900-272b8d31a9d1",
"image_type": null,
"is_public": false,
"launch_url": null,
"logout_callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlL2F1dGgvbG9nb3V0L2NhbGxiYWNrIl0=",
"name": "Tailscale",
"pkce_enabled": false,
"requires_reauthentication": false,
"secret": "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a"
},
{
"callback_urls": "WyJodHRwOi8vZmVkZXJhdGVkL2F1dGgvY2FsbGJhY2siXQ==",
"created_at": "2025-11-25T12:39:02Z",
"created_by_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
"credentials": "eyJmZWRlcmF0ZWRJZGVudGl0aWVzIjpbeyJpc3N1ZXIiOiJodHRwczovL2V4dGVybmFsLWlkcC5sb2NhbCIsInN1YmplY3QiOiJjNDgyMzJmZi1mZjY1LTQ1ZWQtYWU5Ni03YWZhOGE5YjQ0M2IiLCJhdWRpZW5jZSI6ImFwaTovL1BvY2tldElEIiwiandrcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6MTQxMS9hcGkvZXh0ZXJuYWxpZHAvandrcy5qc29uIn1dfQ==",
"dark_image_type": null,
"id": "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
"image_type": null,
"is_public": false,
"launch_url": null,
"logout_callback_urls": "bnVsbA==",
"name": "Federated",
"pkce_enabled": false,
"requires_reauthentication": false,
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe"
}
],
"oidc_clients_allowed_user_groups": [
{
"oidc_client_id": "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
"user_group_id": "adab18bf-f89d-4087-9ee1-70ff15b48211"
}
],
"oidc_refresh_tokens": [
{
"client_id": "3654a746-35d4-4321-ac61-0bdcff2b4055",
"created_at": "2025-11-25T12:39:02Z",
"expires_at": "2025-11-26T12:39:02Z",
"id": "4928604e-e689-410c-9b25-5b9b6db9e46e",
"scope": "openid profile email",
"token": "fef6e2e37eb990f0bd7abd48a41d530c54b6a1f139b556e35e62475e6f4cb38d",
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
}
],
"one_time_access_tokens": [
{
"created_at": "2025-11-25T12:39:02Z",
"expires_at": "2025-11-25T13:39:02Z",
"id": "bf877753-4ea4-4c9c-bbbd-e198bb201cb8",
"token": "HPe6k6uiDRRVuAQV",
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
},
{
"created_at": "2025-11-25T12:39:02Z",
"expires_at": "2025-11-25T12:39:01Z",
"id": "d3afae24-fe2d-4a98-abec-cf0b8525096a",
"token": "YCGDtftvsvYWiXd0",
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
},
{
"created_at": "2025-11-25T12:39:02Z",
"expires_at": "2025-11-25T13:39:02Z",
"id": "defd5164-9d9b-4228-bbce-708e33f49360",
"token": "one-time-token",
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
}
],
"signup_tokens": [
{
"created_at": "2025-11-25T12:39:02Z",
"expires_at": "2025-11-26T12:39:02Z",
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"token": "VALID1234567890A",
"usage_count": 0,
"usage_limit": 1
},
{
"created_at": "2025-11-25T12:39:02Z",
"expires_at": "2025-12-02T12:39:02Z",
"id": "dc3c9c96-714e-48eb-926e-2d7c7858e6cf",
"token": "PARTIAL567890ABC",
"usage_count": 2,
"usage_limit": 5
},
{
"created_at": "2025-11-25T12:39:02Z",
"expires_at": "2025-11-24T12:39:02Z",
"id": "44de1863-ffa5-4db1-9507-4887cd7a1e3f",
"token": "EXPIRED34567890B",
"usage_count": 1,
"usage_limit": 3
},
{
"created_at": "2025-11-25T12:39:02Z",
"expires_at": "2025-11-26T12:39:02Z",
"id": "f1b1678b-7720-4d8b-8f91-1dbff1e2d02b",
"token": "FULLYUSED567890C",
"usage_count": 1,
"usage_limit": 1
}
],
"user_authorized_oidc_clients": [
{
"client_id": "3654a746-35d4-4321-ac61-0bdcff2b4055",
"last_used_at": "2025-08-01T13:00:00Z",
"scope": "openid profile email",
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
},
{
"client_id": "7c21a609-96b5-4011-9900-272b8d31a9d1",
"last_used_at": "2025-08-10T14:00:00Z",
"scope": "openid profile email",
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
},
{
"client_id": "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
"last_used_at": "2025-08-12T12:00:00Z",
"scope": "openid profile email",
"user_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036"
}
],
"user_groups": [
{
"created_at": "2025-11-25T12:39:02Z",
"friendly_name": "Developers",
"id": "c7ae7c01-28a3-4f3c-9572-1ee734ea8368",
"ldap_id": null,
"name": "developers"
},
{
"created_at": "2025-11-25T12:39:02Z",
"friendly_name": "Designers",
"id": "adab18bf-f89d-4087-9ee1-70ff15b48211",
"ldap_id": null,
"name": "designers"
}
],
"user_groups_users": [
{
"user_group_id": "c7ae7c01-28a3-4f3c-9572-1ee734ea8368",
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
},
{
"user_group_id": "c7ae7c01-28a3-4f3c-9572-1ee734ea8368",
"user_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036"
},
{
"user_group_id": "adab18bf-f89d-4087-9ee1-70ff15b48211",
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
}
],
"users": [
{
"created_at": "2025-11-25T12:39:02Z",
"disabled": false,
"display_name": "Tim Cook",
"email": "tim.cook@test.com",
"first_name": "Tim",
"id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
"is_admin": true,
"last_name": "Cook",
"ldap_id": null,
"locale": null,
"username": "tim"
},
{
"created_at": "2025-11-25T12:39:02Z",
"disabled": false,
"display_name": "Craig Federighi",
"email": "craig.federighi@test.com",
"first_name": "Craig",
"id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
"is_admin": false,
"last_name": "Federighi",
"ldap_id": null,
"locale": null,
"username": "craig"
}
],
"webauthn_credentials": [
{
"attestation_type": "none",
"backup_eligible": false,
"backup_state": false,
"created_at": "2025-11-25T12:39:02Z",
"credential_id": "dGVzdC1jcmVkZW50aWFsLXRpbQ==",
"id": "fa7977f9-7cf8-40fa-abca-42b917b6e692",
"name": "Passkey 1",
"public_key": "pQMmIAEhWCDBw6jkpXXr0pHrtAQetxiR5cTcILG/YGDCdKrhVhNDHCJYIIu12YrF6B7Frwl3AUqEpdrYEwj3Fo3XkGgvrBIJEUmGAQI=",
"transport": "WyJpbnRlcm5hbCJd",
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
},
{
"attestation_type": "none",
"backup_eligible": false,
"backup_state": false,
"created_at": "2025-11-25T12:39:02Z",
"credential_id": "dGVzdC1jcmVkZW50aWFsLWNyYWln",
"id": "4bcc54ef-01d1-4970-be51-669ccd8c0198",
"name": "Passkey 2",
"public_key": "pSJYIPmc+FlEB0neERqqscxKckGF8yq1AYrANiloshAUAouHAQIDJiABIVggj4qA0PrZzg8Co1C27nyUbzrp8Ewjr7eOlGI2LfrzmbI=",
"transport": "WyJpbnRlcm5hbCJd",
"user_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036"
}
],
"webauthn_sessions": [
{
"challenge": "challenge",
"created_at": "2025-11-25T12:39:02Z",
"credential_params": "W3sidHlwZSI6InB1YmxpYy1rZXkiLCJhbGciOi03fSx7InR5cGUiOiJwdWJsaWMta2V5IiwiYWxnIjotMjU3fV0=",
"expires_at": "2025-11-25T13:39:02Z",
"id": "267f6907-7bc8-4ea1-9d47-c42a172dc1c7",
"user_verification": "preferred"
}
]
}
}

View File

@@ -0,0 +1 @@
../../../../backend/resources/images/

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 528 KiB

After

Width:  |  Height:  |  Size: 528 KiB

View File

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -11,7 +11,7 @@ services:
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=pocket-id
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
@@ -27,3 +27,6 @@ services:
depends_on:
postgres:
condition: service_healthy
volumes:
pocket-id-test-data:

View File

@@ -38,3 +38,6 @@ services:
depends_on:
create-bucket:
condition: service_completed_successfully
volumes:
pocket-id-test-data:

View File

@@ -11,13 +11,18 @@ services:
pocket-id:
image: pocket-id:test
ports:
- '1411:1411'
- "1411:1411"
environment:
APP_ENV: test
ENCRYPTION_KEY: test-encryption-key
FILE_BACKEND: ${FILE_BACKEND}
volumes:
- pocket-id-test-data:/app/data
build:
args:
- BUILD_TAGS=e2etest
context: ../..
dockerfile: docker/Dockerfile
volumes:
pocket-id-test-data:

View File

@@ -122,12 +122,12 @@ test.describe('Update application images', () => {
});
test('should upload images', async ({ page }) => {
await page.getByLabel('Favicon').setInputFiles('assets/w3-schools-favicon.ico');
await page.getByLabel('Light Mode Logo').setInputFiles('assets/pingvin-share-logo.png');
await page.getByLabel('Dark Mode Logo').setInputFiles('assets/cloud-logo.png');
await page.getByLabel('Email Logo').setInputFiles('assets/pingvin-share-logo.png');
await page.getByLabel('Default Profile Picture').setInputFiles('assets/pingvin-share-logo.png');
await page.getByLabel('Background Image').setInputFiles('assets/clouds.jpg');
await page.getByLabel('Favicon').setInputFiles('resources/images/w3-schools-favicon.ico');
await page.getByLabel('Light Mode Logo').setInputFiles('resources/images/pingvin-share-logo.png');
await page.getByLabel('Dark Mode Logo').setInputFiles('resources/images/cloud-logo.png');
await page.getByLabel('Email Logo').setInputFiles('resources/images/pingvin-share-logo.png');
await page.getByLabel('Default Profile Picture').setInputFiles('resources/images/pingvin-share-logo.png');
await page.getByLabel('Background Image').setInputFiles('resources/images/clouds.jpg');
await page.getByRole('button', { name: 'Save' }).last().click();
await expect(page.locator('[data-type="success"]')).toHaveText(
@@ -154,7 +154,7 @@ test.describe('Update application images', () => {
test('should only allow png/jpeg for email logo', async ({ page }) => {
const emailLogoInput = page.getByLabel('Email Logo');
await emailLogoInput.setInputFiles('assets/cloud-logo.svg');
await emailLogoInput.setInputFiles('resources/images/cloud-logo.svg');
await page.getByRole('button', { name: 'Save' }).last().click();
await expect(page.locator('[data-type="error"]')).toHaveText(

366
tests/specs/cli.spec.ts Normal file
View File

@@ -0,0 +1,366 @@
import { expect, test } from '@playwright/test';
import AdmZip from 'adm-zip';
import { execFileSync, ExecFileSyncOptions } from 'child_process';
import crypto from 'crypto';
import { users } from 'data';
import fs from 'fs';
import path from 'path';
import { cleanupBackend } from 'utils/cleanup.util';
import { pathFromRoot, tmpDir } from 'utils/fs.util';
const containerName = 'pocket-id';
const setupDir = pathFromRoot('setup');
const exampleExportPath = pathFromRoot('resources/export');
const dockerCommandMaxBuffer = 100 * 1024 * 1024;
let mode: 'sqlite' | 'postgres' | 's3' = 'sqlite';
test.beforeAll(() => {
const dockerComposeLs = runDockerCommand(['compose', 'ls', '--format', 'json']);
if (dockerComposeLs.includes('postgres')) {
mode = 'postgres';
} else if (dockerComposeLs.includes('s3')) {
mode = 's3';
}
console.log(`Running CLI tests in ${mode.toUpperCase()} mode`);
});
test('Export', async ({ baseURL }) => {
// Reset the backend but with LDAP setup because the example export has no LDAP data
await cleanupBackend({ skipLdapSetup: true });
// Fetch the profile pictures because they get generated on demand
await Promise.all([
fetch(`${baseURL}/api/users/${users.craig.id}/profile-picture.png`),
fetch(`${baseURL}/api/users/${users.tim.id}/profile-picture.png`)
]);
// Export the data from the seeded container
const exportPath = path.join(tmpDir, 'export.zip');
const extractPath = path.join(tmpDir, 'export-extracted');
runExport(exportPath);
unzipExport(exportPath, extractPath);
compareExports(exampleExportPath, extractPath);
});
test('Export via stdout', async ({ baseURL }) => {
await cleanupBackend({ skipLdapSetup: true });
await Promise.all([
fetch(`${baseURL}/api/users/${users.craig.id}/profile-picture.png`),
fetch(`${baseURL}/api/users/${users.tim.id}/profile-picture.png`)
]);
const stdoutBuffer = runExportToStdout();
const stdoutExtractPath = path.join(tmpDir, 'export-stdout-extracted');
unzipExportBuffer(stdoutBuffer, stdoutExtractPath);
compareExports(exampleExportPath, stdoutExtractPath);
});
test('Import', async () => {
// Reset the backend without seeding
await cleanupBackend({ skipSeed: true });
// Run the import with the example export data
const exampleExportArchivePath = path.join(tmpDir, 'example-export.zip');
archiveExampleExport(exampleExportArchivePath);
try {
runDockerComposeCommand(['stop', containerName]);
runImport(exampleExportArchivePath);
} finally {
runDockerComposeCommand(['up', '-d', containerName]);
}
// Export again from the imported instance
const exportPath = path.join(tmpDir, 'export.zip');
const exportExtracted = path.join(tmpDir, 'export-extracted');
runExport(exportPath);
unzipExport(exportPath, exportExtracted);
compareExports(exampleExportPath, exportExtracted);
});
test('Import via stdin', async () => {
await cleanupBackend({ skipSeed: true });
const exampleExportArchivePath = path.join(tmpDir, 'example-export-stdin.zip');
const exampleExportBuffer = archiveExampleExport(exampleExportArchivePath);
try {
runDockerComposeCommand(['stop', containerName]);
runImportFromStdin(exampleExportBuffer);
} finally {
runDockerComposeCommand(['up', '-d', containerName]);
}
const exportPath = path.join(tmpDir, 'export-from-stdin.zip');
const exportExtracted = path.join(tmpDir, 'export-from-stdin-extracted');
runExport(exportPath);
unzipExport(exportPath, exportExtracted);
compareExports(exampleExportPath, exportExtracted);
});
function compareExports(dir1: string, dir2: string): void {
const hashes1 = hashAllFiles(dir1);
const hashes2 = hashAllFiles(dir2);
const files1 = Object.keys(hashes1).sort();
const files2 = Object.keys(hashes2).sort();
expect(files2).toEqual(files1);
for (const file of files1) {
expect(hashes2[file], `${file} hash should match`).toEqual(hashes1[file]);
}
// Compare database.json contents
const expectedData = loadJSON(path.join(dir1, 'database.json'));
const actualData = loadJSON(path.join(dir2, 'database.json'));
// Check special fields
validateSpecialFields(actualData);
// Normalize and compare
const normalizedExpected = normalizeJSON(expectedData);
const normalizedActual = normalizeJSON(actualData);
expect(normalizedActual).toEqual(normalizedExpected);
}
function archiveExampleExport(outputPath: string): Buffer {
fs.rmSync(outputPath, { force: true });
const zip = new AdmZip();
const files = fs.readdirSync(exampleExportPath);
for (const file of files) {
const filePath = path.join(exampleExportPath, file);
if (fs.statSync(filePath).isFile()) {
zip.addLocalFile(filePath);
} else if (fs.statSync(filePath).isDirectory()) {
zip.addLocalFolder(filePath, file);
}
}
const buffer = zip.toBuffer();
fs.writeFileSync(outputPath, buffer);
return buffer;
}
// Helper to load JSON files
function loadJSON(path: string) {
return JSON.parse(fs.readFileSync(path, 'utf-8'));
}
function normalizeJSON(obj: any): any {
if (typeof obj === 'string') {
try {
// Normalize JSON strings
const parsed = JSON.parse(atob(obj));
return JSON.stringify(normalizeJSON(parsed));
} catch {
return obj;
}
}
if (Array.isArray(obj)) {
// Sort arrays to make order irrelevant
return obj
.map(normalizeJSON)
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
} else if (obj && typeof obj === 'object') {
const ignoredKeys = ['id', 'created_at', 'expires_at', 'credentials', 'provider', 'version'];
// Sort and normalize object keys, skipping ignored ones
return Object.keys(obj)
.filter((key) => !ignoredKeys.includes(key))
.sort()
.reduce(
(acc, key) => {
acc[key] = normalizeJSON(obj[key]);
return acc;
},
{} as Record<string, any>
);
}
return obj;
}
function validateSpecialFields(obj: any): void {
if (Array.isArray(obj)) {
for (const item of obj) validateSpecialFields(item);
} else if (obj && typeof obj === 'object') {
for (const [key, value] of Object.entries(obj)) {
if (key === 'id') {
expect(isUUID(value), `Expected '${value}' to be a valid UUID`).toBe(true);
} else if (key === 'created_at' || key === 'expires_at') {
expect(
isValidISODate(value),
`Expected '${key}' = ${value} to be a valid ISO 8601 date string`
).toBe(true);
} else if (key === 'provider') {
expect(
['postgres', 'sqlite'].includes(value as string),
`Expected 'provider' to be either 'postgres' or 'sqlite', got '${value}'`
).toBe(true);
} else if (key === 'version') {
expect(value).toBeGreaterThanOrEqual(20251001000000);
} else {
validateSpecialFields(value);
}
}
}
}
function isUUID(value: any): boolean {
if (typeof value !== 'string') return false;
const uuidRegex = /^[^-]{8}-[^-]{4}-[^-]{4}-[^-]{4}-[^-]{12}$/;
return uuidRegex.test(value);
}
function isValidISODate(value: any): boolean {
const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
if (!isoRegex.test(value)) return false;
const date = new Date(value);
return !isNaN(date.getTime());
}
function runImport(pathToFile: string) {
const importContainerId = runDockerComposeCommand([
'run',
'-d',
'-v',
`${pathToFile}:/app/data/pocket-id-export.zip`,
containerName,
'/app/pocket-id',
'import',
'--path',
'/app/data/pocket-id-export.zip',
'--yes'
]);
try {
runDockerCommand(['wait', importContainerId]);
} finally {
runDockerCommand(['rm', '-f', importContainerId]);
}
}
function runImportFromStdin(archive: Buffer): void {
runDockerComposeCommandRaw(
['run', '--rm', '-T', containerName, '/app/pocket-id', 'import', '--yes', '--path', '-'],
{ input: archive }
);
}
function runExport(outputFile: string): void {
const containerId = runDockerComposeCommand([
'run',
'-d',
containerName,
'/app/pocket-id',
'export',
'--path',
'/app/data/pocket-id-export.zip'
]);
try {
// Wait until export finishes
runDockerCommand(['wait', containerId]);
runDockerCommand(['cp', `${containerId}:/app/data/pocket-id-export.zip`, outputFile]);
} finally {
runDockerCommand(['rm', '-f', containerId]);
}
expect(fs.existsSync(outputFile)).toBe(true);
}
function runExportToStdout(): Buffer {
const res = runDockerComposeCommandRaw([
'run',
'--rm',
'-T',
containerName,
'/app/pocket-id',
'export',
'--path',
'-'
]);
fs.writeFileSync('export-stdout.txt', res);
return res;
}
function unzipExport(zipFile: string, destDir: string): void {
fs.rmSync(destDir, { recursive: true, force: true });
const zip = new AdmZip(zipFile);
zip.extractAllTo(destDir, true);
}
function unzipExportBuffer(zipBuffer: Buffer, destDir: string): void {
fs.rmSync(destDir, { recursive: true, force: true });
const zip = new AdmZip(zipBuffer);
zip.extractAllTo(destDir, true);
}
function hashFile(filePath: string): string {
const buffer = fs.readFileSync(filePath);
return crypto.createHash('sha256').update(buffer).digest('hex');
}
function getAllFiles(dir: string, root = dir): string[] {
return fs.readdirSync(dir).flatMap((entry) => {
if (['.DS_Store', 'database.json'].includes(entry)) return [];
const fullPath = path.join(dir, entry);
const stat = fs.statSync(fullPath);
return stat.isDirectory() ? getAllFiles(fullPath, root) : [path.relative(root, fullPath)];
});
}
function hashAllFiles(dir: string): Record<string, string> {
const files = getAllFiles(dir);
const hashes: Record<string, string> = {};
for (const relativePath of files) {
const fullPath = path.join(dir, relativePath);
hashes[relativePath] = hashFile(fullPath);
}
return hashes;
}
function runDockerCommand(args: string[], options?: ExecFileSyncOptions): string {
return execFileSync('docker', args, {
cwd: setupDir,
stdio: 'pipe',
maxBuffer: dockerCommandMaxBuffer,
...options
})
.toString()
.trim();
}
function runDockerComposeCommand(args: string[]): string {
return runDockerComposeCommandRaw(args).toString().trim();
}
function runDockerComposeCommandRaw(args: string[], options?: ExecFileSyncOptions): Buffer {
return execFileSync('docker', dockerComposeArgs(args), {
cwd: setupDir,
stdio: 'pipe',
maxBuffer: dockerCommandMaxBuffer,
...options
}) as Buffer;
}
function dockerComposeArgs(args: string[]): string[] {
let dockerComposeFile = 'docker-compose.yml';
switch (mode) {
case 'postgres':
dockerComposeFile = 'docker-compose-postgres.yml';
break;
case 's3':
dockerComposeFile = 'docker-compose-s3.yml';
break;
}
return ['compose', '-f', dockerComposeFile, ...args];
}

View File

@@ -1,8 +1,9 @@
import { test as setup } from '@playwright/test';
import authUtil from '../utils/auth.util';
import { cleanupBackend } from '../utils/cleanup.util';
import { pathFromRoot } from 'utils/fs.util';
import authUtil from '../../utils/auth.util';
import { cleanupBackend } from '../../utils/cleanup.util';
const authFile = './.auth/user.json';
const authFile = pathFromRoot('.tmp/auth/user.json');
setup('authenticate', async ({ page }) => {
await cleanupBackend();

8
tests/specs/fixtures/global.setup.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
import fs from 'fs';
import { tmpDir } from 'utils/fs.util';
async function globalSetup() {
await fs.promises.mkdir(tmpDir, { recursive: true });
}
export default globalSetup;

View File

@@ -0,0 +1,8 @@
import fs from 'fs';
import { tmpDir } from 'utils/fs.util';
async function globalTeardown() {
await fs.promises.rm(tmpDir, { recursive: true, force: true });
}
export default globalTeardown;

View File

@@ -20,9 +20,9 @@ test.describe('Create OIDC client', () => {
await page.getByTestId('callback-url-2').fill(oidcClient.secondCallbackUrl);
await page.locator('[role="tab"][data-value="light-logo"]').first().click();
await page.setInputFiles('#oidc-client-logo-light', 'assets/pingvin-share-logo.png');
await page.setInputFiles('#oidc-client-logo-light', 'resources/images/pingvin-share-logo.png');
await page.locator('[role="tab"][data-value="dark-logo"]').first().click();
await page.setInputFiles('#oidc-client-logo-dark', 'assets/pingvin-share-logo.png');
await page.setInputFiles('#oidc-client-logo-dark', 'resources/images/pingvin-share-logo.png');
if (clientId) {
await page.getByRole('button', { name: 'Show Advanced Options' }).click();
@@ -71,9 +71,9 @@ test('Edit OIDC client', async ({ page }) => {
await page.getByLabel('Name').fill('Nextcloud updated');
await page.getByTestId('callback-url-1').first().fill('http://nextcloud-updated/auth/callback');
await page.locator('[role="tab"][data-value="light-logo"]').first().click();
await page.setInputFiles('#oidc-client-logo-light', 'assets/cloud-logo.png');
await page.setInputFiles('#oidc-client-logo-light', 'resources/images/cloud-logo.png');
await page.locator('[role="tab"][data-value="dark-logo"]').first().click();
await page.setInputFiles('#oidc-client-logo-dark', 'assets/cloud-logo.png');
await page.setInputFiles('#oidc-client-logo-dark', 'resources/images/cloud-logo.png');
await page.getByLabel('Client Launch URL').fill(oidcClient.launchURL);
await page.getByRole('button', { name: 'Save' }).click();

View File

@@ -70,7 +70,7 @@ test.describe('Initial User Signup', () => {
});
test('Initial Signup - success flow', async ({ page }) => {
await cleanupBackend(true);
await cleanupBackend({ skipSeed: true });
await page.goto('/setup');
await page.getByLabel('First name').fill('Jane');
await page.getByLabel('Last name').fill('Smith');

View File

@@ -1,6 +1,10 @@
{
"compilerOptions": {
"baseUrl": ".",
"lib": ["ES2022"]
"lib": ["ES2022"],
"esModuleInterop": true,
"module": "es2022",
"moduleResolution": "node",
"target": "es2022"
}
}

View File

@@ -1,9 +1,9 @@
import playwrightConfig from '../playwright.config';
export async function cleanupBackend(skipSeed = false) {
export async function cleanupBackend({ skipSeed = false, skipLdapSetup = false } = {}) {
const url = new URL('/api/test/reset', playwrightConfig.use!.baseURL);
if (process.env.SKIP_LDAP_TESTS === 'true' || skipSeed) {
if (process.env.SKIP_LDAP_TESTS === 'true' || skipSeed || skipLdapSetup) {
url.searchParams.append('skip-ldap', 'true');
}

7
tests/utils/fs.util.ts Normal file
View File

@@ -0,0 +1,7 @@
import path from 'path';
export const tmpDir = pathFromRoot('.tmp');
export function pathFromRoot(p: string): string {
return path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', p);
}