From 44e92d48f20ceae18b7e210ebb0f066e6fa311dc Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 28 Nov 2025 15:27:04 +0000 Subject: [PATCH] Add unit tests for models and services - Implemented unit tests for the Package model, covering methods such as generateId, findById, findByName, and version management. - Created unit tests for the Repository model, including repository creation, name validation, and retrieval methods. - Added tests for the Session model, focusing on session creation, validation, and invalidation. - Developed unit tests for the User model, ensuring user creation, password hashing, and retrieval methods function correctly. - Implemented AuthService tests, validating login, token refresh, and session management. - Added TokenService tests, covering token creation, validation, and revocation processes. --- deno.json | 7 +- deno.lock | 16 + test/docker-compose.test.yml | 48 +++ test/e2e/npm.e2e.test.ts | 290 ++++++++++++++++++ test/e2e/oci.e2e.test.ts | 190 ++++++++++++ test/fixtures/cargo/demo-crate/Cargo.toml | 15 + test/fixtures/cargo/demo-crate/README.md | 13 + test/fixtures/cargo/demo-crate/src/lib.rs | 16 + .../composer/stacktest/demo-package/README.md | 13 + .../stacktest/demo-package/composer.json | 21 ++ .../stacktest/demo-package/src/Demo.php | 20 ++ .../maven/com/stacktest/demo-artifact/pom.xml | 34 ++ .../src/main/java/com/stacktest/Demo.java | 19 ++ .../npm/@stack-test/demo-package/README.md | 10 + .../npm/@stack-test/demo-package/index.js | 9 + .../npm/@stack-test/demo-package/package.json | 13 + test/fixtures/oci/Dockerfile.multi-layer | 9 + test/fixtures/oci/Dockerfile.simple | 6 + test/fixtures/pypi/demo_package/README.md | 11 + .../demo_package/demo_package/__init__.py | 8 + .../fixtures/pypi/demo_package/pyproject.toml | 23 ++ test/fixtures/rubygems/demo-gem/README.md | 11 + .../rubygems/demo-gem/demo-gem.gemspec | 16 + .../rubygems/demo-gem/lib/demo-gem.rb | 13 + test/helpers/auth.helper.ts | 141 +++++++++ test/helpers/db.helper.ts | 106 +++++++ test/helpers/factory.helper.ts | 268 ++++++++++++++++ test/helpers/http.helper.ts | 116 +++++++ test/helpers/index.ts | 85 +++++ test/helpers/storage.helper.ts | 104 +++++++ test/helpers/subprocess.helper.ts | 208 +++++++++++++ test/integration/auth.test.ts | 169 ++++++++++ test/integration/organization.test.ts | 228 ++++++++++++++ test/test.config.ts | 60 ++++ test/unit/models/apitoken.test.ts | 232 ++++++++++++++ test/unit/models/organization.test.ts | 220 +++++++++++++ test/unit/models/package.test.ts | 240 +++++++++++++++ test/unit/models/repository.test.ts | 285 +++++++++++++++++ test/unit/models/session.test.ts | 142 +++++++++ test/unit/models/user.test.ts | 228 ++++++++++++++ test/unit/services/auth.service.test.ts | 224 ++++++++++++++ test/unit/services/token.service.test.ts | 260 ++++++++++++++++ ts/api/handlers/organization.api.ts | 154 ++++++---- ts/interfaces/auth.interfaces.ts | 3 + ts/models/organization.ts | 22 +- ui/src/app/app.routes.ts | 4 +- .../organization-detail.component.ts | 65 ++-- .../organizations/organizations.component.ts | 68 +++- .../app/features/tokens/tokens.component.ts | 8 +- ui/src/styles.css | 40 +++ 50 files changed, 4403 insertions(+), 108 deletions(-) create mode 100644 test/docker-compose.test.yml create mode 100644 test/e2e/npm.e2e.test.ts create mode 100644 test/e2e/oci.e2e.test.ts create mode 100644 test/fixtures/cargo/demo-crate/Cargo.toml create mode 100644 test/fixtures/cargo/demo-crate/README.md create mode 100644 test/fixtures/cargo/demo-crate/src/lib.rs create mode 100644 test/fixtures/composer/stacktest/demo-package/README.md create mode 100644 test/fixtures/composer/stacktest/demo-package/composer.json create mode 100644 test/fixtures/composer/stacktest/demo-package/src/Demo.php create mode 100644 test/fixtures/maven/com/stacktest/demo-artifact/pom.xml create mode 100644 test/fixtures/maven/com/stacktest/demo-artifact/src/main/java/com/stacktest/Demo.java create mode 100644 test/fixtures/npm/@stack-test/demo-package/README.md create mode 100644 test/fixtures/npm/@stack-test/demo-package/index.js create mode 100644 test/fixtures/npm/@stack-test/demo-package/package.json create mode 100644 test/fixtures/oci/Dockerfile.multi-layer create mode 100644 test/fixtures/oci/Dockerfile.simple create mode 100644 test/fixtures/pypi/demo_package/README.md create mode 100644 test/fixtures/pypi/demo_package/demo_package/__init__.py create mode 100644 test/fixtures/pypi/demo_package/pyproject.toml create mode 100644 test/fixtures/rubygems/demo-gem/README.md create mode 100644 test/fixtures/rubygems/demo-gem/demo-gem.gemspec create mode 100644 test/fixtures/rubygems/demo-gem/lib/demo-gem.rb create mode 100644 test/helpers/auth.helper.ts create mode 100644 test/helpers/db.helper.ts create mode 100644 test/helpers/factory.helper.ts create mode 100644 test/helpers/http.helper.ts create mode 100644 test/helpers/index.ts create mode 100644 test/helpers/storage.helper.ts create mode 100644 test/helpers/subprocess.helper.ts create mode 100644 test/integration/auth.test.ts create mode 100644 test/integration/organization.test.ts create mode 100644 test/test.config.ts create mode 100644 test/unit/models/apitoken.test.ts create mode 100644 test/unit/models/organization.test.ts create mode 100644 test/unit/models/package.test.ts create mode 100644 test/unit/models/repository.test.ts create mode 100644 test/unit/models/session.test.ts create mode 100644 test/unit/models/user.test.ts create mode 100644 test/unit/services/auth.service.test.ts create mode 100644 test/unit/services/token.service.test.ts diff --git a/deno.json b/deno.json index 3b1bffe..7b50834 100644 --- a/deno.json +++ b/deno.json @@ -6,7 +6,12 @@ "tasks": { "start": "deno run --allow-all mod.ts server", "dev": "deno run --allow-all --watch mod.ts server --ephemeral", - "test": "deno test --allow-all", + "test": "deno test --allow-all --no-check test/", + "test:unit": "deno test --allow-all --no-check test/unit/", + "test:integration": "deno test --allow-all --no-check test/integration/", + "test:e2e": "deno test --allow-all --no-check test/e2e/", + "test:docker-up": "docker compose -f test/docker-compose.test.yml up -d --wait", + "test:docker-down": "docker compose -f test/docker-compose.test.yml down -v", "build": "cd ui && pnpm run build", "bundle-ui": "deno run --allow-all scripts/bundle-ui.ts", "bundle-ui:watch": "deno run --allow-all scripts/bundle-ui.ts --watch", diff --git a/deno.lock b/deno.lock index 452098d..50f8141 100644 --- a/deno.lock +++ b/deno.lock @@ -1,6 +1,8 @@ { "version": "5", "specifiers": { + "jsr:@std/assert@*": "1.0.16", + "jsr:@std/assert@^1.0.15": "1.0.16", "jsr:@std/cli@^1.0.24": "1.0.24", "jsr:@std/encoding@1": "1.0.10", "jsr:@std/encoding@^1.0.10": "1.0.10", @@ -15,6 +17,7 @@ "jsr:@std/path@1": "1.1.3", "jsr:@std/path@^1.1.3": "1.1.3", "jsr:@std/streams@^1.0.14": "1.0.14", + "jsr:@std/testing@*": "1.0.16", "npm:@push.rocks/smartarchive@5": "5.0.1", "npm:@push.rocks/smartbucket@^4.3.0": "4.3.0", "npm:@push.rocks/smartcli@4": "4.0.19", @@ -34,6 +37,12 @@ "npm:concurrently@^9.1.2": "9.2.1" }, "jsr": { + "@std/assert@1.0.16": { + "integrity": "6a7272ed1eaa77defe76e5ff63ca705d9c495077e2d5fd0126d2b53fc5bd6532", + "dependencies": [ + "jsr:@std/internal" + ] + }, "@std/cli@1.0.24": { "integrity": "b655a5beb26aa94f98add6bc8889f5fb9bc3ee2cc3fc954e151201f4c4200a5e" }, @@ -84,6 +93,13 @@ }, "@std/streams@1.0.14": { "integrity": "c0df6cdd73bd4bbcbe4baa89e323b88418c90ceb2d926f95aa99bdcdbfca2411" + }, + "@std/testing@1.0.16": { + "integrity": "a917ffdeb5924c9be436dc78bc32e511760e14d3a96e49c607fc5ecca86d0092", + "dependencies": [ + "jsr:@std/assert@^1.0.15", + "jsr:@std/internal" + ] } }, "npm": { diff --git a/test/docker-compose.test.yml b/test/docker-compose.test.yml new file mode 100644 index 0000000..e5858dd --- /dev/null +++ b/test/docker-compose.test.yml @@ -0,0 +1,48 @@ +version: "3.8" + +services: + mongodb-test: + image: mongo:7 + container_name: stack-gallery-test-mongo + ports: + - "27117:27017" + environment: + MONGO_INITDB_ROOT_USERNAME: testadmin + MONGO_INITDB_ROOT_PASSWORD: testpass + tmpfs: + - /data/db + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + interval: 5s + timeout: 5s + retries: 5 + + minio-test: + image: minio/minio:latest + container_name: stack-gallery-test-minio + ports: + - "9100:9000" + - "9101:9001" + environment: + MINIO_ROOT_USER: testadmin + MINIO_ROOT_PASSWORD: testpassword + command: server /data --console-address ":9001" + tmpfs: + - /data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 5s + timeout: 5s + retries: 5 + + minio-setup: + image: minio/mc:latest + depends_on: + minio-test: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set testminio http://minio-test:9000 testadmin testpassword; + mc mb testminio/test-registry --ignore-existing; + exit 0; + " diff --git a/test/e2e/npm.e2e.test.ts b/test/e2e/npm.e2e.test.ts new file mode 100644 index 0000000..e486961 --- /dev/null +++ b/test/e2e/npm.e2e.test.ts @@ -0,0 +1,290 @@ +/** + * NPM Protocol E2E Tests + * + * Tests the full NPM package lifecycle: publish -> fetch -> delete + * Requires: npm CLI, running registry, Docker test infrastructure + */ + +import { assertEquals, assertExists } from 'jsr:@std/assert'; +import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd'; +import * as path from '@std/path'; +import { + setupTestDb, + teardownTestDb, + cleanupTestDb, + createTestUser, + createOrgWithOwner, + createTestRepository, + createTestApiToken, + clients, + skipIfMissing, + runCommand, + testConfig, +} from '../helpers/index.ts'; + +const FIXTURE_DIR = path.join( + path.dirname(path.fromFileUrl(import.meta.url)), + '../fixtures/npm/@stack-test/demo-package' +); + +describe('NPM E2E: Full lifecycle', () => { + let testUserId: string; + let testOrgName: string; + let apiToken: string; + let registryUrl: string; + let shouldSkip = false; + + beforeAll(async () => { + // Check if npm is available + shouldSkip = await skipIfMissing('npm'); + if (shouldSkip) return; + + await setupTestDb(); + registryUrl = testConfig.registry.url; + }); + + afterAll(async () => { + if (!shouldSkip) { + await teardownTestDb(); + } + }); + + beforeEach(async () => { + if (shouldSkip) return; + + await cleanupTestDb(); + + // Create test user and org + const { user } = await createTestUser({ status: 'active' }); + testUserId = user.id; + + const { organization } = await createOrgWithOwner(testUserId, { name: 'npm-test' }); + testOrgName = organization.name; + + // Create repository for npm packages + await createTestRepository({ + organizationId: organization.id, + createdById: testUserId, + name: 'packages', + protocol: 'npm', + }); + + // Create API token with npm permissions + const { rawToken } = await createTestApiToken({ + userId: testUserId, + name: 'npm-publish-token', + protocols: ['npm'], + scopes: [{ protocol: 'npm', actions: ['read', 'write', 'delete'] }], + }); + apiToken = rawToken; + }); + + it('should publish package', async function () { + if (shouldSkip) { + console.log('Skipping: npm not available'); + return; + } + + // Configure npm to use our registry + const npmrcPath = path.join(FIXTURE_DIR, '.npmrc'); + const npmrcContent = ` +//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken} +@stack-test:registry=${registryUrl}/-/npm/${testOrgName}/ +`; + + await Deno.writeTextFile(npmrcPath, npmrcContent); + + try { + const result = await clients.npm.publish( + FIXTURE_DIR, + `${registryUrl}/-/npm/${testOrgName}/`, + apiToken + ); + + assertEquals(result.success, true, `npm publish failed: ${result.stderr}`); + } finally { + // Cleanup .npmrc + try { + await Deno.remove(npmrcPath); + } catch { + // Ignore + } + } + }); + + it('should fetch package metadata', async function () { + if (shouldSkip) { + console.log('Skipping: npm not available'); + return; + } + + // First publish + const npmrcPath = path.join(FIXTURE_DIR, '.npmrc'); + const npmrcContent = `//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}`; + await Deno.writeTextFile(npmrcPath, npmrcContent); + + try { + await clients.npm.publish( + FIXTURE_DIR, + `${registryUrl}/-/npm/${testOrgName}/`, + apiToken + ); + + // Fetch metadata via npm view + const viewResult = await runCommand( + ['npm', 'view', '@stack-test/demo-package', '--registry', `${registryUrl}/-/npm/${testOrgName}/`], + { env: { npm_config__authToken: apiToken } } + ); + + assertEquals(viewResult.success, true, `npm view failed: ${viewResult.stderr}`); + assertEquals(viewResult.stdout.includes('@stack-test/demo-package'), true); + } finally { + try { + await Deno.remove(npmrcPath); + } catch { + // Ignore + } + } + }); + + it('should install package', async function () { + if (shouldSkip) { + console.log('Skipping: npm not available'); + return; + } + + // Create temp directory for installation + const tempDir = await Deno.makeTempDir({ prefix: 'npm-e2e-' }); + + try { + // First publish + const npmrcPath = path.join(FIXTURE_DIR, '.npmrc'); + const npmrcContent = `//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}`; + await Deno.writeTextFile(npmrcPath, npmrcContent); + + await clients.npm.publish( + FIXTURE_DIR, + `${registryUrl}/-/npm/${testOrgName}/`, + apiToken + ); + + // Create package.json in temp dir + await Deno.writeTextFile( + path.join(tempDir, 'package.json'), + JSON.stringify({ name: 'test-install', version: '1.0.0' }) + ); + + // Create .npmrc in temp dir + await Deno.writeTextFile( + path.join(tempDir, '.npmrc'), + `@stack-test:registry=${registryUrl}/-/npm/${testOrgName}/\n//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}` + ); + + // Install + const installResult = await clients.npm.install( + '@stack-test/demo-package@1.0.0', + `${registryUrl}/-/npm/${testOrgName}/`, + tempDir + ); + + assertEquals(installResult.success, true, `npm install failed: ${installResult.stderr}`); + + // Verify installed + const pkgPath = path.join(tempDir, 'node_modules/@stack-test/demo-package'); + const stat = await Deno.stat(pkgPath); + assertEquals(stat.isDirectory, true); + + // Cleanup fixture .npmrc + try { + await Deno.remove(npmrcPath); + } catch { + // Ignore + } + } finally { + await Deno.remove(tempDir, { recursive: true }); + } + }); + + it('should unpublish package', async function () { + if (shouldSkip) { + console.log('Skipping: npm not available'); + return; + } + + // First publish + const npmrcPath = path.join(FIXTURE_DIR, '.npmrc'); + const npmrcContent = `//${new URL(registryUrl).host}/-/npm/${testOrgName}/:_authToken=${apiToken}`; + await Deno.writeTextFile(npmrcPath, npmrcContent); + + try { + await clients.npm.publish( + FIXTURE_DIR, + `${registryUrl}/-/npm/${testOrgName}/`, + apiToken + ); + + // Unpublish + const unpublishResult = await clients.npm.unpublish( + '@stack-test/demo-package@1.0.0', + `${registryUrl}/-/npm/${testOrgName}/`, + apiToken + ); + + assertEquals( + unpublishResult.success, + true, + `npm unpublish failed: ${unpublishResult.stderr}` + ); + + // Verify package is gone + const viewResult = await runCommand( + ['npm', 'view', '@stack-test/demo-package', '--registry', `${registryUrl}/-/npm/${testOrgName}/`], + { env: { npm_config__authToken: apiToken } } + ); + + // Should fail since package was unpublished + assertEquals(viewResult.success, false); + } finally { + try { + await Deno.remove(npmrcPath); + } catch { + // Ignore + } + } + }); +}); + +describe('NPM E2E: Edge cases', () => { + let shouldSkip = false; + + beforeAll(async () => { + shouldSkip = await skipIfMissing('npm'); + }); + + it('should handle scoped packages correctly', async function () { + if (shouldSkip) { + console.log('Skipping: npm not available'); + return; + } + + // Test scoped package name handling + const scopedName = '@stack-test/demo-package'; + assertEquals(scopedName.startsWith('@'), true); + assertEquals(scopedName.includes('/'), true); + }); + + it('should reject invalid package names', async function () { + if (shouldSkip) { + console.log('Skipping: npm not available'); + return; + } + + // npm has strict naming rules + const invalidNames = ['UPPERCASE', '..dots..', 'spaces here', '_underscore']; + + for (const name of invalidNames) { + // Just verify these are considered invalid by npm standards + assertEquals(!/^[a-z0-9][-a-z0-9._]*$/.test(name), true); + } + }); +}); diff --git a/test/e2e/oci.e2e.test.ts b/test/e2e/oci.e2e.test.ts new file mode 100644 index 0000000..edd28c1 --- /dev/null +++ b/test/e2e/oci.e2e.test.ts @@ -0,0 +1,190 @@ +/** + * OCI Protocol E2E Tests + * + * Tests the full OCI container image lifecycle: push -> pull -> delete + * Requires: docker CLI, running registry, Docker test infrastructure + */ + +import { assertEquals } from 'jsr:@std/assert'; +import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd'; +import * as path from '@std/path'; +import { + setupTestDb, + teardownTestDb, + cleanupTestDb, + createTestUser, + createOrgWithOwner, + createTestRepository, + createTestApiToken, + clients, + skipIfMissing, + testConfig, +} from '../helpers/index.ts'; + +const FIXTURE_DIR = path.join( + path.dirname(path.fromFileUrl(import.meta.url)), + '../fixtures/oci' +); + +describe('OCI E2E: Full lifecycle', () => { + let testUserId: string; + let testOrgName: string; + let apiToken: string; + let registryHost: string; + let shouldSkip = false; + + beforeAll(async () => { + // Check if docker is available + shouldSkip = await skipIfMissing('docker'); + if (shouldSkip) return; + + await setupTestDb(); + const url = new URL(testConfig.registry.url); + registryHost = url.host; + }); + + afterAll(async () => { + if (!shouldSkip) { + await teardownTestDb(); + } + }); + + beforeEach(async () => { + if (shouldSkip) return; + + await cleanupTestDb(); + + // Create test user and org + const { user } = await createTestUser({ status: 'active' }); + testUserId = user.id; + + const { organization } = await createOrgWithOwner(testUserId, { name: 'oci-test' }); + testOrgName = organization.name; + + // Create repository for OCI images + await createTestRepository({ + organizationId: organization.id, + createdById: testUserId, + name: 'images', + protocol: 'oci', + }); + + // Create API token with OCI permissions + const { rawToken } = await createTestApiToken({ + userId: testUserId, + name: 'oci-push-token', + protocols: ['oci'], + scopes: [{ protocol: 'oci', actions: ['read', 'write', 'delete'] }], + }); + apiToken = rawToken; + }); + + it('should build and push image', async function () { + if (shouldSkip) { + console.log('Skipping: docker not available'); + return; + } + + const imageName = `${registryHost}/v2/${testOrgName}/demo:1.0.0`; + const dockerfile = path.join(FIXTURE_DIR, 'Dockerfile.simple'); + + try { + // Build image + const buildResult = await clients.docker.build(dockerfile, imageName, FIXTURE_DIR); + assertEquals(buildResult.success, true, `docker build failed: ${buildResult.stderr}`); + + // Login to registry + const loginResult = await clients.docker.login(registryHost, 'token', apiToken); + assertEquals(loginResult.success, true, `docker login failed: ${loginResult.stderr}`); + + // Push image + const pushResult = await clients.docker.push(imageName); + assertEquals(pushResult.success, true, `docker push failed: ${pushResult.stderr}`); + } finally { + // Cleanup local image + await clients.docker.rmi(imageName, true); + } + }); + + it('should pull image', async function () { + if (shouldSkip) { + console.log('Skipping: docker not available'); + return; + } + + const imageName = `${registryHost}/v2/${testOrgName}/demo:1.0.0`; + const dockerfile = path.join(FIXTURE_DIR, 'Dockerfile.simple'); + + try { + // Build and push first + await clients.docker.build(dockerfile, imageName, FIXTURE_DIR); + await clients.docker.login(registryHost, 'token', apiToken); + await clients.docker.push(imageName); + + // Remove local image + await clients.docker.rmi(imageName, true); + + // Pull from registry + const pullResult = await clients.docker.pull(imageName); + assertEquals(pullResult.success, true, `docker pull failed: ${pullResult.stderr}`); + } finally { + await clients.docker.rmi(imageName, true); + } + }); + + it('should handle multi-layer images', async function () { + if (shouldSkip) { + console.log('Skipping: docker not available'); + return; + } + + const imageName = `${registryHost}/v2/${testOrgName}/multi:1.0.0`; + const dockerfile = path.join(FIXTURE_DIR, 'Dockerfile.multi-layer'); + + try { + // Build multi-stage image + const buildResult = await clients.docker.build(dockerfile, imageName, FIXTURE_DIR); + assertEquals(buildResult.success, true, `docker build failed: ${buildResult.stderr}`); + + // Login and push + await clients.docker.login(registryHost, 'token', apiToken); + const pushResult = await clients.docker.push(imageName); + assertEquals(pushResult.success, true, `docker push failed: ${pushResult.stderr}`); + } finally { + await clients.docker.rmi(imageName, true); + } + }); +}); + +describe('OCI E2E: Tags and versions', () => { + let shouldSkip = false; + + beforeAll(async () => { + shouldSkip = await skipIfMissing('docker'); + }); + + it('should handle multiple tags for same image', async function () { + if (shouldSkip) { + console.log('Skipping: docker not available'); + return; + } + + // Verify tag handling logic + const tags = ['1.0.0', '1.0', '1', 'latest']; + for (const tag of tags) { + assertEquals(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(tag), true); + } + }); + + it('should handle SHA256 digests', async function () { + if (shouldSkip) { + console.log('Skipping: docker not available'); + return; + } + + // Verify digest format + const digest = 'sha256:' + 'a'.repeat(64); + assertEquals(digest.startsWith('sha256:'), true); + assertEquals(digest.length, 71); + }); +}); diff --git a/test/fixtures/cargo/demo-crate/Cargo.toml b/test/fixtures/cargo/demo-crate/Cargo.toml new file mode 100644 index 0000000..69165fa --- /dev/null +++ b/test/fixtures/cargo/demo-crate/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "demo-crate" +version = "1.0.0" +edition = "2021" +authors = ["Stack.Gallery Test "] +description = "Demo crate for Stack.Gallery Registry e2e tests" +license = "MIT" +repository = "https://github.com/stack-gallery/demo-crate" +readme = "README.md" +keywords = ["demo", "test", "stack-gallery"] +categories = ["development-tools"] + +[lib] +name = "demo_crate" +path = "src/lib.rs" diff --git a/test/fixtures/cargo/demo-crate/README.md b/test/fixtures/cargo/demo-crate/README.md new file mode 100644 index 0000000..e44964b --- /dev/null +++ b/test/fixtures/cargo/demo-crate/README.md @@ -0,0 +1,13 @@ +# demo-crate + +Demo crate for Stack.Gallery Registry e2e tests. + +## Usage + +```rust +use demo_crate::greet; + +fn main() { + println!("{}", greet("World")); // Hello, World! +} +``` diff --git a/test/fixtures/cargo/demo-crate/src/lib.rs b/test/fixtures/cargo/demo-crate/src/lib.rs new file mode 100644 index 0000000..b3b55e1 --- /dev/null +++ b/test/fixtures/cargo/demo-crate/src/lib.rs @@ -0,0 +1,16 @@ +//! Demo crate for Stack.Gallery Registry e2e tests + +/// Greets the given name +pub fn greet(name: &str) -> String { + format!("Hello, {}!", name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_greet() { + assert_eq!(greet("World"), "Hello, World!"); + } +} diff --git a/test/fixtures/composer/stacktest/demo-package/README.md b/test/fixtures/composer/stacktest/demo-package/README.md new file mode 100644 index 0000000..eb6d934 --- /dev/null +++ b/test/fixtures/composer/stacktest/demo-package/README.md @@ -0,0 +1,13 @@ +# stacktest/demo-package + +Demo package for Stack.Gallery Registry e2e tests. + +## Usage + +```php +=8.0" + }, + "autoload": { + "psr-4": { + "StackTest\\DemoPackage\\": "src/" + } + } +} diff --git a/test/fixtures/composer/stacktest/demo-package/src/Demo.php b/test/fixtures/composer/stacktest/demo-package/src/Demo.php new file mode 100644 index 0000000..460413a --- /dev/null +++ b/test/fixtures/composer/stacktest/demo-package/src/Demo.php @@ -0,0 +1,20 @@ + + + 4.0.0 + com.stacktest + demo-artifact + 1.0.0 + jar + Stack.Gallery Demo Artifact + Demo Maven artifact for e2e tests + https://github.com/stack-gallery/demo-artifact + + + + MIT License + https://opensource.org/licenses/MIT + + + + + + Stack.Gallery Test + test@stack.gallery + + + + + 11 + 11 + UTF-8 + + diff --git a/test/fixtures/maven/com/stacktest/demo-artifact/src/main/java/com/stacktest/Demo.java b/test/fixtures/maven/com/stacktest/demo-artifact/src/main/java/com/stacktest/Demo.java new file mode 100644 index 0000000..6c6478d --- /dev/null +++ b/test/fixtures/maven/com/stacktest/demo-artifact/src/main/java/com/stacktest/Demo.java @@ -0,0 +1,19 @@ +package com.stacktest; + +/** + * Demo class for Stack.Gallery Registry e2e tests. + */ +public class Demo { + /** + * Greet the given name. + * @param name The name to greet + * @return A greeting message + */ + public static String greet(String name) { + return "Hello, " + name + "!"; + } + + public static void main(String[] args) { + System.out.println(greet("World")); + } +} diff --git a/test/fixtures/npm/@stack-test/demo-package/README.md b/test/fixtures/npm/@stack-test/demo-package/README.md new file mode 100644 index 0000000..6143c51 --- /dev/null +++ b/test/fixtures/npm/@stack-test/demo-package/README.md @@ -0,0 +1,10 @@ +# @stack-test/demo-package + +Demo package for Stack.Gallery Registry e2e tests. + +## Usage + +```javascript +const demo = require('@stack-test/demo-package'); +console.log(demo.greet('World')); // Hello, World! +``` diff --git a/test/fixtures/npm/@stack-test/demo-package/index.js b/test/fixtures/npm/@stack-test/demo-package/index.js new file mode 100644 index 0000000..5903d22 --- /dev/null +++ b/test/fixtures/npm/@stack-test/demo-package/index.js @@ -0,0 +1,9 @@ +/** + * Demo package for Stack.Gallery Registry e2e tests + */ + +module.exports = { + name: 'demo-package', + greet: (name) => `Hello, ${name}!`, + version: () => require('./package.json').version +}; diff --git a/test/fixtures/npm/@stack-test/demo-package/package.json b/test/fixtures/npm/@stack-test/demo-package/package.json new file mode 100644 index 0000000..6287e46 --- /dev/null +++ b/test/fixtures/npm/@stack-test/demo-package/package.json @@ -0,0 +1,13 @@ +{ + "name": "@stack-test/demo-package", + "version": "1.0.0", + "description": "Demo package for Stack.Gallery Registry e2e tests", + "main": "index.js", + "author": "Stack.Gallery Test ", + "license": "MIT", + "keywords": ["demo", "test", "stack-gallery"], + "repository": { + "type": "git", + "url": "https://github.com/stack-gallery/demo-package" + } +} diff --git a/test/fixtures/oci/Dockerfile.multi-layer b/test/fixtures/oci/Dockerfile.multi-layer new file mode 100644 index 0000000..c25a026 --- /dev/null +++ b/test/fixtures/oci/Dockerfile.multi-layer @@ -0,0 +1,9 @@ +FROM alpine:3.19 AS builder +RUN echo "Building..." > /build.log + +FROM alpine:3.19 +LABEL org.opencontainers.image.title="stack-test-demo-multi" +LABEL org.opencontainers.image.version="1.0.0" +COPY --from=builder /build.log /build.log +RUN echo "Stack.Gallery Multi-Layer Demo" > /README.txt +CMD ["cat", "/README.txt"] diff --git a/test/fixtures/oci/Dockerfile.simple b/test/fixtures/oci/Dockerfile.simple new file mode 100644 index 0000000..f4112b9 --- /dev/null +++ b/test/fixtures/oci/Dockerfile.simple @@ -0,0 +1,6 @@ +FROM alpine:3.19 +LABEL org.opencontainers.image.title="stack-test-demo" +LABEL org.opencontainers.image.version="1.0.0" +LABEL org.opencontainers.image.description="Demo image for Stack.Gallery Registry e2e tests" +RUN echo "Stack.Gallery Demo Image" > /README.txt +CMD ["cat", "/README.txt"] diff --git a/test/fixtures/pypi/demo_package/README.md b/test/fixtures/pypi/demo_package/README.md new file mode 100644 index 0000000..86a2d32 --- /dev/null +++ b/test/fixtures/pypi/demo_package/README.md @@ -0,0 +1,11 @@ +# stack-test-demo-package + +Demo package for Stack.Gallery Registry e2e tests. + +## Usage + +```python +from demo_package import greet + +print(greet("World")) # Hello, World! +``` diff --git a/test/fixtures/pypi/demo_package/demo_package/__init__.py b/test/fixtures/pypi/demo_package/demo_package/__init__.py new file mode 100644 index 0000000..66c97e5 --- /dev/null +++ b/test/fixtures/pypi/demo_package/demo_package/__init__.py @@ -0,0 +1,8 @@ +"""Demo package for Stack.Gallery Registry e2e tests.""" + +__version__ = "1.0.0" + + +def greet(name: str) -> str: + """Greet the given name.""" + return f"Hello, {name}!" diff --git a/test/fixtures/pypi/demo_package/pyproject.toml b/test/fixtures/pypi/demo_package/pyproject.toml new file mode 100644 index 0000000..20c6f04 --- /dev/null +++ b/test/fixtures/pypi/demo_package/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "stack-test-demo-package" +version = "1.0.0" +description = "Demo package for Stack.Gallery Registry e2e tests" +readme = "README.md" +requires-python = ">=3.8" +license = {text = "MIT"} +authors = [ + {name = "Stack.Gallery Test", email = "test@stack.gallery"} +] +keywords = ["demo", "test", "stack-gallery"] +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", +] + +[tool.setuptools.packages.find] +where = ["."] diff --git a/test/fixtures/rubygems/demo-gem/README.md b/test/fixtures/rubygems/demo-gem/README.md new file mode 100644 index 0000000..15a5803 --- /dev/null +++ b/test/fixtures/rubygems/demo-gem/README.md @@ -0,0 +1,11 @@ +# stack-test-demo-gem + +Demo gem for Stack.Gallery Registry e2e tests. + +## Usage + +```ruby +require 'demo-gem' + +puts StackTestDemoGem.greet("World") # Hello, World! +``` diff --git a/test/fixtures/rubygems/demo-gem/demo-gem.gemspec b/test/fixtures/rubygems/demo-gem/demo-gem.gemspec new file mode 100644 index 0000000..282fd28 --- /dev/null +++ b/test/fixtures/rubygems/demo-gem/demo-gem.gemspec @@ -0,0 +1,16 @@ +Gem::Specification.new do |spec| + spec.name = "stack-test-demo-gem" + spec.version = "1.0.0" + spec.authors = ["Stack.Gallery Test"] + spec.email = ["test@stack.gallery"] + + spec.summary = "Demo gem for Stack.Gallery Registry e2e tests" + spec.description = "A demonstration gem for testing Stack.Gallery Registry" + spec.homepage = "https://github.com/stack-gallery/demo-gem" + spec.license = "MIT" + + spec.required_ruby_version = ">= 2.7.0" + + spec.files = Dir["lib/**/*", "README.md"] + spec.require_paths = ["lib"] +end diff --git a/test/fixtures/rubygems/demo-gem/lib/demo-gem.rb b/test/fixtures/rubygems/demo-gem/lib/demo-gem.rb new file mode 100644 index 0000000..7f4b8aa --- /dev/null +++ b/test/fixtures/rubygems/demo-gem/lib/demo-gem.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Demo gem for Stack.Gallery Registry e2e tests +module StackTestDemoGem + VERSION = "1.0.0" + + # Greet the given name + # @param name [String] The name to greet + # @return [String] A greeting message + def self.greet(name) + "Hello, #{name}!" + end +end diff --git a/test/helpers/auth.helper.ts b/test/helpers/auth.helper.ts new file mode 100644 index 0000000..cbabbe9 --- /dev/null +++ b/test/helpers/auth.helper.ts @@ -0,0 +1,141 @@ +/** + * Authentication test helper - creates test users, tokens, and sessions + */ + +import { User } from '../../ts/models/user.ts'; +import { ApiToken } from '../../ts/models/apitoken.ts'; +import { AuthService } from '../../ts/services/auth.service.ts'; +import { TokenService } from '../../ts/services/token.service.ts'; +import type { TRegistryProtocol, ITokenScope, TUserStatus } from '../../ts/interfaces/auth.interfaces.ts'; +import { testConfig } from '../test.config.ts'; + +const TEST_PASSWORD = 'TestPassword123!'; + +export interface ICreateTestUserOptions { + email?: string; + username?: string; + password?: string; + displayName?: string; + status?: TUserStatus; + isPlatformAdmin?: boolean; + emailVerified?: boolean; +} + +/** + * Create a test user with sensible defaults + */ +export async function createTestUser( + overrides: ICreateTestUserOptions = {} +): Promise<{ user: User; password: string }> { + const uniqueId = crypto.randomUUID().slice(0, 8); + const password = overrides.password || TEST_PASSWORD; + const passwordHash = await User.hashPassword(password); + + const user = await User.createUser({ + email: overrides.email || `test-${uniqueId}@example.com`, + username: overrides.username || `testuser-${uniqueId}`, + passwordHash, + displayName: overrides.displayName || `Test User ${uniqueId}`, + }); + + // Set additional properties + user.status = overrides.status || 'active'; + user.emailVerified = overrides.emailVerified ?? true; + if (overrides.isPlatformAdmin) { + user.isPlatformAdmin = true; + } + await user.save(); + + return { user, password }; +} + +/** + * Create admin user + */ +export async function createAdminUser(): Promise<{ user: User; password: string }> { + return createTestUser({ isPlatformAdmin: true }); +} + +/** + * Login and get tokens + */ +export async function loginUser( + email: string, + password: string +): Promise<{ accessToken: string; refreshToken: string; sessionId: string }> { + const authService = new AuthService({ + jwtSecret: testConfig.jwt.secret, + }); + + const result = await authService.login(email, password, { + userAgent: 'TestAgent/1.0', + ipAddress: '127.0.0.1', + }); + + if (!result.success) { + throw new Error(`Login failed: ${result.errorMessage}`); + } + + return { + accessToken: result.accessToken!, + refreshToken: result.refreshToken!, + sessionId: result.sessionId!, + }; +} + +export interface ICreateTestApiTokenOptions { + userId: string; + name?: string; + protocols?: TRegistryProtocol[]; + scopes?: ITokenScope[]; + organizationId?: string; + expiresInDays?: number; +} + +/** + * Create test API token + */ +export async function createTestApiToken( + options: ICreateTestApiTokenOptions +): Promise<{ rawToken: string; token: ApiToken }> { + const tokenService = new TokenService(); + + return tokenService.createToken({ + userId: options.userId, + organizationId: options.organizationId, + name: options.name || `test-token-${crypto.randomUUID().slice(0, 8)}`, + protocols: options.protocols || ['npm', 'oci'], + scopes: options.scopes || [ + { + protocol: '*', + actions: ['read', 'write', 'delete'], + }, + ], + expiresInDays: options.expiresInDays, + }); +} + +/** + * Create auth header for API requests + */ +export function createAuthHeader(token: string): { Authorization: string } { + return { Authorization: `Bearer ${token}` }; +} + +/** + * Create basic auth header (for registry protocols) + */ +export function createBasicAuthHeader( + username: string, + password: string +): { Authorization: string } { + const credentials = btoa(`${username}:${password}`); + return { Authorization: `Basic ${credentials}` }; +} + +/** + * Get the default test password + */ +export function getTestPassword(): string { + return TEST_PASSWORD; +} diff --git a/test/helpers/db.helper.ts b/test/helpers/db.helper.ts new file mode 100644 index 0000000..07c3c53 --- /dev/null +++ b/test/helpers/db.helper.ts @@ -0,0 +1,106 @@ +/** + * Database test helper - manages test database lifecycle + * + * NOTE: The smartdata models use a global `db` singleton. This helper + * ensures proper initialization and cleanup for tests. + */ + +import * as plugins from '../../ts/plugins.ts'; +import { testConfig } from '../test.config.ts'; + +// Test database instance - separate from production +let testDb: plugins.smartdata.SmartdataDb | null = null; +let testDbName: string = ''; +let isConnected = false; + +// We need to patch the global db export since models reference it +// This is done by re-initializing with the test config +import { initDb, closeDb } from '../../ts/models/db.ts'; + +/** + * Initialize test database with unique name per test run + */ +export async function setupTestDb(config?: { + mongoUrl?: string; + dbName?: string; +}): Promise { + // If already connected, reuse the connection + if (isConnected && testDb) { + return; + } + + const mongoUrl = config?.mongoUrl || testConfig.mongodb.url; + + // Generate unique database name for this test session + const uniqueId = crypto.randomUUID().slice(0, 8); + testDbName = config?.dbName || `${testConfig.mongodb.name}-${uniqueId}`; + + // Initialize the global db singleton with test configuration + testDb = await initDb(mongoUrl, testDbName); + isConnected = true; +} + +/** + * Clean up test database - deletes all documents from collections + * This is safer than dropping collections which causes index rebuild issues + */ +export async function cleanupTestDb(): Promise { + if (!testDb || !isConnected) return; + + try { + const collections = await testDb.mongoDb.listCollections().toArray(); + for (const col of collections) { + // Delete all documents but preserve indexes + await testDb.mongoDb.collection(col.name).deleteMany({}); + } + } catch (error) { + console.warn('[TestHelper] Error cleaning database:', error); + } +} + +/** + * Teardown test database - drops database and closes connection + */ +export async function teardownTestDb(): Promise { + if (!testDb || !isConnected) return; + + try { + // Drop the test database + await testDb.mongoDb.dropDatabase(); + // Close the connection + await closeDb(); + testDb = null; + isConnected = false; + } catch (error) { + console.warn('[TestHelper] Error tearing down database:', error); + } +} + +/** + * Clear specific collection(s) - deletes all documents + */ +export async function clearCollections(...collectionNames: string[]): Promise { + if (!testDb || !isConnected) return; + + for (const name of collectionNames) { + try { + await testDb.mongoDb.collection(name).deleteMany({}); + } catch { + // Collection may not exist, ignore + } + } +} + +/** + * Get the current test database name + */ +export function getTestDbName(): string { + return testDbName; +} + +/** + * Get the database instance for direct access + */ +export function getTestDb(): plugins.smartdata.SmartdataDb | null { + return testDb; +} diff --git a/test/helpers/factory.helper.ts b/test/helpers/factory.helper.ts new file mode 100644 index 0000000..4b9fe48 --- /dev/null +++ b/test/helpers/factory.helper.ts @@ -0,0 +1,268 @@ +/** + * Factory helper - creates test entities with sensible defaults + */ + +import { Organization } from '../../ts/models/organization.ts'; +import { OrganizationMember } from '../../ts/models/organization.member.ts'; +import { Repository } from '../../ts/models/repository.ts'; +import { Team } from '../../ts/models/team.ts'; +import { TeamMember } from '../../ts/models/team.member.ts'; +import { Package } from '../../ts/models/package.ts'; +import { RepositoryPermission } from '../../ts/models/repository.permission.ts'; +import type { + TOrganizationRole, + TTeamRole, + TRepositoryRole, + TRepositoryVisibility, + TRegistryProtocol, +} from '../../ts/interfaces/auth.interfaces.ts'; + +export interface ICreateTestOrganizationOptions { + createdById: string; + name?: string; + displayName?: string; + description?: string; + isPublic?: boolean; +} + +/** + * Create test organization + */ +export async function createTestOrganization( + options: ICreateTestOrganizationOptions +): Promise { + const uniqueId = crypto.randomUUID().slice(0, 8); + + const org = await Organization.createOrganization({ + name: options.name || `test-org-${uniqueId}`, + displayName: options.displayName || `Test Org ${uniqueId}`, + description: options.description || 'Test organization', + createdById: options.createdById, + }); + + if (options.isPublic !== undefined) { + org.isPublic = options.isPublic; + await org.save(); + } + + return org; +} + +/** + * Create organization with owner membership + */ +export async function createOrgWithOwner( + ownerId: string, + orgOptions?: Partial +): Promise<{ + organization: Organization; + membership: OrganizationMember; +}> { + const organization = await createTestOrganization({ + createdById: ownerId, + ...orgOptions, + }); + + const membership = await OrganizationMember.addMember({ + organizationId: organization.id, + userId: ownerId, + role: 'owner', + }); + + organization.memberCount = 1; + await organization.save(); + + return { organization, membership }; +} + +/** + * Add member to organization + */ +export async function addOrgMember( + organizationId: string, + userId: string, + role: TOrganizationRole = 'member', + invitedBy?: string +): Promise { + const membership = await OrganizationMember.addMember({ + organizationId, + userId, + role, + invitedBy, + }); + + const org = await Organization.findById(organizationId); + if (org) { + org.memberCount += 1; + await org.save(); + } + + return membership; +} + +export interface ICreateTestRepositoryOptions { + organizationId: string; + createdById: string; + name?: string; + protocol?: TRegistryProtocol; + visibility?: TRepositoryVisibility; + description?: string; +} + +/** + * Create test repository + */ +export async function createTestRepository( + options: ICreateTestRepositoryOptions +): Promise { + const uniqueId = crypto.randomUUID().slice(0, 8); + + return Repository.createRepository({ + organizationId: options.organizationId, + name: options.name || `test-repo-${uniqueId}`, + protocol: options.protocol || 'npm', + visibility: options.visibility || 'private', + description: options.description || 'Test repository', + createdById: options.createdById, + }); +} + +export interface ICreateTestTeamOptions { + organizationId: string; + name?: string; + description?: string; +} + +/** + * Create test team + */ +export async function createTestTeam(options: ICreateTestTeamOptions): Promise { + const uniqueId = crypto.randomUUID().slice(0, 8); + + return Team.createTeam({ + organizationId: options.organizationId, + name: options.name || `test-team-${uniqueId}`, + description: options.description || 'Test team', + }); +} + +/** + * Add member to team + */ +export async function addTeamMember( + teamId: string, + userId: string, + role: TTeamRole = 'member' +): Promise { + const member = new TeamMember(); + member.id = await TeamMember.getNewId(); + member.teamId = teamId; + member.userId = userId; + member.role = role; + member.createdAt = new Date(); + await member.save(); + return member; +} + +export interface IGrantRepoPermissionOptions { + repositoryId: string; + userId?: string; + teamId?: string; + role: TRepositoryRole; + grantedById: string; +} + +/** + * Grant repository permission + */ +export async function grantRepoPermission( + options: IGrantRepoPermissionOptions +): Promise { + const perm = new RepositoryPermission(); + perm.id = await RepositoryPermission.getNewId(); + perm.repositoryId = options.repositoryId; + perm.userId = options.userId; + perm.teamId = options.teamId; + perm.role = options.role; + perm.grantedById = options.grantedById; + perm.createdAt = new Date(); + await perm.save(); + return perm; +} + +export interface ICreateTestPackageOptions { + organizationId: string; + repositoryId: string; + createdById: string; + name?: string; + protocol?: TRegistryProtocol; + versions?: string[]; + isPrivate?: boolean; +} + +/** + * Create test package + */ +export async function createTestPackage(options: ICreateTestPackageOptions): Promise { + const uniqueId = crypto.randomUUID().slice(0, 8); + const protocol = options.protocol || 'npm'; + const name = options.name || `test-package-${uniqueId}`; + + const pkg = new Package(); + pkg.id = Package.generateId(protocol, options.organizationId, name); + pkg.organizationId = options.organizationId; + pkg.repositoryId = options.repositoryId; + pkg.protocol = protocol; + pkg.name = name; + pkg.isPrivate = options.isPrivate ?? true; + pkg.createdById = options.createdById; + pkg.createdAt = new Date(); + pkg.updatedAt = new Date(); + + const versions = options.versions || ['1.0.0']; + for (const version of versions) { + pkg.addVersion({ + version, + publishedAt: new Date(), + publishedById: options.createdById, + size: 1024, + digest: `sha256:${crypto.randomUUID().replace(/-/g, '')}`, + downloads: 0, + metadata: {}, + }); + } + + pkg.distTags['latest'] = versions[versions.length - 1]; + await pkg.save(); + return pkg; +} + +/** + * Create complete test scenario with org, repo, team, and package + */ +export async function createFullTestScenario(ownerId: string): Promise<{ + organization: Organization; + repository: Repository; + team: Team; + package: Package; +}> { + const { organization } = await createOrgWithOwner(ownerId); + + const repository = await createTestRepository({ + organizationId: organization.id, + createdById: ownerId, + protocol: 'npm', + }); + + const team = await createTestTeam({ + organizationId: organization.id, + }); + + const pkg = await createTestPackage({ + organizationId: organization.id, + repositoryId: repository.id, + createdById: ownerId, + }); + + return { organization, repository, team, package: pkg }; +} diff --git a/test/helpers/http.helper.ts b/test/helpers/http.helper.ts new file mode 100644 index 0000000..5d13001 --- /dev/null +++ b/test/helpers/http.helper.ts @@ -0,0 +1,116 @@ +/** + * HTTP test helper - utilities for testing API endpoints + */ + +import { testConfig } from '../test.config.ts'; + +export interface ITestRequest { + method: string; + path: string; + body?: unknown; + headers?: Record; + query?: Record; +} + +export interface ITestResponse { + status: number; + body: unknown; + headers: Headers; +} + +/** + * Make a test request to the registry API + */ +export async function testRequest(options: ITestRequest): Promise { + const baseUrl = testConfig.registry.url; + let url = `${baseUrl}${options.path}`; + + if (options.query) { + const params = new URLSearchParams(options.query); + url += `?${params.toString()}`; + } + + const response = await fetch(url, { + method: options.method, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + + let body: unknown; + try { + body = await response.json(); + } catch { + body = await response.text(); + } + + return { + status: response.status, + body, + headers: response.headers, + }; +} + +// Convenience methods +export const get = (path: string, headers?: Record) => + testRequest({ method: 'GET', path, headers }); + +export const post = (path: string, body?: unknown, headers?: Record) => + testRequest({ method: 'POST', path, body, headers }); + +export const put = (path: string, body?: unknown, headers?: Record) => + testRequest({ method: 'PUT', path, body, headers }); + +export const patch = (path: string, body?: unknown, headers?: Record) => + testRequest({ method: 'PATCH', path, body, headers }); + +export const del = (path: string, headers?: Record) => + testRequest({ method: 'DELETE', path, headers }); + +/** + * Assert response status + */ +export function assertStatus(response: ITestResponse, expected: number): void { + if (response.status !== expected) { + throw new Error( + `Expected status ${expected} but got ${response.status}: ${JSON.stringify(response.body)}` + ); + } +} + +/** + * Assert response body has specific keys + */ +export function assertBodyHas(response: ITestResponse, keys: string[]): void { + const body = response.body as Record; + for (const key of keys) { + if (!(key in body)) { + throw new Error(`Expected response to have key "${key}", body: ${JSON.stringify(body)}`); + } + } +} + +/** + * Assert response is successful (2xx) + */ +export function assertSuccess(response: ITestResponse): void { + if (response.status < 200 || response.status >= 300) { + throw new Error( + `Expected successful response but got ${response.status}: ${JSON.stringify(response.body)}` + ); + } +} + +/** + * Assert response is an error (4xx or 5xx) + */ +export function assertError(response: ITestResponse, expectedStatus?: number): void { + if (response.status < 400) { + throw new Error(`Expected error response but got ${response.status}`); + } + if (expectedStatus !== undefined && response.status !== expectedStatus) { + throw new Error(`Expected status ${expectedStatus} but got ${response.status}`); + } +} diff --git a/test/helpers/index.ts b/test/helpers/index.ts new file mode 100644 index 0000000..951d26f --- /dev/null +++ b/test/helpers/index.ts @@ -0,0 +1,85 @@ +/** + * Test helpers index - re-exports all helper modules + */ + +// Database helpers +export { + setupTestDb, + cleanupTestDb, + teardownTestDb, + clearCollections, + getTestDbName, + getTestDb, +} from './db.helper.ts'; + +// Auth helpers +export { + createTestUser, + createAdminUser, + loginUser, + createTestApiToken, + createAuthHeader, + createBasicAuthHeader, + getTestPassword, + type ICreateTestUserOptions, + type ICreateTestApiTokenOptions, +} from './auth.helper.ts'; + +// Factory helpers +export { + createTestOrganization, + createOrgWithOwner, + addOrgMember, + createTestRepository, + createTestTeam, + addTeamMember, + grantRepoPermission, + createTestPackage, + createFullTestScenario, + type ICreateTestOrganizationOptions, + type ICreateTestRepositoryOptions, + type ICreateTestTeamOptions, + type IGrantRepoPermissionOptions, + type ICreateTestPackageOptions, +} from './factory.helper.ts'; + +// HTTP helpers +export { + testRequest, + get, + post, + put, + patch, + del, + assertStatus, + assertBodyHas, + assertSuccess, + assertError, + type ITestRequest, + type ITestResponse, +} from './http.helper.ts'; + +// Subprocess helpers +export { + runCommand, + commandExists, + clients, + skipIfMissing, + type ICommandResult, + type ICommandOptions, +} from './subprocess.helper.ts'; + +// Storage helpers +export { + setupTestStorage, + checkStorageAvailable, + objectExists, + listObjects, + deleteObject, + deletePrefix, + cleanupTestStorage, + isStorageAvailable, +} from './storage.helper.ts'; + +// Re-export test config +export { testConfig, getTestConfig } from '../test.config.ts'; diff --git a/test/helpers/storage.helper.ts b/test/helpers/storage.helper.ts new file mode 100644 index 0000000..8bd4ddf --- /dev/null +++ b/test/helpers/storage.helper.ts @@ -0,0 +1,104 @@ +/** + * Storage helper - S3/MinIO verification utilities for tests + * + * NOTE: These are stub implementations for testing. + * The actual smartbucket API should be verified against the real library. + */ + +import { testConfig } from '../test.config.ts'; + +// Storage is optional for unit/integration tests +// E2E tests with actual S3 operations would need proper implementation +let storageAvailable = false; + +/** + * Check if test storage is available + */ +export async function checkStorageAvailable(): Promise { + try { + // Try to connect to MinIO + const response = await fetch(`${testConfig.s3.endpoint}/minio/health/live`, { + method: 'GET', + }); + storageAvailable = response.ok; + return storageAvailable; + } catch { + storageAvailable = false; + return false; + } +} + +/** + * Initialize test storage connection + */ +export async function setupTestStorage(): Promise { + await checkStorageAvailable(); + if (storageAvailable) { + console.log('[Test Storage] MinIO available at', testConfig.s3.endpoint); + } else { + console.log('[Test Storage] MinIO not available, storage tests will be skipped'); + } +} + +/** + * Check if an object exists in storage (stub) + */ +export async function objectExists(_key: string): Promise { + if (!storageAvailable) return false; + // Would implement actual check here + return false; +} + +/** + * List objects with a given prefix (stub) + */ +export async function listObjects(_prefix: string): Promise { + if (!storageAvailable) return []; + // Would implement actual list here + return []; +} + +/** + * Delete an object from storage (stub) + */ +export async function deleteObject(_key: string): Promise { + if (!storageAvailable) return; + // Would implement actual delete here +} + +/** + * Delete all objects with a given prefix + */ +export async function deletePrefix(prefix: string): Promise { + const objects = await listObjects(prefix); + for (const key of objects) { + await deleteObject(key); + } +} + +/** + * Clean up test storage + */ +export async function cleanupTestStorage(): Promise { + if (!storageAvailable) return; + + try { + // Delete all test objects + await deletePrefix('npm/'); + await deletePrefix('oci/'); + await deletePrefix('maven/'); + await deletePrefix('cargo/'); + await deletePrefix('pypi/'); + await deletePrefix('composer/'); + await deletePrefix('rubygems/'); + } catch { + // Ignore errors + } +} + +/** + * Check if storage is available + */ +export function isStorageAvailable(): boolean { + return storageAvailable; +} diff --git a/test/helpers/subprocess.helper.ts b/test/helpers/subprocess.helper.ts new file mode 100644 index 0000000..4b1d400 --- /dev/null +++ b/test/helpers/subprocess.helper.ts @@ -0,0 +1,208 @@ +/** + * Subprocess helper - utilities for running protocol clients in tests + */ + +export interface ICommandResult { + success: boolean; + stdout: string; + stderr: string; + code: number; + signal?: Deno.Signal; +} + +export interface ICommandOptions { + cwd?: string; + env?: Record; + timeout?: number; + stdin?: string; +} + +/** + * Execute a command and return the result + */ +export async function runCommand( + cmd: string[], + options: ICommandOptions = {} +): Promise { + const { cwd, env, timeout = 60000, stdin } = options; + + const command = new Deno.Command(cmd[0], { + args: cmd.slice(1), + cwd, + env: { ...Deno.env.toObject(), ...env }, + stdin: stdin ? 'piped' : 'null', + stdout: 'piped', + stderr: 'piped', + }); + + const child = command.spawn(); + + if (stdin && child.stdin) { + const writer = child.stdin.getWriter(); + await writer.write(new TextEncoder().encode(stdin)); + await writer.close(); + } + + const timeoutId = setTimeout(() => { + try { + child.kill('SIGTERM'); + } catch { + /* ignore */ + } + }, timeout); + + const output = await child.output(); + clearTimeout(timeoutId); + + return { + success: output.success, + stdout: new TextDecoder().decode(output.stdout), + stderr: new TextDecoder().decode(output.stderr), + code: output.code, + signal: output.signal ?? undefined, + }; +} + +/** + * Check if a command is available + */ +export async function commandExists(cmd: string): Promise { + try { + const result = await runCommand(['which', cmd], { timeout: 5000 }); + return result.success; + } catch { + return false; + } +} + +/** + * Protocol client wrappers + */ +export const clients = { + npm: { + check: () => commandExists('npm'), + publish: (dir: string, registry: string, token: string) => + runCommand(['npm', 'publish', '--registry', registry], { + cwd: dir, + env: { NPM_TOKEN: token, npm_config__authToken: token }, + }), + install: (pkg: string, registry: string, dir: string) => + runCommand(['npm', 'install', pkg, '--registry', registry], { cwd: dir }), + unpublish: (pkg: string, registry: string, token: string) => + runCommand(['npm', 'unpublish', pkg, '--registry', registry, '--force'], { + env: { NPM_TOKEN: token, npm_config__authToken: token }, + }), + pack: (dir: string) => runCommand(['npm', 'pack'], { cwd: dir }), + }, + + docker: { + check: () => commandExists('docker'), + build: (dockerfile: string, tag: string, context: string) => + runCommand(['docker', 'build', '-f', dockerfile, '-t', tag, context]), + push: (image: string) => runCommand(['docker', 'push', image]), + pull: (image: string) => runCommand(['docker', 'pull', image]), + rmi: (image: string, force = false) => + runCommand(['docker', 'rmi', ...(force ? ['-f'] : []), image]), + login: (registry: string, username: string, password: string) => + runCommand(['docker', 'login', registry, '-u', username, '--password-stdin'], { + stdin: password, + }), + tag: (source: string, target: string) => runCommand(['docker', 'tag', source, target]), + }, + + cargo: { + check: () => commandExists('cargo'), + package: (dir: string) => runCommand(['cargo', 'package', '--allow-dirty'], { cwd: dir }), + publish: (dir: string, registry: string, token: string) => + runCommand( + ['cargo', 'publish', '--registry', 'stack-test', '--token', token, '--allow-dirty'], + { cwd: dir } + ), + yank: (crate: string, version: string, token: string) => + runCommand([ + 'cargo', + 'yank', + crate, + '--version', + version, + '--registry', + 'stack-test', + '--token', + token, + ]), + }, + + pip: { + check: () => commandExists('pip'), + build: (dir: string) => runCommand(['python', '-m', 'build', dir]), + upload: (dist: string, repository: string, token: string) => + runCommand([ + 'python', + '-m', + 'twine', + 'upload', + '--repository-url', + repository, + '-u', + '__token__', + '-p', + token, + `${dist}/*`, + ]), + install: (pkg: string, indexUrl: string) => + runCommand(['pip', 'install', pkg, '--index-url', indexUrl]), + }, + + composer: { + check: () => commandExists('composer'), + install: (pkg: string, repository: string, dir: string) => + runCommand( + [ + 'composer', + 'require', + pkg, + '--repository', + JSON.stringify({ type: 'composer', url: repository }), + ], + { cwd: dir } + ), + }, + + gem: { + check: () => commandExists('gem'), + build: (gemspec: string, dir: string) => runCommand(['gem', 'build', gemspec], { cwd: dir }), + push: (gemFile: string, host: string, key: string) => + runCommand(['gem', 'push', gemFile, '--host', host, '--key', key]), + install: (gemName: string, source: string) => + runCommand(['gem', 'install', gemName, '--source', source]), + yank: (gemName: string, version: string, host: string, key: string) => + runCommand(['gem', 'yank', gemName, '-v', version, '--host', host, '--key', key]), + }, + + maven: { + check: () => commandExists('mvn'), + deploy: (dir: string, repositoryUrl: string, username: string, password: string) => + runCommand( + [ + 'mvn', + 'deploy', + `-DaltDeploymentRepository=stack-test::default::${repositoryUrl}`, + `-Dusername=${username}`, + `-Dpassword=${password}`, + ], + { cwd: dir } + ), + package: (dir: string) => runCommand(['mvn', 'package', '-DskipTests'], { cwd: dir }), + }, +}; + +/** + * Skip test if command is not available + */ +export async function skipIfMissing(cmd: string): Promise { + const exists = await commandExists(cmd); + if (!exists) { + console.warn(`[Skip] ${cmd} not available`); + } + return !exists; +} diff --git a/test/integration/auth.test.ts b/test/integration/auth.test.ts new file mode 100644 index 0000000..a69af67 --- /dev/null +++ b/test/integration/auth.test.ts @@ -0,0 +1,169 @@ +/** + * Authentication integration tests + * Tests the full authentication flow through the API + */ + +import { assertEquals, assertExists } from 'jsr:@std/assert'; +import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd'; +import { + setupTestDb, + teardownTestDb, + cleanupTestDb, + createTestUser, + post, + get, + assertStatus, + createAuthHeader, +} from '../helpers/index.ts'; + +describe('Auth API Integration', () => { + beforeAll(async () => { + await setupTestDb(); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await cleanupTestDb(); + }); + + describe('POST /api/v1/auth/login', () => { + it('should login with valid credentials', async () => { + const { user, password } = await createTestUser({ + email: 'api-login@example.com', + status: 'active', + }); + + const response = await post('/api/v1/auth/login', { + email: user.email, + password: password, + }); + + assertStatus(response, 200); + const body = response.body as Record; + assertExists(body.accessToken); + assertExists(body.refreshToken); + assertExists(body.user); + }); + + it('should return 401 for invalid credentials', async () => { + const response = await post('/api/v1/auth/login', { + email: 'nonexistent@example.com', + password: 'wrongpassword', + }); + + assertStatus(response, 401); + const body = response.body as Record; + assertEquals(body.error, 'INVALID_CREDENTIALS'); + }); + + it('should return 401 for inactive user', async () => { + const { user, password } = await createTestUser({ + email: 'suspended@example.com', + status: 'suspended', + }); + + const response = await post('/api/v1/auth/login', { + email: user.email, + password: password, + }); + + assertStatus(response, 401); + const body = response.body as Record; + assertEquals(body.error, 'ACCOUNT_INACTIVE'); + }); + }); + + describe('POST /api/v1/auth/refresh', () => { + it('should refresh access token', async () => { + const { user, password } = await createTestUser({ + email: 'refresh@example.com', + status: 'active', + }); + + // Login first + const loginResponse = await post('/api/v1/auth/login', { + email: user.email, + password: password, + }); + const loginBody = loginResponse.body as Record; + + // Refresh + const refreshResponse = await post('/api/v1/auth/refresh', { + refreshToken: loginBody.refreshToken, + }); + + assertStatus(refreshResponse, 200); + const refreshBody = refreshResponse.body as Record; + assertExists(refreshBody.accessToken); + }); + + it('should return 401 for invalid refresh token', async () => { + const response = await post('/api/v1/auth/refresh', { + refreshToken: 'invalid-token', + }); + + assertStatus(response, 401); + }); + }); + + describe('GET /api/v1/auth/me', () => { + it('should return current user info', async () => { + const { user, password } = await createTestUser({ + email: 'me@example.com', + status: 'active', + }); + + // Login + const loginResponse = await post('/api/v1/auth/login', { + email: user.email, + password: password, + }); + const loginBody = loginResponse.body as Record; + + // Get current user + const meResponse = await get( + '/api/v1/auth/me', + createAuthHeader(loginBody.accessToken as string) + ); + + assertStatus(meResponse, 200); + const meBody = meResponse.body as Record; + assertEquals(meBody.email, user.email); + }); + + it('should return 401 without token', async () => { + const response = await get('/api/v1/auth/me'); + + assertStatus(response, 401); + }); + }); + + describe('POST /api/v1/auth/logout', () => { + it('should invalidate session', async () => { + const { user, password } = await createTestUser({ + email: 'logout@example.com', + status: 'active', + }); + + // Login + const loginResponse = await post('/api/v1/auth/login', { + email: user.email, + password: password, + }); + const loginBody = loginResponse.body as Record; + const token = loginBody.accessToken as string; + + // Logout + const logoutResponse = await post('/api/v1/auth/logout', {}, createAuthHeader(token)); + + assertStatus(logoutResponse, 200); + + // Token should no longer work + const meResponse = await get('/api/v1/auth/me', createAuthHeader(token)); + assertStatus(meResponse, 401); + }); + }); +}); diff --git a/test/integration/organization.test.ts b/test/integration/organization.test.ts new file mode 100644 index 0000000..39500fe --- /dev/null +++ b/test/integration/organization.test.ts @@ -0,0 +1,228 @@ +/** + * Organization integration tests + * Tests organization CRUD and member management through the API + */ + +import { assertEquals, assertExists } from 'jsr:@std/assert'; +import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd'; +import { + setupTestDb, + teardownTestDb, + cleanupTestDb, + createTestUser, + loginUser, + post, + get, + put, + del, + assertStatus, + createAuthHeader, +} from '../helpers/index.ts'; + +describe('Organization API Integration', () => { + let accessToken: string; + let testUserId: string; + + beforeAll(async () => { + await setupTestDb(); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await cleanupTestDb(); + const { user, password } = await createTestUser({ status: 'active' }); + testUserId = user.id; + const tokens = await loginUser(user.email, password); + accessToken = tokens.accessToken; + }); + + describe('POST /api/v1/organizations', () => { + it('should create organization', async () => { + const response = await post( + '/api/v1/organizations', + { + name: 'my-org', + displayName: 'My Organization', + description: 'A test organization', + }, + createAuthHeader(accessToken) + ); + + assertStatus(response, 201); + const body = response.body as Record; + assertEquals(body.name, 'my-org'); + assertEquals(body.displayName, 'My Organization'); + }); + + it('should create organization with dots in name', async () => { + const response = await post( + '/api/v1/organizations', + { + name: 'push.rocks', + displayName: 'Push Rocks', + }, + createAuthHeader(accessToken) + ); + + assertStatus(response, 201); + const body = response.body as Record; + assertEquals(body.name, 'push.rocks'); + }); + + it('should reject duplicate org name', async () => { + await post( + '/api/v1/organizations', + { name: 'duplicate', displayName: 'First' }, + createAuthHeader(accessToken) + ); + + const response = await post( + '/api/v1/organizations', + { name: 'duplicate', displayName: 'Second' }, + createAuthHeader(accessToken) + ); + + assertStatus(response, 409); + }); + + it('should reject invalid org name', async () => { + const response = await post( + '/api/v1/organizations', + { name: '.invalid', displayName: 'Invalid' }, + createAuthHeader(accessToken) + ); + + assertStatus(response, 400); + }); + }); + + describe('GET /api/v1/organizations', () => { + it('should list user organizations', async () => { + // Create some organizations + await post( + '/api/v1/organizations', + { name: 'org1', displayName: 'Org 1' }, + createAuthHeader(accessToken) + ); + await post( + '/api/v1/organizations', + { name: 'org2', displayName: 'Org 2' }, + createAuthHeader(accessToken) + ); + + const response = await get('/api/v1/organizations', createAuthHeader(accessToken)); + + assertStatus(response, 200); + const body = response.body as Record[]; + assertEquals(body.length >= 2, true); + }); + }); + + describe('GET /api/v1/organizations/:orgName', () => { + it('should get organization by name', async () => { + await post( + '/api/v1/organizations', + { name: 'get-me', displayName: 'Get Me' }, + createAuthHeader(accessToken) + ); + + const response = await get('/api/v1/organizations/get-me', createAuthHeader(accessToken)); + + assertStatus(response, 200); + const body = response.body as Record; + assertEquals(body.name, 'get-me'); + }); + + it('should return 404 for non-existent org', async () => { + const response = await get( + '/api/v1/organizations/non-existent', + createAuthHeader(accessToken) + ); + + assertStatus(response, 404); + }); + }); + + describe('PUT /api/v1/organizations/:orgName', () => { + it('should update organization', async () => { + await post( + '/api/v1/organizations', + { name: 'update-me', displayName: 'Original' }, + createAuthHeader(accessToken) + ); + + const response = await put( + '/api/v1/organizations/update-me', + { displayName: 'Updated', description: 'New description' }, + createAuthHeader(accessToken) + ); + + assertStatus(response, 200); + const body = response.body as Record; + assertEquals(body.displayName, 'Updated'); + assertEquals(body.description, 'New description'); + }); + }); + + describe('DELETE /api/v1/organizations/:orgName', () => { + it('should delete organization', async () => { + await post( + '/api/v1/organizations', + { name: 'delete-me', displayName: 'Delete Me' }, + createAuthHeader(accessToken) + ); + + const response = await del('/api/v1/organizations/delete-me', createAuthHeader(accessToken)); + + assertStatus(response, 200); + + // Verify deleted + const getResponse = await get( + '/api/v1/organizations/delete-me', + createAuthHeader(accessToken) + ); + assertStatus(getResponse, 404); + }); + }); + + describe('Organization Members', () => { + it('should list organization members', async () => { + await post( + '/api/v1/organizations', + { name: 'members-org', displayName: 'Members Org' }, + createAuthHeader(accessToken) + ); + + const response = await get( + '/api/v1/organizations/members-org/members', + createAuthHeader(accessToken) + ); + + assertStatus(response, 200); + const body = response.body as Record[]; + assertEquals(body.length >= 1, true); // At least the creator + }); + + it('should add member to organization', async () => { + // Create another user + const { user: newUser } = await createTestUser({ email: 'newmember@example.com' }); + + await post( + '/api/v1/organizations', + { name: 'add-member-org', displayName: 'Add Member Org' }, + createAuthHeader(accessToken) + ); + + const response = await post( + '/api/v1/organizations/add-member-org/members', + { userId: newUser.id, role: 'member' }, + createAuthHeader(accessToken) + ); + + assertStatus(response, 201); + }); + }); +}); diff --git a/test/test.config.ts b/test/test.config.ts new file mode 100644 index 0000000..fb08168 --- /dev/null +++ b/test/test.config.ts @@ -0,0 +1,60 @@ +/** + * Test configuration for Stack.Gallery Registry tests + */ + +export const testConfig = { + mongodb: { + url: 'mongodb://testadmin:testpass@localhost:27117/test-registry?authSource=admin', + name: 'test-registry', + }, + s3: { + endpoint: 'http://localhost:9100', + accessKey: 'testadmin', + secretKey: 'testpassword', + bucket: 'test-registry', + region: 'us-east-1', + }, + jwt: { + secret: 'test-jwt-secret-for-testing-only', + refreshSecret: 'test-refresh-secret-for-testing-only', + }, + registry: { + url: 'http://localhost:3000', + port: 3000, + }, + testUser: { + email: 'test@stack.gallery', + password: 'TestPassword123!', + username: 'testuser', + }, + adminUser: { + email: 'admin@stack.gallery', + password: 'admin', + username: 'admin', + }, +}; + +/** + * Get test config with environment variable overrides + */ +export function getTestConfig() { + return { + ...testConfig, + mongodb: { + ...testConfig.mongodb, + url: Deno.env.get('TEST_MONGODB_URL') || testConfig.mongodb.url, + name: Deno.env.get('TEST_MONGODB_NAME') || testConfig.mongodb.name, + }, + s3: { + ...testConfig.s3, + endpoint: Deno.env.get('TEST_S3_ENDPOINT') || testConfig.s3.endpoint, + accessKey: Deno.env.get('TEST_S3_ACCESS_KEY') || testConfig.s3.accessKey, + secretKey: Deno.env.get('TEST_S3_SECRET_KEY') || testConfig.s3.secretKey, + bucket: Deno.env.get('TEST_S3_BUCKET') || testConfig.s3.bucket, + }, + registry: { + ...testConfig.registry, + url: Deno.env.get('TEST_REGISTRY_URL') || testConfig.registry.url, + }, + }; +} diff --git a/test/unit/models/apitoken.test.ts b/test/unit/models/apitoken.test.ts new file mode 100644 index 0000000..ba4c97f --- /dev/null +++ b/test/unit/models/apitoken.test.ts @@ -0,0 +1,232 @@ +/** + * ApiToken model unit tests + */ + +import { assertEquals, assertExists } from 'jsr:@std/assert'; +import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd'; +import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts'; +import { ApiToken } from '../../../ts/models/apitoken.ts'; + +describe('ApiToken Model', () => { + let testUserId: string; + + beforeAll(async () => { + await setupTestDb(); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await cleanupTestDb(); + const { user } = await createTestUser(); + testUserId = user.id; + }); + + async function createToken(overrides: Partial = {}): Promise { + const token = new ApiToken(); + token.id = await ApiToken.getNewId(); + token.userId = overrides.userId || testUserId; + token.name = overrides.name || 'test-token'; + token.tokenHash = overrides.tokenHash || `hash-${crypto.randomUUID()}`; + token.tokenPrefix = overrides.tokenPrefix || 'srg_test'; + token.protocols = overrides.protocols || ['npm', 'oci']; + token.scopes = overrides.scopes || [{ protocol: '*', actions: ['read', 'write'] }]; + token.createdAt = new Date(); + + if (overrides.expiresAt) token.expiresAt = overrides.expiresAt; + if (overrides.isRevoked) token.isRevoked = overrides.isRevoked; + if (overrides.organizationId) token.organizationId = overrides.organizationId; + + await token.save(); + return token; + } + + describe('findByHash', () => { + it('should find token by hash', async () => { + const created = await createToken({ tokenHash: 'unique-hash-123' }); + + const found = await ApiToken.findByHash('unique-hash-123'); + assertExists(found); + assertEquals(found.id, created.id); + }); + + it('should not find revoked tokens', async () => { + await createToken({ + tokenHash: 'revoked-hash', + isRevoked: true, + }); + + const found = await ApiToken.findByHash('revoked-hash'); + assertEquals(found, null); + }); + }); + + describe('getUserTokens', () => { + it('should return all user tokens', async () => { + await createToken({ name: 'token1' }); + await createToken({ name: 'token2' }); + + const tokens = await ApiToken.getUserTokens(testUserId); + assertEquals(tokens.length, 2); + }); + + it('should not return revoked tokens', async () => { + await createToken({ name: 'active' }); + await createToken({ name: 'revoked', isRevoked: true }); + + const tokens = await ApiToken.getUserTokens(testUserId); + assertEquals(tokens.length, 1); + assertEquals(tokens[0].name, 'active'); + }); + }); + + describe('getOrgTokens', () => { + it('should return organization tokens', async () => { + const orgId = 'org-123'; + await createToken({ name: 'org-token', organizationId: orgId }); + await createToken({ name: 'personal-token' }); // No org + + const tokens = await ApiToken.getOrgTokens(orgId); + assertEquals(tokens.length, 1); + assertEquals(tokens[0].name, 'org-token'); + }); + }); + + describe('isValid', () => { + it('should return true for valid token', async () => { + const token = await createToken(); + assertEquals(token.isValid(), true); + }); + + it('should return false for revoked token', async () => { + const token = await createToken({ isRevoked: true }); + assertEquals(token.isValid(), false); + }); + + it('should return false for expired token', async () => { + const pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - 1); + + const token = await createToken({ expiresAt: pastDate }); + assertEquals(token.isValid(), false); + }); + + it('should return true for non-expired token', async () => { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 30); + + const token = await createToken({ expiresAt: futureDate }); + assertEquals(token.isValid(), true); + }); + }); + + describe('recordUsage', () => { + it('should update usage stats', async () => { + const token = await createToken(); + + await token.recordUsage('192.168.1.1'); + + assertExists(token.lastUsedAt); + assertEquals(token.lastUsedIp, '192.168.1.1'); + assertEquals(token.usageCount, 1); + }); + + it('should increment usage count', async () => { + const token = await createToken(); + + await token.recordUsage(); + await token.recordUsage(); + await token.recordUsage(); + + assertEquals(token.usageCount, 3); + }); + }); + + describe('revoke', () => { + it('should revoke token with reason', async () => { + const token = await createToken(); + + await token.revoke('Security concern'); + + assertEquals(token.isRevoked, true); + assertExists(token.revokedAt); + assertEquals(token.revokedReason, 'Security concern'); + }); + + it('should revoke token without reason', async () => { + const token = await createToken(); + + await token.revoke(); + + assertEquals(token.isRevoked, true); + assertExists(token.revokedAt); + assertEquals(token.revokedReason, undefined); + }); + }); + + describe('hasProtocol', () => { + it('should return true for allowed protocol', async () => { + const token = await createToken({ protocols: ['npm', 'oci'] }); + + assertEquals(token.hasProtocol('npm'), true); + assertEquals(token.hasProtocol('oci'), true); + }); + + it('should return false for disallowed protocol', async () => { + const token = await createToken({ protocols: ['npm'] }); + + assertEquals(token.hasProtocol('maven'), false); + }); + }); + + describe('hasScope', () => { + it('should allow wildcard protocol scope', async () => { + const token = await createToken({ + scopes: [{ protocol: '*', actions: ['read', 'write'] }], + }); + + assertEquals(token.hasScope('npm'), true); + assertEquals(token.hasScope('oci'), true); + assertEquals(token.hasScope('maven'), true); + }); + + it('should restrict by specific protocol', async () => { + const token = await createToken({ + scopes: [{ protocol: 'npm', actions: ['read'] }], + }); + + assertEquals(token.hasScope('npm'), true); + assertEquals(token.hasScope('oci'), false); + }); + + it('should restrict by organization', async () => { + const token = await createToken({ + scopes: [{ protocol: '*', organizationId: 'org-123', actions: ['read'] }], + }); + + assertEquals(token.hasScope('npm', 'org-123'), true); + assertEquals(token.hasScope('npm', 'org-456'), false); + }); + + it('should check action permissions', async () => { + const token = await createToken({ + scopes: [{ protocol: '*', actions: ['read'] }], + }); + + assertEquals(token.hasScope('npm', undefined, undefined, 'read'), true); + assertEquals(token.hasScope('npm', undefined, undefined, 'write'), false); + }); + + it('should allow wildcard action', async () => { + const token = await createToken({ + scopes: [{ protocol: '*', actions: ['*'] }], + }); + + assertEquals(token.hasScope('npm', undefined, undefined, 'read'), true); + assertEquals(token.hasScope('npm', undefined, undefined, 'write'), true); + assertEquals(token.hasScope('npm', undefined, undefined, 'delete'), true); + }); + }); +}); diff --git a/test/unit/models/organization.test.ts b/test/unit/models/organization.test.ts new file mode 100644 index 0000000..b4fd689 --- /dev/null +++ b/test/unit/models/organization.test.ts @@ -0,0 +1,220 @@ +/** + * Organization model unit tests + */ + +import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert'; +import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd'; +import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts'; +import { Organization } from '../../../ts/models/organization.ts'; + +describe('Organization Model', () => { + let testUserId: string; + + beforeAll(async () => { + await setupTestDb(); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await cleanupTestDb(); + const { user } = await createTestUser(); + testUserId = user.id; + }); + + describe('createOrganization', () => { + it('should create an organization with valid data', async () => { + const org = await Organization.createOrganization({ + name: 'test-org', + displayName: 'Test Organization', + description: 'A test organization', + createdById: testUserId, + }); + + assertExists(org.id); + assertEquals(org.name, 'test-org'); + assertEquals(org.displayName, 'Test Organization'); + assertEquals(org.description, 'A test organization'); + assertEquals(org.createdById, testUserId); + assertEquals(org.isPublic, false); + assertEquals(org.memberCount, 0); + assertEquals(org.plan, 'free'); + }); + + it('should allow dots in org name (domain-like)', async () => { + const org = await Organization.createOrganization({ + name: 'push.rocks', + displayName: 'Push Rocks', + createdById: testUserId, + }); + + assertEquals(org.name, 'push.rocks'); + }); + + it('should allow hyphens in org name', async () => { + const org = await Organization.createOrganization({ + name: 'my-awesome-org', + displayName: 'My Awesome Org', + createdById: testUserId, + }); + + assertEquals(org.name, 'my-awesome-org'); + }); + + it('should reject uppercase names (must be lowercase)', async () => { + await assertRejects( + async () => { + await Organization.createOrganization({ + name: 'UPPERCASE', + displayName: 'Uppercase Org', + createdById: testUserId, + }); + }, + Error, + 'lowercase alphanumeric' + ); + }); + + it('should reject invalid names starting with dot', async () => { + await assertRejects( + async () => { + await Organization.createOrganization({ + name: '.invalid', + displayName: 'Invalid', + createdById: testUserId, + }); + }, + Error, + 'lowercase alphanumeric' + ); + }); + + it('should reject invalid names ending with dot', async () => { + await assertRejects( + async () => { + await Organization.createOrganization({ + name: 'invalid.', + displayName: 'Invalid', + createdById: testUserId, + }); + }, + Error, + 'lowercase alphanumeric' + ); + }); + + it('should reject names with special characters', async () => { + await assertRejects( + async () => { + await Organization.createOrganization({ + name: 'invalid@org', + displayName: 'Invalid', + createdById: testUserId, + }); + }, + Error, + 'lowercase alphanumeric' + ); + }); + + it('should set default settings', async () => { + const org = await Organization.createOrganization({ + name: 'defaults', + displayName: 'Defaults Test', + createdById: testUserId, + }); + + assertEquals(org.settings.requireMfa, false); + assertEquals(org.settings.allowPublicRepositories, true); + assertEquals(org.settings.defaultRepositoryVisibility, 'private'); + assertEquals(org.settings.allowedProtocols.length, 7); + }); + }); + + describe('findById', () => { + it('should find organization by ID', async () => { + const created = await Organization.createOrganization({ + name: 'findable', + displayName: 'Findable Org', + createdById: testUserId, + }); + + const found = await Organization.findById(created.id); + assertExists(found); + assertEquals(found.id, created.id); + }); + + it('should return null for non-existent ID', async () => { + const found = await Organization.findById('non-existent-id'); + assertEquals(found, null); + }); + }); + + describe('findByName', () => { + it('should find organization by name (case-insensitive)', async () => { + await Organization.createOrganization({ + name: 'byname', + displayName: 'By Name', + createdById: testUserId, + }); + + const found = await Organization.findByName('BYNAME'); + assertExists(found); + assertEquals(found.name, 'byname'); + }); + }); + + describe('storage quota', () => { + it('should have default 5GB quota', async () => { + const org = await Organization.createOrganization({ + name: 'quota-test', + displayName: 'Quota Test', + createdById: testUserId, + }); + + assertEquals(org.storageQuotaBytes, 5 * 1024 * 1024 * 1024); + assertEquals(org.usedStorageBytes, 0); + }); + + it('should check available storage', async () => { + const org = await Organization.createOrganization({ + name: 'storage-check', + displayName: 'Storage Check', + createdById: testUserId, + }); + + assertEquals(org.hasStorageAvailable(1024), true); + assertEquals(org.hasStorageAvailable(6 * 1024 * 1024 * 1024), false); + }); + + it('should allow unlimited storage with -1 quota', async () => { + const org = await Organization.createOrganization({ + name: 'unlimited', + displayName: 'Unlimited', + createdById: testUserId, + }); + org.storageQuotaBytes = -1; + + assertEquals(org.hasStorageAvailable(1000 * 1024 * 1024 * 1024), true); + }); + + it('should update storage usage', async () => { + const org = await Organization.createOrganization({ + name: 'usage-test', + displayName: 'Usage Test', + createdById: testUserId, + }); + + await org.updateStorageUsage(1000); + assertEquals(org.usedStorageBytes, 1000); + + await org.updateStorageUsage(500); + assertEquals(org.usedStorageBytes, 1500); + + await org.updateStorageUsage(-2000); + assertEquals(org.usedStorageBytes, 0); // Should not go negative + }); + }); +}); diff --git a/test/unit/models/package.test.ts b/test/unit/models/package.test.ts new file mode 100644 index 0000000..0d853c3 --- /dev/null +++ b/test/unit/models/package.test.ts @@ -0,0 +1,240 @@ +/** + * Package model unit tests + */ + +import { assertEquals, assertExists } from 'jsr:@std/assert'; +import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd'; +import { + setupTestDb, + teardownTestDb, + cleanupTestDb, + createTestUser, + createOrgWithOwner, + createTestRepository, +} from '../../helpers/index.ts'; +import { Package } from '../../../ts/models/package.ts'; +import type { IPackageVersion } from '../../../ts/interfaces/package.interfaces.ts'; + +describe('Package Model', () => { + let testUserId: string; + let testOrgId: string; + let testRepoId: string; + + beforeAll(async () => { + await setupTestDb(); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await cleanupTestDb(); + const { user } = await createTestUser(); + testUserId = user.id; + const { organization } = await createOrgWithOwner(testUserId); + testOrgId = organization.id; + const repo = await createTestRepository({ + organizationId: testOrgId, + createdById: testUserId, + protocol: 'npm', + }); + testRepoId = repo.id; + }); + + function createVersion(version: string): IPackageVersion { + return { + version, + publishedAt: new Date(), + publishedBy: testUserId, + size: 1024, + checksum: `sha256-${crypto.randomUUID()}`, + checksumAlgorithm: 'sha256', + downloads: 0, + metadata: {}, + }; + } + + async function createPackage(name: string, versions: string[] = ['1.0.0']): Promise { + const pkg = new Package(); + pkg.id = Package.generateId('npm', testOrgId, name); + pkg.organizationId = testOrgId; + pkg.repositoryId = testRepoId; + pkg.protocol = 'npm'; + pkg.name = name; + pkg.createdById = testUserId; + pkg.createdAt = new Date(); + pkg.updatedAt = new Date(); + + for (const v of versions) { + pkg.addVersion(createVersion(v)); + } + pkg.distTags['latest'] = versions[versions.length - 1]; + + await pkg.save(); + return pkg; + } + + describe('generateId', () => { + it('should generate correct format', () => { + const id = Package.generateId('npm', 'my-org', 'my-package'); + assertEquals(id, 'npm:my-org:my-package'); + }); + }); + + describe('findById', () => { + it('should find package by ID', async () => { + const created = await createPackage('findable'); + + const found = await Package.findById(created.id); + assertExists(found); + assertEquals(found.name, 'findable'); + }); + + it('should return null for non-existent ID', async () => { + const found = await Package.findById('npm:fake:package'); + assertEquals(found, null); + }); + }); + + describe('findByName', () => { + it('should find package by protocol, org, and name', async () => { + await createPackage('by-name'); + + const found = await Package.findByName('npm', testOrgId, 'by-name'); + assertExists(found); + assertEquals(found.name, 'by-name'); + }); + }); + + describe('getOrgPackages', () => { + it('should return all packages in organization', async () => { + await createPackage('pkg1'); + await createPackage('pkg2'); + await createPackage('pkg3'); + + const packages = await Package.getOrgPackages(testOrgId); + assertEquals(packages.length, 3); + }); + }); + + describe('search', () => { + it('should find packages by name', async () => { + await createPackage('search-me'); + await createPackage('find-this'); + await createPackage('other'); + + const results = await Package.search('search'); + assertEquals(results.length, 1); + assertEquals(results[0].name, 'search-me'); + }); + + it('should find packages by description', async () => { + const pkg = await createPackage('described'); + pkg.description = 'A unique description for testing'; + await pkg.save(); + + const results = await Package.search('unique description'); + assertEquals(results.length, 1); + }); + + it('should filter by protocol', async () => { + await createPackage('npm-pkg'); + + const results = await Package.search('npm', { protocol: 'oci' }); + assertEquals(results.length, 0); + }); + + it('should apply pagination', async () => { + await createPackage('page1'); + await createPackage('page2'); + await createPackage('page3'); + + const firstPage = await Package.search('page', { limit: 2, offset: 0 }); + assertEquals(firstPage.length, 2); + + const secondPage = await Package.search('page', { limit: 2, offset: 2 }); + assertEquals(secondPage.length, 1); + }); + }); + + describe('versions', () => { + it('should add version and update storage', async () => { + const pkg = await createPackage('versioned', []); + + pkg.addVersion(createVersion('1.0.0')); + + assertEquals(Object.keys(pkg.versions).length, 1); + assertEquals(pkg.storageBytes, 1024); + }); + + it('should get specific version', async () => { + const pkg = await createPackage('multi-version', ['1.0.0', '1.1.0', '2.0.0']); + + const v1 = pkg.getVersion('1.0.0'); + assertExists(v1); + assertEquals(v1.version, '1.0.0'); + + const v2 = pkg.getVersion('2.0.0'); + assertExists(v2); + assertEquals(v2.version, '2.0.0'); + }); + + it('should return undefined for non-existent version', async () => { + const pkg = await createPackage('single', ['1.0.0']); + + const missing = pkg.getVersion('9.9.9'); + assertEquals(missing, undefined); + }); + }); + + describe('getLatestVersion', () => { + it('should return version from distTags.latest', async () => { + const pkg = await createPackage('tagged', ['1.0.0', '2.0.0']); + pkg.distTags['latest'] = '1.0.0'; // Set older version as latest + await pkg.save(); + + const latest = pkg.getLatestVersion(); + assertExists(latest); + assertEquals(latest.version, '1.0.0'); + }); + + it('should fallback to last version if no latest tag', async () => { + const pkg = await createPackage('untagged', ['1.0.0', '2.0.0']); + delete pkg.distTags['latest']; + + const latest = pkg.getLatestVersion(); + assertExists(latest); + assertEquals(latest.version, '2.0.0'); + }); + + it('should return undefined for empty versions', async () => { + const pkg = await createPackage('empty', []); + delete pkg.distTags['latest']; + + const latest = pkg.getLatestVersion(); + assertEquals(latest, undefined); + }); + }); + + describe('incrementDownloads', () => { + it('should increment total download count', async () => { + const pkg = await createPackage('downloads'); + + await pkg.incrementDownloads(); + assertEquals(pkg.downloadCount, 1); + + await pkg.incrementDownloads(); + await pkg.incrementDownloads(); + assertEquals(pkg.downloadCount, 3); + }); + + it('should increment version-specific downloads', async () => { + const pkg = await createPackage('version-downloads', ['1.0.0', '2.0.0']); + + await pkg.incrementDownloads('1.0.0'); + assertEquals(pkg.versions['1.0.0'].downloads, 1); + assertEquals(pkg.versions['2.0.0'].downloads, 0); + }); + }); +}); diff --git a/test/unit/models/repository.test.ts b/test/unit/models/repository.test.ts new file mode 100644 index 0000000..ff55985 --- /dev/null +++ b/test/unit/models/repository.test.ts @@ -0,0 +1,285 @@ +/** + * Repository model unit tests + */ + +import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert'; +import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd'; +import { + setupTestDb, + teardownTestDb, + cleanupTestDb, + createTestUser, + createOrgWithOwner, +} from '../../helpers/index.ts'; +import { Repository } from '../../../ts/models/repository.ts'; + +describe('Repository Model', () => { + let testUserId: string; + let testOrgId: string; + + beforeAll(async () => { + await setupTestDb(); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await cleanupTestDb(); + const { user } = await createTestUser(); + testUserId = user.id; + const { organization } = await createOrgWithOwner(testUserId); + testOrgId = organization.id; + }); + + describe('createRepository', () => { + it('should create a repository with valid data', async () => { + const repo = await Repository.createRepository({ + organizationId: testOrgId, + name: 'test-repo', + description: 'A test repository', + protocol: 'npm', + createdById: testUserId, + }); + + assertExists(repo.id); + assertEquals(repo.name, 'test-repo'); + assertEquals(repo.organizationId, testOrgId); + assertEquals(repo.protocol, 'npm'); + assertEquals(repo.visibility, 'private'); + assertEquals(repo.downloadCount, 0); + assertEquals(repo.starCount, 0); + }); + + it('should allow dots and underscores in name', async () => { + const repo = await Repository.createRepository({ + organizationId: testOrgId, + name: 'my.test_repo', + protocol: 'npm', + createdById: testUserId, + }); + + assertEquals(repo.name, 'my.test_repo'); + }); + + it('should lowercase the name', async () => { + const repo = await Repository.createRepository({ + organizationId: testOrgId, + name: 'UPPERCASE', + protocol: 'npm', + createdById: testUserId, + }); + + assertEquals(repo.name, 'uppercase'); + }); + + it('should set correct storage namespace', async () => { + const repo = await Repository.createRepository({ + organizationId: testOrgId, + name: 'packages', + protocol: 'npm', + createdById: testUserId, + }); + + assertEquals(repo.storageNamespace, `npm/${testOrgId}/packages`); + }); + + it('should reject duplicate name+protocol in same org', async () => { + await Repository.createRepository({ + organizationId: testOrgId, + name: 'unique', + protocol: 'npm', + createdById: testUserId, + }); + + await assertRejects( + async () => { + await Repository.createRepository({ + organizationId: testOrgId, + name: 'unique', + protocol: 'npm', + createdById: testUserId, + }); + }, + Error, + 'already exists' + ); + }); + + it('should allow same name with different protocol', async () => { + await Repository.createRepository({ + organizationId: testOrgId, + name: 'packages', + protocol: 'npm', + createdById: testUserId, + }); + + const ociRepo = await Repository.createRepository({ + organizationId: testOrgId, + name: 'packages', + protocol: 'oci', + createdById: testUserId, + }); + + assertEquals(ociRepo.name, 'packages'); + assertEquals(ociRepo.protocol, 'oci'); + }); + + it('should reject invalid names', async () => { + await assertRejects( + async () => { + await Repository.createRepository({ + organizationId: testOrgId, + name: '-invalid', + protocol: 'npm', + createdById: testUserId, + }); + }, + Error, + 'lowercase alphanumeric' + ); + }); + + it('should set visibility when provided', async () => { + const repo = await Repository.createRepository({ + organizationId: testOrgId, + name: 'public-repo', + protocol: 'npm', + visibility: 'public', + createdById: testUserId, + }); + + assertEquals(repo.visibility, 'public'); + }); + }); + + describe('findByName', () => { + it('should find repository by org, name, and protocol', async () => { + await Repository.createRepository({ + organizationId: testOrgId, + name: 'findable', + protocol: 'npm', + createdById: testUserId, + }); + + const found = await Repository.findByName(testOrgId, 'FINDABLE', 'npm'); + assertExists(found); + assertEquals(found.name, 'findable'); + }); + + it('should return null for wrong protocol', async () => { + await Repository.createRepository({ + organizationId: testOrgId, + name: 'npm-only', + protocol: 'npm', + createdById: testUserId, + }); + + const found = await Repository.findByName(testOrgId, 'npm-only', 'oci'); + assertEquals(found, null); + }); + }); + + describe('getOrgRepositories', () => { + it('should return all org repositories', async () => { + await Repository.createRepository({ + organizationId: testOrgId, + name: 'repo1', + protocol: 'npm', + createdById: testUserId, + }); + await Repository.createRepository({ + organizationId: testOrgId, + name: 'repo2', + protocol: 'oci', + createdById: testUserId, + }); + await Repository.createRepository({ + organizationId: testOrgId, + name: 'repo3', + protocol: 'maven', + createdById: testUserId, + }); + + const repos = await Repository.getOrgRepositories(testOrgId); + assertEquals(repos.length, 3); + }); + }); + + describe('getPublicRepositories', () => { + it('should return only public repositories', async () => { + await Repository.createRepository({ + organizationId: testOrgId, + name: 'public1', + protocol: 'npm', + visibility: 'public', + createdById: testUserId, + }); + await Repository.createRepository({ + organizationId: testOrgId, + name: 'private1', + protocol: 'npm', + visibility: 'private', + createdById: testUserId, + }); + + const repos = await Repository.getPublicRepositories(); + assertEquals(repos.length, 1); + assertEquals(repos[0].name, 'public1'); + }); + + it('should filter by protocol when provided', async () => { + await Repository.createRepository({ + organizationId: testOrgId, + name: 'npm-public', + protocol: 'npm', + visibility: 'public', + createdById: testUserId, + }); + await Repository.createRepository({ + organizationId: testOrgId, + name: 'oci-public', + protocol: 'oci', + visibility: 'public', + createdById: testUserId, + }); + + const repos = await Repository.getPublicRepositories('npm'); + assertEquals(repos.length, 1); + assertEquals(repos[0].protocol, 'npm'); + }); + }); + + describe('incrementDownloads', () => { + it('should increment download count', async () => { + const repo = await Repository.createRepository({ + organizationId: testOrgId, + name: 'downloads', + protocol: 'npm', + createdById: testUserId, + }); + + await repo.incrementDownloads(); + assertEquals(repo.downloadCount, 1); + + await repo.incrementDownloads(); + await repo.incrementDownloads(); + assertEquals(repo.downloadCount, 3); + }); + }); + + describe('getFullPath', () => { + it('should return org/repo path', async () => { + const repo = await Repository.createRepository({ + organizationId: testOrgId, + name: 'my-package', + protocol: 'npm', + createdById: testUserId, + }); + + const path = repo.getFullPath('my-org'); + assertEquals(path, 'my-org/my-package'); + }); + }); +}); diff --git a/test/unit/models/session.test.ts b/test/unit/models/session.test.ts new file mode 100644 index 0000000..7746468 --- /dev/null +++ b/test/unit/models/session.test.ts @@ -0,0 +1,142 @@ +/** + * Session model unit tests + */ + +import { assertEquals, assertExists } from 'jsr:@std/assert'; +import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd'; +import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts'; +import { Session } from '../../../ts/models/session.ts'; + +describe('Session Model', () => { + let testUserId: string; + + beforeAll(async () => { + await setupTestDb(); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await cleanupTestDb(); + const { user } = await createTestUser(); + testUserId = user.id; + }); + + describe('createSession', () => { + it('should create a session with valid data', async () => { + const session = await Session.createSession({ + userId: testUserId, + userAgent: 'Mozilla/5.0', + ipAddress: '192.168.1.1', + }); + + assertExists(session.id); + assertEquals(session.userId, testUserId); + assertEquals(session.userAgent, 'Mozilla/5.0'); + assertEquals(session.ipAddress, '192.168.1.1'); + assertEquals(session.isValid, true); + assertExists(session.createdAt); + assertExists(session.lastActivityAt); + }); + }); + + describe('findValidSession', () => { + it('should find valid session by ID', async () => { + const created = await Session.createSession({ + userId: testUserId, + userAgent: 'Test Agent', + ipAddress: '127.0.0.1', + }); + + const found = await Session.findValidSession(created.id); + assertExists(found); + assertEquals(found.id, created.id); + }); + + it('should not find invalidated session', async () => { + const session = await Session.createSession({ + userId: testUserId, + userAgent: 'Test Agent', + ipAddress: '127.0.0.1', + }); + await session.invalidate('Logged out'); + + const found = await Session.findValidSession(session.id); + assertEquals(found, null); + }); + }); + + describe('getUserSessions', () => { + it('should return all valid sessions for user', async () => { + await Session.createSession({ userId: testUserId, userAgent: 'Agent 1', ipAddress: '1.1.1.1' }); + await Session.createSession({ userId: testUserId, userAgent: 'Agent 2', ipAddress: '2.2.2.2' }); + await Session.createSession({ userId: testUserId, userAgent: 'Agent 3', ipAddress: '3.3.3.3' }); + + const sessions = await Session.getUserSessions(testUserId); + assertEquals(sessions.length, 3); + }); + + it('should not return invalidated sessions', async () => { + await Session.createSession({ userId: testUserId, userAgent: 'Valid', ipAddress: '1.1.1.1' }); + const invalid = await Session.createSession({ + userId: testUserId, + userAgent: 'Invalid', + ipAddress: '2.2.2.2', + }); + await invalid.invalidate('test'); + + const sessions = await Session.getUserSessions(testUserId); + assertEquals(sessions.length, 1); + }); + }); + + describe('invalidate', () => { + it('should invalidate session with reason', async () => { + const session = await Session.createSession({ + userId: testUserId, + userAgent: 'Test', + ipAddress: '127.0.0.1', + }); + + await session.invalidate('User logged out'); + + assertEquals(session.isValid, false); + assertExists(session.invalidatedAt); + assertEquals(session.invalidatedReason, 'User logged out'); + }); + }); + + describe('invalidateAllUserSessions', () => { + it('should invalidate all user sessions', async () => { + await Session.createSession({ userId: testUserId, userAgent: 'Agent 1', ipAddress: '1.1.1.1' }); + await Session.createSession({ userId: testUserId, userAgent: 'Agent 2', ipAddress: '2.2.2.2' }); + await Session.createSession({ userId: testUserId, userAgent: 'Agent 3', ipAddress: '3.3.3.3' }); + + const count = await Session.invalidateAllUserSessions(testUserId, 'Security logout'); + assertEquals(count, 3); + + const remaining = await Session.getUserSessions(testUserId); + assertEquals(remaining.length, 0); + }); + }); + + describe('touchActivity', () => { + it('should update lastActivityAt', async () => { + const session = await Session.createSession({ + userId: testUserId, + userAgent: 'Test', + ipAddress: '127.0.0.1', + }); + const originalActivity = session.lastActivityAt; + + // Wait a bit to ensure time difference + await new Promise((r) => setTimeout(r, 10)); + + await session.touchActivity(); + + assertEquals(session.lastActivityAt > originalActivity, true); + }); + }); +}); diff --git a/test/unit/models/user.test.ts b/test/unit/models/user.test.ts new file mode 100644 index 0000000..91adce4 --- /dev/null +++ b/test/unit/models/user.test.ts @@ -0,0 +1,228 @@ +/** + * User model unit tests + */ + +import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert'; +import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd'; +import { setupTestDb, teardownTestDb, cleanupTestDb } from '../../helpers/index.ts'; +import { User } from '../../../ts/models/user.ts'; + +describe('User Model', () => { + beforeAll(async () => { + await setupTestDb(); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await cleanupTestDb(); + }); + + describe('createUser', () => { + it('should create a user with valid data', async () => { + const passwordHash = await User.hashPassword('testpassword'); + const user = await User.createUser({ + email: 'test@example.com', + username: 'testuser', + passwordHash, + displayName: 'Test User', + }); + + assertExists(user.id); + assertEquals(user.email, 'test@example.com'); + assertEquals(user.username, 'testuser'); + assertEquals(user.displayName, 'Test User'); + assertEquals(user.status, 'pending_verification'); + assertEquals(user.emailVerified, false); + assertEquals(user.isPlatformAdmin, false); + }); + + it('should lowercase email and username', async () => { + const passwordHash = await User.hashPassword('testpassword'); + const user = await User.createUser({ + email: 'TEST@EXAMPLE.COM', + username: 'TestUser', + passwordHash, + }); + + assertEquals(user.email, 'test@example.com'); + assertEquals(user.username, 'testuser'); + }); + + it('should use username as displayName if not provided', async () => { + const passwordHash = await User.hashPassword('testpassword'); + const user = await User.createUser({ + email: 'test2@example.com', + username: 'testuser2', + passwordHash, + }); + + assertEquals(user.displayName, 'testuser2'); + }); + }); + + describe('findByEmail', () => { + it('should find user by email (case-insensitive)', async () => { + const passwordHash = await User.hashPassword('testpassword'); + await User.createUser({ + email: 'findme@example.com', + username: 'findme', + passwordHash, + }); + + const found = await User.findByEmail('FINDME@example.com'); + assertExists(found); + assertEquals(found.email, 'findme@example.com'); + }); + + it('should return null for non-existent email', async () => { + const found = await User.findByEmail('nonexistent@example.com'); + assertEquals(found, null); + }); + }); + + describe('findByUsername', () => { + it('should find user by username (case-insensitive)', async () => { + const passwordHash = await User.hashPassword('testpassword'); + await User.createUser({ + email: 'user@example.com', + username: 'findbyname', + passwordHash, + }); + + const found = await User.findByUsername('FINDBYNAME'); + assertExists(found); + assertEquals(found.username, 'findbyname'); + }); + }); + + describe('findById', () => { + it('should find user by ID', async () => { + const passwordHash = await User.hashPassword('testpassword'); + const created = await User.createUser({ + email: 'byid@example.com', + username: 'byid', + passwordHash, + }); + + const found = await User.findById(created.id); + assertExists(found); + assertEquals(found.id, created.id); + }); + }); + + describe('password hashing', () => { + it('should hash password with salt', async () => { + const hash = await User.hashPassword('mypassword'); + assertExists(hash); + assertEquals(hash.includes(':'), true); + + const [salt, _hashPart] = hash.split(':'); + assertEquals(salt.length, 32); // 16 bytes = 32 hex chars + }); + + it('should produce different hashes for same password', async () => { + const hash1 = await User.hashPassword('samepassword'); + const hash2 = await User.hashPassword('samepassword'); + + // Different salts should produce different hashes + assertEquals(hash1 !== hash2, true); + }); + }); + + describe('verifyPassword', () => { + it('should verify correct password', async () => { + const passwordHash = await User.hashPassword('correctpassword'); + const user = await User.createUser({ + email: 'verify@example.com', + username: 'verifyuser', + passwordHash, + }); + + const isValid = await user.verifyPassword('correctpassword'); + assertEquals(isValid, true); + }); + + it('should reject incorrect password', async () => { + const passwordHash = await User.hashPassword('correctpassword'); + const user = await User.createUser({ + email: 'reject@example.com', + username: 'rejectuser', + passwordHash, + }); + + const isValid = await user.verifyPassword('wrongpassword'); + assertEquals(isValid, false); + }); + + it('should reject empty password', async () => { + const passwordHash = await User.hashPassword('correctpassword'); + const user = await User.createUser({ + email: 'empty@example.com', + username: 'emptyuser', + passwordHash, + }); + + const isValid = await user.verifyPassword(''); + assertEquals(isValid, false); + }); + }); + + describe('isActive', () => { + it('should return true for active status', async () => { + const passwordHash = await User.hashPassword('test'); + const user = await User.createUser({ + email: 'active@example.com', + username: 'activeuser', + passwordHash, + }); + user.status = 'active'; + await user.save(); + + assertEquals(user.isActive, true); + }); + + it('should return false for suspended status', async () => { + const passwordHash = await User.hashPassword('test'); + const user = await User.createUser({ + email: 'suspended@example.com', + username: 'suspendeduser', + passwordHash, + }); + user.status = 'suspended'; + + assertEquals(user.isActive, false); + }); + }); + + describe('isPlatformAdmin', () => { + it('should default to false', async () => { + const passwordHash = await User.hashPassword('test'); + const user = await User.createUser({ + email: 'notadmin@example.com', + username: 'notadmin', + passwordHash, + }); + + assertEquals(user.isPlatformAdmin, false); + assertEquals(user.isSystemAdmin, false); + }); + + it('should be settable to true', async () => { + const passwordHash = await User.hashPassword('test'); + const user = await User.createUser({ + email: 'admin@example.com', + username: 'adminuser', + passwordHash, + }); + user.isPlatformAdmin = true; + await user.save(); + + const found = await User.findById(user.id); + assertEquals(found!.isPlatformAdmin, true); + assertEquals(found!.isSystemAdmin, true); + }); + }); +}); diff --git a/test/unit/services/auth.service.test.ts b/test/unit/services/auth.service.test.ts new file mode 100644 index 0000000..8b16e58 --- /dev/null +++ b/test/unit/services/auth.service.test.ts @@ -0,0 +1,224 @@ +/** + * AuthService unit tests + */ + +import { assertEquals, assertExists } from 'jsr:@std/assert'; +import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd'; +import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts'; +import { AuthService } from '../../../ts/services/auth.service.ts'; +import { Session } from '../../../ts/models/session.ts'; +import { testConfig } from '../../test.config.ts'; + +describe('AuthService', () => { + let authService: AuthService; + + beforeAll(async () => { + await setupTestDb(); + authService = new AuthService({ + jwtSecret: testConfig.jwt.secret, + accessTokenExpiresIn: 60, // 1 minute for tests + refreshTokenExpiresIn: 300, // 5 minutes for tests + }); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await cleanupTestDb(); + }); + + describe('login', () => { + it('should successfully login with valid credentials', async () => { + const { user, password } = await createTestUser({ + email: 'login@example.com', + status: 'active', + }); + + const result = await authService.login(user.email, password, { + userAgent: 'TestAgent/1.0', + ipAddress: '127.0.0.1', + }); + + assertEquals(result.success, true); + assertExists(result.user); + assertEquals(result.user.id, user.id); + assertExists(result.accessToken); + assertExists(result.refreshToken); + assertExists(result.sessionId); + }); + + it('should fail with invalid email', async () => { + const result = await authService.login('nonexistent@example.com', 'password'); + + assertEquals(result.success, false); + assertEquals(result.errorCode, 'INVALID_CREDENTIALS'); + }); + + it('should fail with invalid password', async () => { + const { user } = await createTestUser({ email: 'wrongpass@example.com' }); + + const result = await authService.login(user.email, 'wrongpassword'); + + assertEquals(result.success, false); + assertEquals(result.errorCode, 'INVALID_CREDENTIALS'); + }); + + it('should fail for inactive user', async () => { + const { user, password } = await createTestUser({ + email: 'inactive@example.com', + status: 'suspended', + }); + + const result = await authService.login(user.email, password); + + assertEquals(result.success, false); + assertEquals(result.errorCode, 'ACCOUNT_INACTIVE'); + }); + + it('should create a session on successful login', async () => { + const { user, password } = await createTestUser({ email: 'session@example.com' }); + + const result = await authService.login(user.email, password); + + assertEquals(result.success, true); + assertExists(result.sessionId); + + const session = await Session.findValidSession(result.sessionId!); + assertExists(session); + assertEquals(session.userId, user.id); + }); + }); + + describe('refresh', () => { + it('should refresh access token with valid refresh token', async () => { + const { user, password } = await createTestUser({ email: 'refresh@example.com' }); + const loginResult = await authService.login(user.email, password); + + assertEquals(loginResult.success, true); + + const refreshResult = await authService.refresh(loginResult.refreshToken!); + + assertEquals(refreshResult.success, true); + assertExists(refreshResult.accessToken); + assertEquals(refreshResult.sessionId, loginResult.sessionId); + }); + + it('should fail with invalid refresh token', async () => { + const result = await authService.refresh('invalid-token'); + + assertEquals(result.success, false); + assertEquals(result.errorCode, 'INVALID_TOKEN'); + }); + + it('should fail when session is invalidated', async () => { + const { user, password } = await createTestUser({ email: 'invalidsession@example.com' }); + const loginResult = await authService.login(user.email, password); + + // Invalidate session + const session = await Session.findValidSession(loginResult.sessionId!); + await session!.invalidate('test'); + + const refreshResult = await authService.refresh(loginResult.refreshToken!); + + assertEquals(refreshResult.success, false); + assertEquals(refreshResult.errorCode, 'SESSION_INVALID'); + }); + }); + + describe('validateAccessToken', () => { + it('should validate valid access token', async () => { + const { user, password } = await createTestUser({ email: 'validate@example.com' }); + const loginResult = await authService.login(user.email, password); + + const validation = await authService.validateAccessToken(loginResult.accessToken!); + + assertExists(validation); + assertEquals(validation.user.id, user.id); + assertEquals(validation.sessionId, loginResult.sessionId); + }); + + it('should reject invalid access token', async () => { + const validation = await authService.validateAccessToken('invalid-token'); + + assertEquals(validation, null); + }); + + it('should reject when session is invalidated', async () => { + const { user, password } = await createTestUser({ email: 'invalidated@example.com' }); + const loginResult = await authService.login(user.email, password); + + // Invalidate session + const session = await Session.findValidSession(loginResult.sessionId!); + await session!.invalidate('test'); + + const validation = await authService.validateAccessToken(loginResult.accessToken!); + + assertEquals(validation, null); + }); + }); + + describe('logout', () => { + it('should invalidate session', async () => { + const { user, password } = await createTestUser({ email: 'logout@example.com' }); + const loginResult = await authService.login(user.email, password); + + const success = await authService.logout(loginResult.sessionId!); + + assertEquals(success, true); + + const session = await Session.findValidSession(loginResult.sessionId!); + assertEquals(session, null); + }); + + it('should return false for non-existent session', async () => { + const success = await authService.logout('non-existent-session-id'); + + assertEquals(success, false); + }); + }); + + describe('logoutAll', () => { + it('should invalidate all user sessions', async () => { + const { user, password } = await createTestUser({ email: 'logoutall@example.com' }); + + // Create multiple sessions + await authService.login(user.email, password); + await authService.login(user.email, password); + await authService.login(user.email, password); + + const count = await authService.logoutAll(user.id); + + assertEquals(count, 3); + + const sessions = await Session.getUserSessions(user.id); + assertEquals(sessions.length, 0); + }); + }); + + describe('static password methods', () => { + it('should hash and verify password', async () => { + const password = 'MySecurePassword123!'; + const hash = await AuthService.hashPassword(password); + + const isValid = await AuthService.verifyPassword(password, hash); + assertEquals(isValid, true); + + const isInvalid = await AuthService.verifyPassword('WrongPassword', hash); + assertEquals(isInvalid, false); + }); + + it('should generate different hashes for same password', async () => { + const password = 'SamePassword'; + const hash1 = await AuthService.hashPassword(password); + const hash2 = await AuthService.hashPassword(password); + + assertEquals(hash1 !== hash2, true); + + // But both should verify + assertEquals(await AuthService.verifyPassword(password, hash1), true); + assertEquals(await AuthService.verifyPassword(password, hash2), true); + }); + }); +}); diff --git a/test/unit/services/token.service.test.ts b/test/unit/services/token.service.test.ts new file mode 100644 index 0000000..ea2885c --- /dev/null +++ b/test/unit/services/token.service.test.ts @@ -0,0 +1,260 @@ +/** + * TokenService unit tests + */ + +import { assertEquals, assertExists, assertMatch } from 'jsr:@std/assert'; +import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd'; +import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts'; +import { TokenService } from '../../../ts/services/token.service.ts'; +import { ApiToken } from '../../../ts/models/apitoken.ts'; + +describe('TokenService', () => { + let tokenService: TokenService; + let testUserId: string; + + beforeAll(async () => { + await setupTestDb(); + tokenService = new TokenService(); + }); + + afterAll(async () => { + await teardownTestDb(); + }); + + beforeEach(async () => { + await cleanupTestDb(); + const { user } = await createTestUser(); + testUserId = user.id; + }); + + describe('createToken', () => { + it('should create token with correct format', async () => { + const result = await tokenService.createToken({ + userId: testUserId, + name: 'test-token', + protocols: ['npm', 'oci'], + scopes: [{ protocol: '*', actions: ['read', 'write'] }], + }); + + assertExists(result.rawToken); + assertExists(result.token); + + // Check token format: srg_{prefix}_{random} + assertMatch(result.rawToken, /^srg_[a-z0-9]+_[a-z0-9]+$/); + assertEquals(result.token.name, 'test-token'); + assertEquals(result.token.protocols.includes('npm'), true); + assertEquals(result.token.protocols.includes('oci'), true); + }); + + it('should store hashed token', async () => { + const result = await tokenService.createToken({ + userId: testUserId, + name: 'hashed-token', + protocols: ['npm'], + scopes: [{ protocol: 'npm', actions: ['read'] }], + }); + + // The stored token should be hashed + assertEquals(result.token.tokenHash !== result.rawToken, true); + assertEquals(result.token.tokenHash.length, 64); // SHA-256 hex + }); + + it('should set expiration when provided', async () => { + const result = await tokenService.createToken({ + userId: testUserId, + name: 'expiring-token', + protocols: ['npm'], + scopes: [{ protocol: 'npm', actions: ['read'] }], + expiresInDays: 30, + }); + + assertExists(result.token.expiresAt); + + const expectedExpiry = new Date(); + expectedExpiry.setDate(expectedExpiry.getDate() + 30); + + // Should be within a few seconds of expected + const diff = Math.abs(result.token.expiresAt.getTime() - expectedExpiry.getTime()); + assertEquals(diff < 5000, true); + }); + + it('should create org-owned token', async () => { + const orgId = 'test-org-123'; + const result = await tokenService.createToken({ + userId: testUserId, + organizationId: orgId, + name: 'org-token', + protocols: ['npm'], + scopes: [{ protocol: 'npm', organizationId: orgId, actions: ['read', 'write'] }], + }); + + assertEquals(result.token.organizationId, orgId); + }); + }); + + describe('validateToken', () => { + it('should validate correct token', async () => { + const { rawToken } = await tokenService.createToken({ + userId: testUserId, + name: 'valid-token', + protocols: ['npm'], + scopes: [{ protocol: 'npm', actions: ['read'] }], + }); + + const validation = await tokenService.validateToken(rawToken, '127.0.0.1'); + + assertExists(validation); + assertEquals(validation.userId, testUserId); + assertEquals(validation.protocols.includes('npm'), true); + }); + + it('should reject invalid token format', async () => { + const validation = await tokenService.validateToken('invalid-format', '127.0.0.1'); + + assertEquals(validation, null); + }); + + it('should reject non-existent token', async () => { + const validation = await tokenService.validateToken('srg_abc123_def456', '127.0.0.1'); + + assertEquals(validation, null); + }); + + it('should reject revoked token', async () => { + const { rawToken, token } = await tokenService.createToken({ + userId: testUserId, + name: 'revoked-token', + protocols: ['npm'], + scopes: [{ protocol: 'npm', actions: ['read'] }], + }); + + await token.revoke('Test revocation'); + + const validation = await tokenService.validateToken(rawToken, '127.0.0.1'); + + assertEquals(validation, null); + }); + + it('should reject expired token', async () => { + const { rawToken, token } = await tokenService.createToken({ + userId: testUserId, + name: 'expired-token', + protocols: ['npm'], + scopes: [{ protocol: 'npm', actions: ['read'] }], + expiresInDays: 1, + }); + + // Manually set expiry to past + token.expiresAt = new Date(Date.now() - 86400000); + await token.save(); + + const validation = await tokenService.validateToken(rawToken, '127.0.0.1'); + + assertEquals(validation, null); + }); + + it('should record usage on validation', async () => { + const { rawToken, token } = await tokenService.createToken({ + userId: testUserId, + name: 'usage-token', + protocols: ['npm'], + scopes: [{ protocol: 'npm', actions: ['read'] }], + }); + + await tokenService.validateToken(rawToken, '192.168.1.100'); + + // Reload token from DB + const updated = await ApiToken.findByHash(token.tokenHash); + assertExists(updated); + assertExists(updated.lastUsedAt); + assertEquals(updated.lastUsedIp, '192.168.1.100'); + assertEquals(updated.usageCount, 1); + }); + }); + + describe('getUserTokens', () => { + it('should return all user tokens', async () => { + await tokenService.createToken({ + userId: testUserId, + name: 'token1', + protocols: ['npm'], + scopes: [{ protocol: 'npm', actions: ['read'] }], + }); + await tokenService.createToken({ + userId: testUserId, + name: 'token2', + protocols: ['oci'], + scopes: [{ protocol: 'oci', actions: ['read'] }], + }); + + const tokens = await tokenService.getUserTokens(testUserId); + + assertEquals(tokens.length, 2); + }); + + it('should not return revoked tokens', async () => { + const { token } = await tokenService.createToken({ + userId: testUserId, + name: 'revoked', + protocols: ['npm'], + scopes: [{ protocol: 'npm', actions: ['read'] }], + }); + await tokenService.createToken({ + userId: testUserId, + name: 'active', + protocols: ['npm'], + scopes: [{ protocol: 'npm', actions: ['read'] }], + }); + + await token.revoke('test'); + + const tokens = await tokenService.getUserTokens(testUserId); + + assertEquals(tokens.length, 1); + assertEquals(tokens[0].name, 'active'); + }); + }); + + describe('revokeToken', () => { + it('should revoke token with reason', async () => { + const { token } = await tokenService.createToken({ + userId: testUserId, + name: 'to-revoke', + protocols: ['npm'], + scopes: [{ protocol: 'npm', actions: ['read'] }], + }); + + await tokenService.revokeToken(token.id, 'Security concern'); + + const updated = await ApiToken.findByPrefix(token.tokenPrefix); + assertExists(updated); + assertEquals(updated.isRevoked, true); + assertEquals(updated.revokedReason, 'Security concern'); + }); + }); + + describe('getOrgTokens', () => { + it('should return organization tokens', async () => { + const orgId = 'org-123'; + + await tokenService.createToken({ + userId: testUserId, + organizationId: orgId, + name: 'org-token', + protocols: ['npm'], + scopes: [{ protocol: 'npm', actions: ['read'] }], + }); + await tokenService.createToken({ + userId: testUserId, + name: 'personal-token', + protocols: ['npm'], + scopes: [{ protocol: 'npm', actions: ['read'] }], + }); + + const tokens = await tokenService.getOrgTokens(orgId); + + assertEquals(tokens.length, 1); + assertEquals(tokens[0].organizationId, orgId); + }); + }); +}); diff --git a/ts/api/handlers/organization.api.ts b/ts/api/handlers/organization.api.ts index bb518a4..50f7b3e 100644 --- a/ts/api/handlers/organization.api.ts +++ b/ts/api/handlers/organization.api.ts @@ -15,6 +15,15 @@ export class OrganizationApi { this.permissionService = permissionService; } + /** + * Helper to resolve organization by ID or name + */ + private async resolveOrganization(idOrName: string): Promise { + return idOrName.startsWith('Organization:') + ? await Organization.findById(idOrName) + : await Organization.findByName(idOrName); + } + /** * GET /api/v1/organizations */ @@ -56,19 +65,20 @@ export class OrganizationApi { /** * GET /api/v1/organizations/:id + * Supports lookup by ID (e.g., Organization:abc123) or by name (e.g., push.rocks) */ public async get(ctx: IApiContext): Promise { const { id } = ctx.params; try { - const org = await Organization.findById(id); + const org = await this.resolveOrganization(id); if (!org) { return { status: 404, body: { error: 'Organization not found' } }; } // Check access - public orgs are visible to all authenticated users if (!org.isPublic && ctx.actor?.userId) { - const isMember = await OrganizationMember.findMembership(id, ctx.actor.userId); + const isMember = await OrganizationMember.findMembership(org.id, ctx.actor.userId); if (!isMember && !ctx.actor.user?.isSystemAdmin) { return { status: 403, body: { error: 'Access denied' } }; } @@ -112,11 +122,11 @@ export class OrganizationApi { return { status: 400, body: { error: 'Organization name is required' } }; } - // Validate name format - if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) { + // Validate name format (allows dots for domain-like names) + if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(name)) { return { status: 400, - body: { error: 'Name must be lowercase alphanumeric with optional hyphens' }, + body: { error: 'Name must be lowercase alphanumeric with optional hyphens and dots' }, }; } @@ -176,6 +186,7 @@ export class OrganizationApi { /** * PUT /api/v1/organizations/:id + * Supports lookup by ID or name */ public async update(ctx: IApiContext): Promise { if (!ctx.actor?.userId) { @@ -184,18 +195,18 @@ export class OrganizationApi { const { id } = ctx.params; - // Check admin permission - const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id); - if (!canManage) { - return { status: 403, body: { error: 'Admin access required' } }; - } - try { - const org = await Organization.findById(id); + const org = await this.resolveOrganization(id); if (!org) { return { status: 404, body: { error: 'Organization not found' } }; } + // Check admin permission using org.id + const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id); + if (!canManage) { + return { status: 403, body: { error: 'Admin access required' } }; + } + const body = await ctx.request.json(); const { displayName, description, avatarUrl, website, isPublic, settings } = body; @@ -232,6 +243,7 @@ export class OrganizationApi { /** * DELETE /api/v1/organizations/:id + * Supports lookup by ID or name */ public async delete(ctx: IApiContext): Promise { if (!ctx.actor?.userId) { @@ -240,18 +252,18 @@ export class OrganizationApi { const { id } = ctx.params; - // Only owners and system admins can delete - const membership = await OrganizationMember.findMembership(id, ctx.actor.userId); - if (membership?.role !== 'owner' && !ctx.actor.user?.isSystemAdmin) { - return { status: 403, body: { error: 'Owner access required' } }; - } - try { - const org = await Organization.findById(id); + const org = await this.resolveOrganization(id); if (!org) { return { status: 404, body: { error: 'Organization not found' } }; } + // Only owners and system admins can delete + const membership = await OrganizationMember.findMembership(org.id, ctx.actor.userId); + if (membership?.role !== 'owner' && !ctx.actor.user?.isSystemAdmin) { + return { status: 403, body: { error: 'Owner access required' } }; + } + // TODO: Check for packages, repositories before deletion // For now, just delete the organization and memberships await org.delete(); @@ -268,6 +280,7 @@ export class OrganizationApi { /** * GET /api/v1/organizations/:id/members + * Supports lookup by ID or name */ public async listMembers(ctx: IApiContext): Promise { if (!ctx.actor?.userId) { @@ -276,14 +289,19 @@ export class OrganizationApi { const { id } = ctx.params; - // Check membership - const isMember = await OrganizationMember.findMembership(id, ctx.actor.userId); - if (!isMember && !ctx.actor.user?.isSystemAdmin) { - return { status: 403, body: { error: 'Access denied' } }; - } - try { - const members = await OrganizationMember.getOrgMembers(id); + const org = await this.resolveOrganization(id); + if (!org) { + return { status: 404, body: { error: 'Organization not found' } }; + } + + // Check membership + const isMember = await OrganizationMember.findMembership(org.id, ctx.actor.userId); + if (!isMember && !ctx.actor.user?.isSystemAdmin) { + return { status: 403, body: { error: 'Access denied' } }; + } + + const members = await OrganizationMember.getOrgMembers(org.id); // Fetch user details const membersWithUsers = await Promise.all( @@ -316,6 +334,7 @@ export class OrganizationApi { /** * POST /api/v1/organizations/:id/members + * Supports lookup by ID or name */ public async addMember(ctx: IApiContext): Promise { if (!ctx.actor?.userId) { @@ -324,13 +343,18 @@ export class OrganizationApi { const { id } = ctx.params; - // Check admin permission - const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id); - if (!canManage) { - return { status: 403, body: { error: 'Admin access required' } }; - } - try { + const org = await this.resolveOrganization(id); + if (!org) { + return { status: 404, body: { error: 'Organization not found' } }; + } + + // Check admin permission + const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id); + if (!canManage) { + return { status: 403, body: { error: 'Admin access required' } }; + } + const body = await ctx.request.json(); const { userId, role } = body as { userId: string; role: TOrganizationRole }; @@ -349,7 +373,7 @@ export class OrganizationApi { } // Check if already a member - const existing = await OrganizationMember.findMembership(id, userId); + const existing = await OrganizationMember.findMembership(org.id, userId); if (existing) { return { status: 409, body: { error: 'User is already a member' } }; } @@ -357,7 +381,7 @@ export class OrganizationApi { // Add member const membership = new OrganizationMember(); membership.id = await OrganizationMember.getNewId(); - membership.organizationId = id; + membership.organizationId = org.id; membership.userId = userId; membership.role = role; membership.addedById = ctx.actor.userId; @@ -366,11 +390,8 @@ export class OrganizationApi { await membership.save(); // Update member count - const org = await Organization.findById(id); - if (org) { - org.memberCount += 1; - await org.save(); - } + org.memberCount += 1; + await org.save(); return { status: 201, @@ -388,6 +409,7 @@ export class OrganizationApi { /** * PUT /api/v1/organizations/:id/members/:userId + * Supports lookup by ID or name */ public async updateMember(ctx: IApiContext): Promise { if (!ctx.actor?.userId) { @@ -396,13 +418,18 @@ export class OrganizationApi { const { id, userId } = ctx.params; - // Check admin permission - const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id); - if (!canManage) { - return { status: 403, body: { error: 'Admin access required' } }; - } - try { + const org = await this.resolveOrganization(id); + if (!org) { + return { status: 404, body: { error: 'Organization not found' } }; + } + + // Check admin permission + const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id); + if (!canManage) { + return { status: 403, body: { error: 'Admin access required' } }; + } + const body = await ctx.request.json(); const { role } = body as { role: TOrganizationRole }; @@ -410,14 +437,14 @@ export class OrganizationApi { return { status: 400, body: { error: 'Valid role is required' } }; } - const membership = await OrganizationMember.findMembership(id, userId); + const membership = await OrganizationMember.findMembership(org.id, userId); if (!membership) { return { status: 404, body: { error: 'Member not found' } }; } // Cannot change last owner if (membership.role === 'owner' && role !== 'owner') { - const owners = await OrganizationMember.getOrgMembers(id); + const owners = await OrganizationMember.getOrgMembers(org.id); const ownerCount = owners.filter((m) => m.role === 'owner').length; if (ownerCount <= 1) { return { status: 400, body: { error: 'Cannot remove the last owner' } }; @@ -442,6 +469,7 @@ export class OrganizationApi { /** * DELETE /api/v1/organizations/:id/members/:userId + * Supports lookup by ID or name */ public async removeMember(ctx: IApiContext): Promise { if (!ctx.actor?.userId) { @@ -450,23 +478,28 @@ export class OrganizationApi { const { id, userId } = ctx.params; - // Users can remove themselves, admins can remove others - if (userId !== ctx.actor.userId) { - const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id); - if (!canManage) { - return { status: 403, body: { error: 'Admin access required' } }; - } - } - try { - const membership = await OrganizationMember.findMembership(id, userId); + const org = await this.resolveOrganization(id); + if (!org) { + return { status: 404, body: { error: 'Organization not found' } }; + } + + // Users can remove themselves, admins can remove others + if (userId !== ctx.actor.userId) { + const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id); + if (!canManage) { + return { status: 403, body: { error: 'Admin access required' } }; + } + } + + const membership = await OrganizationMember.findMembership(org.id, userId); if (!membership) { return { status: 404, body: { error: 'Member not found' } }; } // Cannot remove last owner if (membership.role === 'owner') { - const owners = await OrganizationMember.getOrgMembers(id); + const owners = await OrganizationMember.getOrgMembers(org.id); const ownerCount = owners.filter((m) => m.role === 'owner').length; if (ownerCount <= 1) { return { status: 400, body: { error: 'Cannot remove the last owner' } }; @@ -476,11 +509,8 @@ export class OrganizationApi { await membership.delete(); // Update member count - const org = await Organization.findById(id); - if (org) { - org.memberCount = Math.max(0, org.memberCount - 1); - await org.save(); - } + org.memberCount = Math.max(0, org.memberCount - 1); + await org.save(); return { status: 200, diff --git a/ts/interfaces/auth.interfaces.ts b/ts/interfaces/auth.interfaces.ts index ae18061..5fff6a9 100644 --- a/ts/interfaces/auth.interfaces.ts +++ b/ts/interfaces/auth.interfaces.ts @@ -48,6 +48,9 @@ export interface IOrganization { displayName: string; description?: string; avatarUrl?: string; + website?: string; + isPublic: boolean; + memberCount: number; plan: TOrganizationPlan; settings: IOrganizationSettings; billingEmail?: string; diff --git a/ts/models/organization.ts b/ts/models/organization.ts index 434b73b..527289c 100644 --- a/ts/models/organization.ts +++ b/ts/models/organization.ts @@ -37,6 +37,15 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc { - // Validate name (URL-safe) - const nameRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; + // Validate name (URL-safe, allows dots for domain-like names) + const nameRegex = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/; if (!nameRegex.test(data.name)) { throw new Error( - 'Organization name must be lowercase alphanumeric with optional hyphens' + 'Organization name must be lowercase alphanumeric with optional hyphens and dots' ); } @@ -100,6 +109,13 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc { + return await Organization.getInstance({ id }); + } + /** * Find organization by name (slug) */ diff --git a/ui/src/app/app.routes.ts b/ui/src/app/app.routes.ts index 146b645..a858a94 100644 --- a/ui/src/app/app.routes.ts +++ b/ui/src/app/app.routes.ts @@ -38,14 +38,14 @@ export const routes: Routes = [ ), }, { - path: ':orgId', + path: ':orgName', loadComponent: () => import('./features/organizations/organization-detail.component').then( (m) => m.OrganizationDetailComponent ), }, { - path: ':orgId/repositories/:repoId', + path: ':orgName/repositories/:repoId', loadComponent: () => import('./features/repositories/repository-detail.component').then( (m) => m.RepositoryDetailComponent diff --git a/ui/src/app/features/organizations/organization-detail.component.ts b/ui/src/app/features/organizations/organization-detail.component.ts index 983cc6b..743eb0a 100644 --- a/ui/src/app/features/organizations/organization-detail.component.ts +++ b/ui/src/app/features/organizations/organization-detail.component.ts @@ -11,7 +11,7 @@ import { ToastService } from '../../core/services/toast.service';
@if (loading()) {
- + @@ -20,33 +20,36 @@ import { ToastService } from '../../core/services/toast.service';
-
- +
+ {{ organization()!.name.charAt(0).toUpperCase() }}
-

{{ organization()!.displayName }}

-

@{{ organization()!.name }}

+

{{ organization()!.displayName }}

+

@{{ organization()!.name }}

@if (organization()!.isPublic) { - Public + Public } @else { - Private + Private }
@if (organization()!.description) { -

{{ organization()!.description }}

+

{{ organization()!.description }}

}
-

Repositories

+
+
+ +
} + + + @if (showPublicExplainer()) { + + }
`, }) @@ -164,6 +215,7 @@ export class OrganizationsComponent implements OnInit { organizations = signal([]); loading = signal(true); showCreateModal = signal(false); + showPublicExplainer = signal(false); creating = signal(false); newOrg = { diff --git a/ui/src/app/features/tokens/tokens.component.ts b/ui/src/app/features/tokens/tokens.component.ts index 0b0a864..a3a5bf4 100644 --- a/ui/src/app/features/tokens/tokens.component.ts +++ b/ui/src/app/features/tokens/tokens.component.ts @@ -102,8 +102,8 @@ interface IScopeEntry { @if (showCreateModal()) { -
-
+