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.
This commit is contained in:
2025-11-28 15:27:04 +00:00
parent 61324ba195
commit 44e92d48f2
50 changed files with 4403 additions and 108 deletions

View File

@@ -6,7 +6,12 @@
"tasks": { "tasks": {
"start": "deno run --allow-all mod.ts server", "start": "deno run --allow-all mod.ts server",
"dev": "deno run --allow-all --watch mod.ts server --ephemeral", "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", "build": "cd ui && pnpm run build",
"bundle-ui": "deno run --allow-all scripts/bundle-ui.ts", "bundle-ui": "deno run --allow-all scripts/bundle-ui.ts",
"bundle-ui:watch": "deno run --allow-all scripts/bundle-ui.ts --watch", "bundle-ui:watch": "deno run --allow-all scripts/bundle-ui.ts --watch",

16
deno.lock generated
View File

@@ -1,6 +1,8 @@
{ {
"version": "5", "version": "5",
"specifiers": { "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/cli@^1.0.24": "1.0.24",
"jsr:@std/encoding@1": "1.0.10", "jsr:@std/encoding@1": "1.0.10",
"jsr:@std/encoding@^1.0.10": "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.1.3",
"jsr:@std/path@^1.1.3": "1.1.3", "jsr:@std/path@^1.1.3": "1.1.3",
"jsr:@std/streams@^1.0.14": "1.0.14", "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/smartarchive@5": "5.0.1",
"npm:@push.rocks/smartbucket@^4.3.0": "4.3.0", "npm:@push.rocks/smartbucket@^4.3.0": "4.3.0",
"npm:@push.rocks/smartcli@4": "4.0.19", "npm:@push.rocks/smartcli@4": "4.0.19",
@@ -34,6 +37,12 @@
"npm:concurrently@^9.1.2": "9.2.1" "npm:concurrently@^9.1.2": "9.2.1"
}, },
"jsr": { "jsr": {
"@std/assert@1.0.16": {
"integrity": "6a7272ed1eaa77defe76e5ff63ca705d9c495077e2d5fd0126d2b53fc5bd6532",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/cli@1.0.24": { "@std/cli@1.0.24": {
"integrity": "b655a5beb26aa94f98add6bc8889f5fb9bc3ee2cc3fc954e151201f4c4200a5e" "integrity": "b655a5beb26aa94f98add6bc8889f5fb9bc3ee2cc3fc954e151201f4c4200a5e"
}, },
@@ -84,6 +93,13 @@
}, },
"@std/streams@1.0.14": { "@std/streams@1.0.14": {
"integrity": "c0df6cdd73bd4bbcbe4baa89e323b88418c90ceb2d926f95aa99bdcdbfca2411" "integrity": "c0df6cdd73bd4bbcbe4baa89e323b88418c90ceb2d926f95aa99bdcdbfca2411"
},
"@std/testing@1.0.16": {
"integrity": "a917ffdeb5924c9be436dc78bc32e511760e14d3a96e49c607fc5ecca86d0092",
"dependencies": [
"jsr:@std/assert@^1.0.15",
"jsr:@std/internal"
]
} }
}, },
"npm": { "npm": {

View File

@@ -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;
"

290
test/e2e/npm.e2e.test.ts Normal file
View File

@@ -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);
}
});
});

190
test/e2e/oci.e2e.test.ts Normal file
View File

@@ -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);
});
});

View File

@@ -0,0 +1,15 @@
[package]
name = "demo-crate"
version = "1.0.0"
edition = "2021"
authors = ["Stack.Gallery Test <test@stack.gallery>"]
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"

View File

@@ -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!
}
```

View File

@@ -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!");
}
}

View File

@@ -0,0 +1,13 @@
# stacktest/demo-package
Demo package for Stack.Gallery Registry e2e tests.
## Usage
```php
<?php
use StackTest\DemoPackage\Demo;
echo Demo::greet("World"); // Hello, World!
```

View File

@@ -0,0 +1,21 @@
{
"name": "stacktest/demo-package",
"description": "Demo package for Stack.Gallery Registry e2e tests",
"version": "1.0.0",
"type": "library",
"license": "MIT",
"authors": [
{
"name": "Stack.Gallery Test",
"email": "test@stack.gallery"
}
],
"require": {
"php": ">=8.0"
},
"autoload": {
"psr-4": {
"StackTest\\DemoPackage\\": "src/"
}
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace StackTest\DemoPackage;
/**
* Demo class for Stack.Gallery Registry e2e tests.
*/
class Demo
{
/**
* Greet the given name.
*
* @param string $name The name to greet
* @return string A greeting message
*/
public static function greet(string $name): string
{
return "Hello, {$name}!";
}
}

View File

@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.stacktest</groupId>
<artifactId>demo-artifact</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>Stack.Gallery Demo Artifact</name>
<description>Demo Maven artifact for e2e tests</description>
<url>https://github.com/stack-gallery/demo-artifact</url>
<licenses>
<license>
<name>MIT License</name>
<url>https://opensource.org/licenses/MIT</url>
</license>
</licenses>
<developers>
<developer>
<name>Stack.Gallery Test</name>
<email>test@stack.gallery</email>
</developer>
</developers>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>

View File

@@ -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"));
}
}

View File

@@ -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!
```

View File

@@ -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
};

View File

@@ -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 <test@stack.gallery>",
"license": "MIT",
"keywords": ["demo", "test", "stack-gallery"],
"repository": {
"type": "git",
"url": "https://github.com/stack-gallery/demo-package"
}
}

View File

@@ -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"]

6
test/fixtures/oci/Dockerfile.simple vendored Normal file
View File

@@ -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"]

View File

@@ -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!
```

View File

@@ -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}!"

View File

@@ -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 = ["."]

View File

@@ -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!
```

View File

@@ -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

View File

@@ -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

141
test/helpers/auth.helper.ts Normal file
View File

@@ -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;
}

106
test/helpers/db.helper.ts Normal file
View File

@@ -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<void> {
// 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<void> {
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<void> {
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<void> {
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;
}

View File

@@ -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<Organization> {
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<ICreateTestOrganizationOptions>
): 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<OrganizationMember> {
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<Repository> {
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<Team> {
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<TeamMember> {
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<RepositoryPermission> {
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<Package> {
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 };
}

116
test/helpers/http.helper.ts Normal file
View File

@@ -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<string, string>;
query?: Record<string, string>;
}
export interface ITestResponse {
status: number;
body: unknown;
headers: Headers;
}
/**
* Make a test request to the registry API
*/
export async function testRequest(options: ITestRequest): Promise<ITestResponse> {
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<string, string>) =>
testRequest({ method: 'GET', path, headers });
export const post = (path: string, body?: unknown, headers?: Record<string, string>) =>
testRequest({ method: 'POST', path, body, headers });
export const put = (path: string, body?: unknown, headers?: Record<string, string>) =>
testRequest({ method: 'PUT', path, body, headers });
export const patch = (path: string, body?: unknown, headers?: Record<string, string>) =>
testRequest({ method: 'PATCH', path, body, headers });
export const del = (path: string, headers?: Record<string, string>) =>
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<string, unknown>;
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}`);
}
}

85
test/helpers/index.ts Normal file
View File

@@ -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';

View File

@@ -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<boolean> {
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<void> {
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<boolean> {
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<string[]> {
if (!storageAvailable) return [];
// Would implement actual list here
return [];
}
/**
* Delete an object from storage (stub)
*/
export async function deleteObject(_key: string): Promise<void> {
if (!storageAvailable) return;
// Would implement actual delete here
}
/**
* Delete all objects with a given prefix
*/
export async function deletePrefix(prefix: string): Promise<void> {
const objects = await listObjects(prefix);
for (const key of objects) {
await deleteObject(key);
}
}
/**
* Clean up test storage
*/
export async function cleanupTestStorage(): Promise<void> {
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;
}

View File

@@ -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<string, string>;
timeout?: number;
stdin?: string;
}
/**
* Execute a command and return the result
*/
export async function runCommand(
cmd: string[],
options: ICommandOptions = {}
): Promise<ICommandResult> {
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<boolean> {
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<boolean> {
const exists = await commandExists(cmd);
if (!exists) {
console.warn(`[Skip] ${cmd} not available`);
}
return !exists;
}

View File

@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
// Refresh
const refreshResponse = await post('/api/v1/auth/refresh', {
refreshToken: loginBody.refreshToken,
});
assertStatus(refreshResponse, 200);
const refreshBody = refreshResponse.body as Record<string, unknown>;
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<string, unknown>;
// 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<string, unknown>;
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<string, unknown>;
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);
});
});
});

View File

@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>[];
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<string, unknown>;
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<string, unknown>;
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<string, unknown>[];
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);
});
});
});

60
test/test.config.ts Normal file
View File

@@ -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,
},
};
}

View File

@@ -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<ApiToken> = {}): Promise<ApiToken> {
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);
});
});
});

View File

@@ -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
});
});
});

View File

@@ -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<Package> {
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);
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -15,6 +15,15 @@ export class OrganizationApi {
this.permissionService = permissionService; this.permissionService = permissionService;
} }
/**
* Helper to resolve organization by ID or name
*/
private async resolveOrganization(idOrName: string): Promise<Organization | null> {
return idOrName.startsWith('Organization:')
? await Organization.findById(idOrName)
: await Organization.findByName(idOrName);
}
/** /**
* GET /api/v1/organizations * GET /api/v1/organizations
*/ */
@@ -56,19 +65,20 @@ export class OrganizationApi {
/** /**
* GET /api/v1/organizations/:id * 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<IApiResponse> { public async get(ctx: IApiContext): Promise<IApiResponse> {
const { id } = ctx.params; const { id } = ctx.params;
try { try {
const org = await Organization.findById(id); const org = await this.resolveOrganization(id);
if (!org) { if (!org) {
return { status: 404, body: { error: 'Organization not found' } }; return { status: 404, body: { error: 'Organization not found' } };
} }
// Check access - public orgs are visible to all authenticated users // Check access - public orgs are visible to all authenticated users
if (!org.isPublic && ctx.actor?.userId) { 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) { if (!isMember && !ctx.actor.user?.isSystemAdmin) {
return { status: 403, body: { error: 'Access denied' } }; return { status: 403, body: { error: 'Access denied' } };
} }
@@ -112,11 +122,11 @@ export class OrganizationApi {
return { status: 400, body: { error: 'Organization name is required' } }; return { status: 400, body: { error: 'Organization name is required' } };
} }
// Validate name format // Validate name format (allows dots for domain-like names)
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) { if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(name)) {
return { return {
status: 400, 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 * PUT /api/v1/organizations/:id
* Supports lookup by ID or name
*/ */
public async update(ctx: IApiContext): Promise<IApiResponse> { public async update(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) { if (!ctx.actor?.userId) {
@@ -184,18 +195,18 @@ export class OrganizationApi {
const { id } = ctx.params; 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 { try {
const org = await Organization.findById(id); const org = await this.resolveOrganization(id);
if (!org) { if (!org) {
return { status: 404, body: { error: 'Organization not found' } }; 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 body = await ctx.request.json();
const { displayName, description, avatarUrl, website, isPublic, settings } = body; const { displayName, description, avatarUrl, website, isPublic, settings } = body;
@@ -232,6 +243,7 @@ export class OrganizationApi {
/** /**
* DELETE /api/v1/organizations/:id * DELETE /api/v1/organizations/:id
* Supports lookup by ID or name
*/ */
public async delete(ctx: IApiContext): Promise<IApiResponse> { public async delete(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) { if (!ctx.actor?.userId) {
@@ -240,18 +252,18 @@ export class OrganizationApi {
const { id } = ctx.params; 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 { try {
const org = await Organization.findById(id); const org = await this.resolveOrganization(id);
if (!org) { if (!org) {
return { status: 404, body: { error: 'Organization not found' } }; 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 // TODO: Check for packages, repositories before deletion
// For now, just delete the organization and memberships // For now, just delete the organization and memberships
await org.delete(); await org.delete();
@@ -268,6 +280,7 @@ export class OrganizationApi {
/** /**
* GET /api/v1/organizations/:id/members * GET /api/v1/organizations/:id/members
* Supports lookup by ID or name
*/ */
public async listMembers(ctx: IApiContext): Promise<IApiResponse> { public async listMembers(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) { if (!ctx.actor?.userId) {
@@ -276,14 +289,19 @@ export class OrganizationApi {
const { id } = ctx.params; const { id } = ctx.params;
try {
const org = await this.resolveOrganization(id);
if (!org) {
return { status: 404, body: { error: 'Organization not found' } };
}
// Check membership // Check membership
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) { if (!isMember && !ctx.actor.user?.isSystemAdmin) {
return { status: 403, body: { error: 'Access denied' } }; return { status: 403, body: { error: 'Access denied' } };
} }
try { const members = await OrganizationMember.getOrgMembers(org.id);
const members = await OrganizationMember.getOrgMembers(id);
// Fetch user details // Fetch user details
const membersWithUsers = await Promise.all( const membersWithUsers = await Promise.all(
@@ -316,6 +334,7 @@ export class OrganizationApi {
/** /**
* POST /api/v1/organizations/:id/members * POST /api/v1/organizations/:id/members
* Supports lookup by ID or name
*/ */
public async addMember(ctx: IApiContext): Promise<IApiResponse> { public async addMember(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) { if (!ctx.actor?.userId) {
@@ -324,13 +343,18 @@ export class OrganizationApi {
const { id } = ctx.params; const { id } = ctx.params;
try {
const org = await this.resolveOrganization(id);
if (!org) {
return { status: 404, body: { error: 'Organization not found' } };
}
// Check admin permission // Check admin permission
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id); const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
if (!canManage) { if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } }; return { status: 403, body: { error: 'Admin access required' } };
} }
try {
const body = await ctx.request.json(); const body = await ctx.request.json();
const { userId, role } = body as { userId: string; role: TOrganizationRole }; const { userId, role } = body as { userId: string; role: TOrganizationRole };
@@ -349,7 +373,7 @@ export class OrganizationApi {
} }
// Check if already a member // Check if already a member
const existing = await OrganizationMember.findMembership(id, userId); const existing = await OrganizationMember.findMembership(org.id, userId);
if (existing) { if (existing) {
return { status: 409, body: { error: 'User is already a member' } }; return { status: 409, body: { error: 'User is already a member' } };
} }
@@ -357,7 +381,7 @@ export class OrganizationApi {
// Add member // Add member
const membership = new OrganizationMember(); const membership = new OrganizationMember();
membership.id = await OrganizationMember.getNewId(); membership.id = await OrganizationMember.getNewId();
membership.organizationId = id; membership.organizationId = org.id;
membership.userId = userId; membership.userId = userId;
membership.role = role; membership.role = role;
membership.addedById = ctx.actor.userId; membership.addedById = ctx.actor.userId;
@@ -366,11 +390,8 @@ export class OrganizationApi {
await membership.save(); await membership.save();
// Update member count // Update member count
const org = await Organization.findById(id);
if (org) {
org.memberCount += 1; org.memberCount += 1;
await org.save(); await org.save();
}
return { return {
status: 201, status: 201,
@@ -388,6 +409,7 @@ export class OrganizationApi {
/** /**
* PUT /api/v1/organizations/:id/members/:userId * PUT /api/v1/organizations/:id/members/:userId
* Supports lookup by ID or name
*/ */
public async updateMember(ctx: IApiContext): Promise<IApiResponse> { public async updateMember(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) { if (!ctx.actor?.userId) {
@@ -396,13 +418,18 @@ export class OrganizationApi {
const { id, userId } = ctx.params; const { id, userId } = ctx.params;
try {
const org = await this.resolveOrganization(id);
if (!org) {
return { status: 404, body: { error: 'Organization not found' } };
}
// Check admin permission // Check admin permission
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id); const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
if (!canManage) { if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } }; return { status: 403, body: { error: 'Admin access required' } };
} }
try {
const body = await ctx.request.json(); const body = await ctx.request.json();
const { role } = body as { role: TOrganizationRole }; const { role } = body as { role: TOrganizationRole };
@@ -410,14 +437,14 @@ export class OrganizationApi {
return { status: 400, body: { error: 'Valid role is required' } }; 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) { if (!membership) {
return { status: 404, body: { error: 'Member not found' } }; return { status: 404, body: { error: 'Member not found' } };
} }
// Cannot change last owner // Cannot change last owner
if (membership.role === 'owner' && role !== '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; const ownerCount = owners.filter((m) => m.role === 'owner').length;
if (ownerCount <= 1) { if (ownerCount <= 1) {
return { status: 400, body: { error: 'Cannot remove the last owner' } }; return { status: 400, body: { error: 'Cannot remove the last owner' } };
@@ -442,6 +469,7 @@ export class OrganizationApi {
/** /**
* DELETE /api/v1/organizations/:id/members/:userId * DELETE /api/v1/organizations/:id/members/:userId
* Supports lookup by ID or name
*/ */
public async removeMember(ctx: IApiContext): Promise<IApiResponse> { public async removeMember(ctx: IApiContext): Promise<IApiResponse> {
if (!ctx.actor?.userId) { if (!ctx.actor?.userId) {
@@ -450,23 +478,28 @@ export class OrganizationApi {
const { id, userId } = ctx.params; const { id, userId } = ctx.params;
try {
const org = await this.resolveOrganization(id);
if (!org) {
return { status: 404, body: { error: 'Organization not found' } };
}
// Users can remove themselves, admins can remove others // Users can remove themselves, admins can remove others
if (userId !== ctx.actor.userId) { if (userId !== ctx.actor.userId) {
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id); const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
if (!canManage) { if (!canManage) {
return { status: 403, body: { error: 'Admin access required' } }; return { status: 403, body: { error: 'Admin access required' } };
} }
} }
try { const membership = await OrganizationMember.findMembership(org.id, userId);
const membership = await OrganizationMember.findMembership(id, userId);
if (!membership) { if (!membership) {
return { status: 404, body: { error: 'Member not found' } }; return { status: 404, body: { error: 'Member not found' } };
} }
// Cannot remove last owner // Cannot remove last owner
if (membership.role === '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; const ownerCount = owners.filter((m) => m.role === 'owner').length;
if (ownerCount <= 1) { if (ownerCount <= 1) {
return { status: 400, body: { error: 'Cannot remove the last owner' } }; return { status: 400, body: { error: 'Cannot remove the last owner' } };
@@ -476,11 +509,8 @@ export class OrganizationApi {
await membership.delete(); await membership.delete();
// Update member count // Update member count
const org = await Organization.findById(id);
if (org) {
org.memberCount = Math.max(0, org.memberCount - 1); org.memberCount = Math.max(0, org.memberCount - 1);
await org.save(); await org.save();
}
return { return {
status: 200, status: 200,

View File

@@ -48,6 +48,9 @@ export interface IOrganization {
displayName: string; displayName: string;
description?: string; description?: string;
avatarUrl?: string; avatarUrl?: string;
website?: string;
isPublic: boolean;
memberCount: number;
plan: TOrganizationPlan; plan: TOrganizationPlan;
settings: IOrganizationSettings; settings: IOrganizationSettings;
billingEmail?: string; billingEmail?: string;

View File

@@ -37,6 +37,15 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization,
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
public avatarUrl?: string; public avatarUrl?: string;
@plugins.smartdata.svDb()
public website?: string;
@plugins.smartdata.svDb()
public isPublic: boolean = false;
@plugins.smartdata.svDb()
public memberCount: number = 0;
@plugins.smartdata.svDb() @plugins.smartdata.svDb()
@plugins.smartdata.index() @plugins.smartdata.index()
public plan: TOrganizationPlan = 'free'; public plan: TOrganizationPlan = 'free';
@@ -79,11 +88,11 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization,
description?: string; description?: string;
createdById: string; createdById: string;
}): Promise<Organization> { }): Promise<Organization> {
// Validate name (URL-safe) // Validate name (URL-safe, allows dots for domain-like names)
const nameRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/; const nameRegex = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
if (!nameRegex.test(data.name)) { if (!nameRegex.test(data.name)) {
throw new Error( 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<Organization,
return org; return org;
} }
/**
* Find organization by ID
*/
public static async findById(id: string): Promise<Organization | null> {
return await Organization.getInstance({ id });
}
/** /**
* Find organization by name (slug) * Find organization by name (slug)
*/ */

View File

@@ -38,14 +38,14 @@ export const routes: Routes = [
), ),
}, },
{ {
path: ':orgId', path: ':orgName',
loadComponent: () => loadComponent: () =>
import('./features/organizations/organization-detail.component').then( import('./features/organizations/organization-detail.component').then(
(m) => m.OrganizationDetailComponent (m) => m.OrganizationDetailComponent
), ),
}, },
{ {
path: ':orgId/repositories/:repoId', path: ':orgName/repositories/:repoId',
loadComponent: () => loadComponent: () =>
import('./features/repositories/repository-detail.component').then( import('./features/repositories/repository-detail.component').then(
(m) => m.RepositoryDetailComponent (m) => m.RepositoryDetailComponent

View File

@@ -11,7 +11,7 @@ import { ToastService } from '../../core/services/toast.service';
<div class="p-6 max-w-7xl mx-auto"> <div class="p-6 max-w-7xl mx-auto">
@if (loading()) { @if (loading()) {
<div class="flex items-center justify-center py-12"> <div class="flex items-center justify-center py-12">
<svg class="animate-spin h-8 w-8 text-primary-600" fill="none" viewBox="0 0 24 24"> <svg class="animate-spin h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg> </svg>
@@ -20,33 +20,36 @@ import { ToastService } from '../../core/services/toast.service';
<!-- Header --> <!-- Header -->
<div class="flex items-start justify-between mb-8"> <div class="flex items-start justify-between mb-8">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="w-16 h-16 bg-gray-200 dark:bg-gray-700 rounded-xl flex items-center justify-center"> <div class="w-16 h-16 bg-muted flex items-center justify-center">
<span class="text-2xl font-medium text-gray-600 dark:text-gray-300"> <span class="font-mono text-2xl font-medium text-muted-foreground">
{{ organization()!.name.charAt(0).toUpperCase() }} {{ organization()!.name.charAt(0).toUpperCase() }}
</span> </span>
</div> </div>
<div> <div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ organization()!.displayName }}</h1> <h1 class="font-mono text-2xl font-bold text-foreground">{{ organization()!.displayName }}</h1>
<p class="text-gray-500 dark:text-gray-400">&#64;{{ organization()!.name }}</p> <p class="font-mono text-muted-foreground">&#64;{{ organization()!.name }}</p>
</div> </div>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@if (organization()!.isPublic) { @if (organization()!.isPublic) {
<span class="badge-default">Public</span> <span class="badge-accent">Public</span>
} @else { } @else {
<span class="badge-warning">Private</span> <span class="badge-primary">Private</span>
} }
</div> </div>
</div> </div>
@if (organization()!.description) { @if (organization()!.description) {
<p class="text-gray-600 dark:text-gray-400 mb-8">{{ organization()!.description }}</p> <p class="font-mono text-muted-foreground mb-8">{{ organization()!.description }}</p>
} }
<!-- Repositories Section --> <!-- Repositories Section -->
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Repositories</h2> <div class="section-header">
<div class="section-indicator"></div>
<span class="section-label">Repositories</span>
</div>
<button class="btn-primary btn-sm"> <button class="btn-primary btn-sm">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
@@ -57,26 +60,26 @@ import { ToastService } from '../../core/services/toast.service';
@if (repositories().length === 0) { @if (repositories().length === 0) {
<div class="card card-content text-center py-8"> <div class="card card-content text-center py-8">
<p class="text-gray-500 dark:text-gray-400">No repositories yet</p> <p class="font-mono text-muted-foreground">No repositories yet</p>
</div> </div>
} @else { } @else {
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@for (repo of repositories(); track repo.id) { @for (repo of repositories(); track repo.id) {
<a [routerLink]="['repositories', repo.id]" class="card card-content hover:border-primary-300 dark:hover:border-primary-700 transition-colors"> <a [routerLink]="['repositories', repo.id]" class="card card-content hover:border-primary/50 transition-colors">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div> <div>
<h3 class="font-medium text-gray-900 dark:text-gray-100">{{ repo.displayName }}</h3> <h3 class="font-mono font-medium text-foreground">{{ repo.displayName }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ repo.name }}</p> <p class="font-mono text-sm text-muted-foreground">{{ repo.name }}</p>
</div> </div>
@if (repo.isPublic) { @if (repo.isPublic) {
<span class="badge-default">Public</span> <span class="badge-accent">Public</span>
} }
</div> </div>
@if (repo.description) { @if (repo.description) {
<p class="text-sm text-gray-600 dark:text-gray-400 mt-2 line-clamp-2">{{ repo.description }}</p> <p class="font-mono text-sm text-muted-foreground mt-2 line-clamp-2">{{ repo.description }}</p>
} }
<div class="mt-3 flex items-center gap-4"> <div class="mt-3 flex items-center gap-4">
<div class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400"> <div class="flex items-center gap-1 font-mono text-sm text-muted-foreground">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg> </svg>
@@ -95,18 +98,24 @@ import { ToastService } from '../../core/services/toast.service';
</div> </div>
<!-- Stats --> <!-- Stats -->
<div class="mb-4">
<div class="section-header">
<div class="section-indicator"></div>
<span class="section-label">Statistics</span>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="card card-content"> <div class="card card-content">
<p class="text-sm text-gray-500 dark:text-gray-400">Members</p> <p class="font-mono text-sm text-muted-foreground">Members</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ organization()!.memberCount }}</p> <p class="font-mono text-2xl font-bold text-foreground">{{ organization()!.memberCount }}</p>
</div> </div>
<div class="card card-content"> <div class="card card-content">
<p class="text-sm text-gray-500 dark:text-gray-400">Repositories</p> <p class="font-mono text-sm text-muted-foreground">Repositories</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ repositories().length }}</p> <p class="font-mono text-2xl font-bold text-foreground">{{ repositories().length }}</p>
</div> </div>
<div class="card card-content"> <div class="card card-content">
<p class="text-sm text-gray-500 dark:text-gray-400">Created</p> <p class="font-mono text-sm text-muted-foreground">Created</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ formatDate(organization()!.createdAt) }}</p> <p class="font-mono text-2xl font-bold text-foreground">{{ formatDate(organization()!.createdAt) }}</p>
</div> </div>
</div> </div>
} }
@@ -123,18 +132,18 @@ export class OrganizationDetailComponent implements OnInit {
loading = signal(true); loading = signal(true);
ngOnInit(): void { ngOnInit(): void {
const orgId = this.route.snapshot.paramMap.get('orgId'); const orgName = this.route.snapshot.paramMap.get('orgName');
if (orgId) { if (orgName) {
this.loadData(orgId); this.loadData(orgName);
} }
} }
private async loadData(orgId: string): Promise<void> { private async loadData(orgName: string): Promise<void> {
this.loading.set(true); this.loading.set(true);
try { try {
const [org, reposResponse] = await Promise.all([ const [org, reposResponse] = await Promise.all([
this.apiService.getOrganization(orgId).toPromise(), this.apiService.getOrganization(orgName).toPromise(),
this.apiService.getRepositories(orgId).toPromise(), this.apiService.getRepositories(orgName).toPromise(),
]); ]);
this.organization.set(org || null); this.organization.set(org || null);
this.repositories.set(reposResponse?.repositories || []); this.repositories.set(reposResponse?.repositories || []);

View File

@@ -47,7 +47,7 @@ import { ToastService } from '../../core/services/toast.service';
} @else { } @else {
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@for (org of organizations(); track org.id) { @for (org of organizations(); track org.id) {
<a [routerLink]="['/organizations', org.id]" class="card hover:border-primary/50 transition-colors"> <a [routerLink]="['/organizations', org.name]" class="card hover:border-primary/50 transition-colors">
<div class="card-content"> <div class="card-content">
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<div class="w-12 h-12 bg-muted flex items-center justify-center flex-shrink-0"> <div class="w-12 h-12 bg-muted flex items-center justify-center flex-shrink-0">
@@ -84,8 +84,8 @@ import { ToastService } from '../../core/services/toast.service';
<!-- Create Modal --> <!-- Create Modal -->
@if (showCreateModal()) { @if (showCreateModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80"> <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
<div class="card w-full max-w-md mx-4"> <div class="card w-full max-w-md mx-4 modal-content">
<div class="card-header flex items-center justify-between"> <div class="card-header flex items-center justify-between">
<div class="section-header"> <div class="section-header">
<div class="section-indicator"></div> <div class="section-indicator"></div>
@@ -105,20 +105,20 @@ import { ToastService } from '../../core/services/toast.service';
[(ngModel)]="newOrg.name" [(ngModel)]="newOrg.name"
name="name" name="name"
class="input" class="input"
placeholder="my-organization" placeholder="push.rocks"
required required
pattern="^[a-z0-9]([a-z0-9-]*[a-z0-9])?$" pattern="^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$"
/> />
<p class="font-mono text-xs text-muted-foreground mt-1">Lowercase letters, numbers, and hyphens only</p> <p class="font-mono text-xs text-muted-foreground mt-1">Lowercase letters, numbers, hyphens, and dots (e.g., push.rocks)</p>
</div> </div>
<div> <div>
<label class="label block mb-1.5">Display Name</label> <label class="label block mb-1.5">Display Name (optional)</label>
<input <input
type="text" type="text"
[(ngModel)]="newOrg.displayName" [(ngModel)]="newOrg.displayName"
name="displayName" name="displayName"
class="input" class="input"
placeholder="My Organization" placeholder="Defaults to name if empty"
/> />
</div> </div>
<div> <div>
@@ -139,6 +139,11 @@ import { ToastService } from '../../core/services/toast.service';
class="w-4 h-4 border-border text-primary focus:ring-primary" class="w-4 h-4 border-border text-primary focus:ring-primary"
/> />
<label for="isPublic" class="font-mono text-sm text-foreground">Make this organization public</label> <label for="isPublic" class="font-mono text-sm text-foreground">Make this organization public</label>
<button type="button" (click)="showPublicExplainer.set(true)" class="btn-ghost p-0 h-5 w-5 text-muted-foreground hover:text-foreground">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</div> </div>
</form> </form>
<div class="card-footer flex justify-end gap-3"> <div class="card-footer flex justify-end gap-3">
@@ -154,6 +159,52 @@ import { ToastService } from '../../core/services/toast.service';
</div> </div>
</div> </div>
} }
<!-- Public/Private Explainer Modal -->
@if (showPublicExplainer()) {
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
<div class="card w-full max-w-md mx-4 modal-content">
<div class="card-header flex items-center justify-between">
<div class="section-header">
<div class="section-indicator"></div>
<span class="font-mono text-sm font-semibold text-foreground uppercase">Organization Visibility</span>
</div>
<button (click)="showPublicExplainer.set(false)" class="btn-ghost btn-sm p-1">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="card-content space-y-4">
<div class="flex items-start gap-3">
<div class="w-8 h-8 bg-accent/10 flex items-center justify-center flex-shrink-0">
<svg class="w-4 h-4 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h4 class="font-mono text-sm font-semibold text-foreground mb-1">Public Organization</h4>
<p class="font-mono text-sm text-muted-foreground">Anyone can view this organization and its public repositories. Useful for open-source projects or public packages.</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="w-8 h-8 bg-primary/10 flex items-center justify-center flex-shrink-0">
<svg class="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<h4 class="font-mono text-sm font-semibold text-foreground mb-1">Private Organization</h4>
<p class="font-mono text-sm text-muted-foreground">Only organization members can see this organization and access its repositories. Best for internal or proprietary packages.</p>
</div>
</div>
</div>
<div class="card-footer flex justify-end">
<button (click)="showPublicExplainer.set(false)" class="btn-primary btn-md">Got it</button>
</div>
</div>
</div>
}
</div> </div>
`, `,
}) })
@@ -164,6 +215,7 @@ export class OrganizationsComponent implements OnInit {
organizations = signal<IOrganization[]>([]); organizations = signal<IOrganization[]>([]);
loading = signal(true); loading = signal(true);
showCreateModal = signal(false); showCreateModal = signal(false);
showPublicExplainer = signal(false);
creating = signal(false); creating = signal(false);
newOrg = { newOrg = {

View File

@@ -102,8 +102,8 @@ interface IScopeEntry {
<!-- Create Modal --> <!-- Create Modal -->
@if (showCreateModal()) { @if (showCreateModal()) {
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 overflow-y-auto py-8"> <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 overflow-y-auto py-8 modal-backdrop">
<div class="card w-full max-w-2xl mx-4"> <div class="card w-full max-w-2xl mx-4 modal-content">
<div class="card-header flex items-center justify-between"> <div class="card-header flex items-center justify-between">
<div class="section-header"> <div class="section-header">
<div class="section-indicator"></div> <div class="section-indicator"></div>
@@ -284,8 +284,8 @@ interface IScopeEntry {
<!-- Token Created Modal --> <!-- Token Created Modal -->
@if (createdToken()) { @if (createdToken()) {
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80"> <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 modal-backdrop">
<div class="card w-full max-w-lg mx-4"> <div class="card w-full max-w-lg mx-4 modal-content">
<div class="card-header"> <div class="card-header">
<div class="section-header"> <div class="section-header">
<div class="section-indicator bg-accent"></div> <div class="section-indicator bg-accent"></div>

View File

@@ -259,4 +259,44 @@
.status-error { .status-error {
@apply bg-destructive; @apply bg-destructive;
} }
/* Modal animations */
.modal-backdrop {
@apply animate-fade-in;
}
.modal-content {
@apply animate-modal-in;
}
}
/* Custom animations */
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-in {
from {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@layer utilities {
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
.animate-modal-in {
animation: modal-in 0.2s ease-out;
}
} }