2 Commits

15 changed files with 1186 additions and 4 deletions

View File

@@ -1,5 +1,13 @@
# Changelog
## 2026-03-21 - 1.5.0 - feat(test)
add end-to-end test coverage for container lifecycle, auth, buckets, objects, policies, credentials, status, and S3 compatibility
- adds a reusable test helper for creating isolated ObjectStorageContainer instances and logging in as admin
- introduces a test script in package.json to run the new Deno test suite
- covers core management API flows including authentication, bucket operations, object operations, named policies, credentials, and server status/config
- verifies S3 SDK interoperability including bucket and object operations plus credential rejection cases
## 2026-03-15 - 1.4.2 - fix(license)
add missing license file

View File

@@ -1,6 +1,6 @@
{
"name": "@lossless.zone/objectstorage",
"version": "1.4.2",
"version": "1.5.0",
"exports": "./mod.ts",
"nodeModulesDir": "auto",
"tasks": {

22
deno.lock generated
View File

@@ -1,6 +1,10 @@
{
"version": "5",
"specifiers": {
"jsr:@std/assert@*": "1.0.19",
"jsr:@std/assert@^1.0.17": "1.0.19",
"jsr:@std/internal@^1.0.12": "1.0.12",
"jsr:@std/testing@*": "1.0.17",
"npm:@api.global/typedrequest-interfaces@^3.0.19": "3.0.19",
"npm:@api.global/typedrequest@^3.2.6": "3.3.0",
"npm:@api.global/typedserver@^8.3.1": "8.4.2_@push.rocks+smartserve@2.0.1",
@@ -15,6 +19,24 @@
"npm:@push.rocks/smartjwt@^2.2.1": "2.2.1",
"npm:@push.rocks/smartstorage@^6.0.1": "6.0.1"
},
"jsr": {
"@std/assert@1.0.19": {
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/internal@1.0.12": {
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
},
"@std/testing@1.0.17": {
"integrity": "87bdc2700fa98249d48a17cd72413352d3d3680dcfbdb64947fd0982d6bbf681",
"dependencies": [
"jsr:@std/assert@^1.0.17",
"jsr:@std/internal"
]
}
},
"npm": {
"@api.global/typedrequest-interfaces@2.0.2": {
"integrity": "sha512-D+mkr4IiUZ/eUgrdp5jXjBKOW/iuMcl0z2ZLQsLLypKX/psFGD3viZJ58FNRa+/1OSM38JS5wFyoWl8oPEFLrw==",

View File

@@ -1,10 +1,11 @@
{
"name": "@lossless.zone/objectstorage",
"version": "1.4.2",
"version": "1.5.0",
"description": "object storage server with management UI powered by smartstorage",
"main": "mod.ts",
"type": "module",
"scripts": {
"test": "deno task test",
"watch": "tswatch",
"build": "tsbundle",
"bundle": "tsbundle",

View File

@@ -0,0 +1,49 @@
import { ObjectStorageContainer } from '../../ts/index.ts';
import type { IObjectStorageConfig } from '../../ts/types.ts';
import type * as interfaces from '../../ts_interfaces/index.ts';
import { TypedRequest } from '@api.global/typedrequest';
import type { IReq_AdminLoginWithUsernameAndPassword } from '../../ts_interfaces/requests/admin.ts';
export const TEST_ADMIN_PASSWORD = 'testpassword';
export const TEST_ACCESS_KEY = 'testkey';
export const TEST_SECRET_KEY = 'testsecret';
export function getTestPorts(index: number): { objstPort: number; uiPort: number } {
return {
objstPort: 19000 + index * 10,
uiPort: 19001 + index * 10,
};
}
export function createTestContainer(
index: number,
extraConfig?: Partial<IObjectStorageConfig>,
): ObjectStorageContainer {
const ports = getTestPorts(index);
return new ObjectStorageContainer({
objstPort: ports.objstPort,
uiPort: ports.uiPort,
storageDirectory: `.nogit/testdata-${index}`,
accessCredentials: [{ accessKeyId: TEST_ACCESS_KEY, secretAccessKey: TEST_SECRET_KEY }],
adminPassword: TEST_ADMIN_PASSWORD,
region: 'us-east-1',
...extraConfig,
});
}
export async function loginAndGetIdentity(
uiPort: number,
): Promise<interfaces.data.IIdentity> {
const req = new TypedRequest<IReq_AdminLoginWithUsernameAndPassword>(
`http://localhost:${uiPort}/typedrequest`,
'adminLoginWithUsernameAndPassword',
);
const response = await req.fire({
username: 'admin',
password: TEST_ADMIN_PASSWORD,
});
if (!response.identity) {
throw new Error('Login failed: no identity returned');
}
return response.identity;
}

103
test/test.auth.test.ts Normal file
View File

@@ -0,0 +1,103 @@
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { afterAll, beforeAll, describe, it } from 'jsr:@std/testing/bdd';
import { TypedRequest } from '@api.global/typedrequest';
import { createTestContainer, getTestPorts, loginAndGetIdentity, TEST_ADMIN_PASSWORD } from './helpers/server.helper.ts';
import { ObjectStorageContainer } from '../ts/index.ts';
import type * as interfaces from '../ts_interfaces/index.ts';
import type { IReq_AdminLoginWithUsernameAndPassword } from '../ts_interfaces/requests/admin.ts';
import type { IReq_VerifyIdentity } from '../ts_interfaces/requests/admin.ts';
import type { IReq_AdminLogout } from '../ts_interfaces/requests/admin.ts';
import type { IReq_GetServerStatus } from '../ts_interfaces/requests/status.ts';
const PORT_INDEX = 1;
const ports = getTestPorts(PORT_INDEX);
const url = `http://localhost:${ports.uiPort}/typedrequest`;
describe('Authentication', { sanitizeResources: false, sanitizeOps: false }, () => {
let container: ObjectStorageContainer;
let identity: interfaces.data.IIdentity;
beforeAll(async () => {
container = createTestContainer(PORT_INDEX);
await container.start();
});
afterAll(async () => {
await container.stop();
});
it('should login with valid credentials', async () => {
identity = await loginAndGetIdentity(ports.uiPort);
assertExists(identity.jwt);
assertEquals(identity.userId, 'admin');
assertEquals(identity.username, 'admin');
assertEquals(identity.role, 'admin');
assertEquals(identity.expiresAt > Date.now(), true);
});
it('should reject login with wrong password', async () => {
const req = new TypedRequest<IReq_AdminLoginWithUsernameAndPassword>(
url,
'adminLoginWithUsernameAndPassword',
);
let threw = false;
try {
await req.fire({ username: 'admin', password: 'wrongpassword' });
} catch {
threw = true;
}
assertEquals(threw, true);
});
it('should reject login with wrong username', async () => {
const req = new TypedRequest<IReq_AdminLoginWithUsernameAndPassword>(
url,
'adminLoginWithUsernameAndPassword',
);
let threw = false;
try {
await req.fire({ username: 'notadmin', password: TEST_ADMIN_PASSWORD });
} catch {
threw = true;
}
assertEquals(threw, true);
});
it('should verify a valid identity', async () => {
const req = new TypedRequest<IReq_VerifyIdentity>(url, 'verifyIdentity');
const response = await req.fire({ identity });
assertEquals(response.valid, true);
assertExists(response.identity);
assertEquals(response.identity!.userId, 'admin');
});
it('should reject verification with tampered JWT', async () => {
const req = new TypedRequest<IReq_VerifyIdentity>(url, 'verifyIdentity');
const tamperedIdentity = { ...identity, jwt: identity.jwt + 'tampered' };
const response = await req.fire({ identity: tamperedIdentity });
assertEquals(response.valid, false);
});
it('should reject verification with missing identity', async () => {
const req = new TypedRequest<IReq_VerifyIdentity>(url, 'verifyIdentity');
const response = await req.fire({ identity: null as any });
assertEquals(response.valid, false);
});
it('should logout successfully', async () => {
const req = new TypedRequest<IReq_AdminLogout>(url, 'adminLogout');
const response = await req.fire({ identity });
assertEquals(response.ok, true);
});
it('should reject protected endpoint without identity', async () => {
const req = new TypedRequest<IReq_GetServerStatus>(url, 'getServerStatus');
let threw = false;
try {
await req.fire({ identity: null as any });
} catch {
threw = true;
}
assertEquals(threw, true);
});
});

133
test/test.buckets.test.ts Normal file
View File

@@ -0,0 +1,133 @@
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { afterAll, beforeAll, describe, it } from 'jsr:@std/testing/bdd';
import { TypedRequest } from '@api.global/typedrequest';
import { createTestContainer, getTestPorts, loginAndGetIdentity } from './helpers/server.helper.ts';
import { ObjectStorageContainer } from '../ts/index.ts';
import type * as interfaces from '../ts_interfaces/index.ts';
import type {
IReq_ListBuckets,
IReq_CreateBucket,
IReq_DeleteBucket,
IReq_GetBucketPolicy,
IReq_PutBucketPolicy,
IReq_DeleteBucketPolicy,
} from '../ts_interfaces/requests/buckets.ts';
const PORT_INDEX = 2;
const ports = getTestPorts(PORT_INDEX);
const url = `http://localhost:${ports.uiPort}/typedrequest`;
describe('Bucket management', { sanitizeResources: false, sanitizeOps: false }, () => {
let container: ObjectStorageContainer;
let identity: interfaces.data.IIdentity;
beforeAll(async () => {
container = createTestContainer(PORT_INDEX);
await container.start();
identity = await loginAndGetIdentity(ports.uiPort);
});
afterAll(async () => {
await container.stop();
});
it('should list buckets (initially empty)', async () => {
const req = new TypedRequest<IReq_ListBuckets>(url, 'listBuckets');
const response = await req.fire({ identity });
assertEquals(response.buckets.length, 0);
});
it('should create a bucket', async () => {
const req = new TypedRequest<IReq_CreateBucket>(url, 'createBucket');
const response = await req.fire({ identity, bucketName: 'test-bucket-1' });
assertEquals(response.ok, true);
});
it('should create a second bucket', async () => {
const req = new TypedRequest<IReq_CreateBucket>(url, 'createBucket');
const response = await req.fire({ identity, bucketName: 'test-bucket-2' });
assertEquals(response.ok, true);
});
it('should list both buckets', async () => {
const req = new TypedRequest<IReq_ListBuckets>(url, 'listBuckets');
const response = await req.fire({ identity });
assertEquals(response.buckets.length, 2);
const names = response.buckets.map((b) => b.name).sort();
assertEquals(names, ['test-bucket-1', 'test-bucket-2']);
});
it('should have valid bucket metadata', async () => {
const req = new TypedRequest<IReq_ListBuckets>(url, 'listBuckets');
const response = await req.fire({ identity });
for (const bucket of response.buckets) {
assertEquals(bucket.objectCount, 0);
assertEquals(bucket.totalSizeBytes, 0);
assertEquals(bucket.creationDate > 0, true);
}
});
it('should delete a bucket', async () => {
const req = new TypedRequest<IReq_DeleteBucket>(url, 'deleteBucket');
const response = await req.fire({ identity, bucketName: 'test-bucket-2' });
assertEquals(response.ok, true);
});
it('should list one remaining bucket', async () => {
const req = new TypedRequest<IReq_ListBuckets>(url, 'listBuckets');
const response = await req.fire({ identity });
assertEquals(response.buckets.length, 1);
assertEquals(response.buckets[0].name, 'test-bucket-1');
});
it('should get bucket policy (initially null)', async () => {
const req = new TypedRequest<IReq_GetBucketPolicy>(url, 'getBucketPolicy');
const response = await req.fire({ identity, bucketName: 'test-bucket-1' });
assertEquals(response.policy, null);
});
it('should put a bucket policy', async () => {
const policy = JSON.stringify({
Version: '2012-10-17',
Statement: [
{
Sid: 'PublicRead',
Effect: 'Allow',
Principal: '*',
Action: 's3:GetObject',
Resource: 'arn:aws:s3:::test-bucket-1/*',
},
],
});
const req = new TypedRequest<IReq_PutBucketPolicy>(url, 'putBucketPolicy');
const response = await req.fire({ identity, bucketName: 'test-bucket-1', policy });
assertEquals(response.ok, true);
});
it('should get the bucket policy back', async () => {
const req = new TypedRequest<IReq_GetBucketPolicy>(url, 'getBucketPolicy');
const response = await req.fire({ identity, bucketName: 'test-bucket-1' });
assertExists(response.policy);
const parsed = JSON.parse(response.policy!);
assertExists(parsed.Statement);
assertEquals(parsed.Statement[0].Sid, 'PublicRead');
});
it('should delete bucket policy', async () => {
const req = new TypedRequest<IReq_DeleteBucketPolicy>(url, 'deleteBucketPolicy');
const response = await req.fire({ identity, bucketName: 'test-bucket-1' });
assertEquals(response.ok, true);
});
it('should confirm policy is deleted', async () => {
const req = new TypedRequest<IReq_GetBucketPolicy>(url, 'getBucketPolicy');
const response = await req.fire({ identity, bucketName: 'test-bucket-1' });
assertEquals(response.policy, null);
});
it('cleanup: delete remaining bucket', async () => {
const req = new TypedRequest<IReq_DeleteBucket>(url, 'deleteBucket');
const response = await req.fire({ identity, bucketName: 'test-bucket-1' });
assertEquals(response.ok, true);
});
});

View File

@@ -0,0 +1,56 @@
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { afterAll, beforeAll, describe, it } from 'jsr:@std/testing/bdd';
import { createTestContainer, getTestPorts } from './helpers/server.helper.ts';
import { ObjectStorageContainer } from '../ts/index.ts';
import { defaultConfig } from '../ts/types.ts';
const PORT_INDEX = 0;
const ports = getTestPorts(PORT_INDEX);
describe('ObjectStorageContainer lifecycle', { sanitizeResources: false, sanitizeOps: false }, () => {
let container: ObjectStorageContainer;
it('should create container with default config values', () => {
const c = new ObjectStorageContainer();
assertEquals(c.config.objstPort, defaultConfig.objstPort);
assertEquals(c.config.uiPort, defaultConfig.uiPort);
assertEquals(c.config.region, defaultConfig.region);
assertEquals(c.config.adminPassword, defaultConfig.adminPassword);
});
it('should create container with custom config overrides', () => {
container = createTestContainer(PORT_INDEX);
assertEquals(container.config.objstPort, ports.objstPort);
assertEquals(container.config.uiPort, ports.uiPort);
assertEquals(container.config.adminPassword, 'testpassword');
assertEquals(container.config.accessCredentials[0].accessKeyId, 'testkey');
});
it('should start successfully', async () => {
await container.start();
assertEquals(container.startedAt > 0, true);
});
it('should have s3Client initialized after start', () => {
assertExists(container.s3Client);
});
it('should have smartstorageInstance initialized after start', () => {
assertExists(container.smartstorageInstance);
});
it('should have policyManager with empty policies', () => {
assertExists(container.policyManager);
const policies = container.policyManager.listPolicies();
assertEquals(policies.length, 0);
});
it('should have opsServer started', () => {
assertExists(container.opsServer);
assertExists(container.opsServer.server);
});
it('should stop cleanly', async () => {
await container.stop();
});
});

View File

@@ -0,0 +1,93 @@
import { assertEquals } from 'jsr:@std/assert';
import { afterAll, beforeAll, describe, it } from 'jsr:@std/testing/bdd';
import { TypedRequest } from '@api.global/typedrequest';
import {
createTestContainer,
getTestPorts,
loginAndGetIdentity,
TEST_ACCESS_KEY,
} from './helpers/server.helper.ts';
import { ObjectStorageContainer } from '../ts/index.ts';
import type * as interfaces from '../ts_interfaces/index.ts';
import type {
IReq_GetCredentials,
IReq_AddCredential,
IReq_RemoveCredential,
} from '../ts_interfaces/requests/credentials.ts';
const PORT_INDEX = 6;
const ports = getTestPorts(PORT_INDEX);
const url = `http://localhost:${ports.uiPort}/typedrequest`;
describe('Credential management', { sanitizeResources: false, sanitizeOps: false }, () => {
let container: ObjectStorageContainer;
let identity: interfaces.data.IIdentity;
beforeAll(async () => {
container = createTestContainer(PORT_INDEX);
await container.start();
identity = await loginAndGetIdentity(ports.uiPort);
});
afterAll(async () => {
await container.stop();
});
it('should get credentials with masked secrets', async () => {
const req = new TypedRequest<IReq_GetCredentials>(url, 'getCredentials');
const response = await req.fire({ identity });
assertEquals(response.credentials.length, 1);
assertEquals(response.credentials[0].accessKeyId, TEST_ACCESS_KEY);
// Secret should be masked: first 4 chars + ****
assertEquals(response.credentials[0].secretAccessKey.includes('****'), true);
});
it('should add a new credential', async () => {
const req = new TypedRequest<IReq_AddCredential>(url, 'addCredential');
const response = await req.fire({
identity,
accessKeyId: 'newkey',
secretAccessKey: 'newsecret',
});
assertEquals(response.ok, true);
});
it('should list credentials showing both', async () => {
const req = new TypedRequest<IReq_GetCredentials>(url, 'getCredentials');
const response = await req.fire({ identity });
assertEquals(response.credentials.length, 2);
const keys = response.credentials.map((c) => c.accessKeyId);
assertEquals(keys.includes(TEST_ACCESS_KEY), true);
assertEquals(keys.includes('newkey'), true);
});
it('should verify new credential is stored in config', () => {
const creds = container.config.accessCredentials;
const newCred = creds.find((c) => c.accessKeyId === 'newkey');
assertEquals(newCred?.secretAccessKey, 'newsecret');
});
it('should remove a credential', async () => {
const req = new TypedRequest<IReq_RemoveCredential>(url, 'removeCredential');
const response = await req.fire({ identity, accessKeyId: 'newkey' });
assertEquals(response.ok, true);
});
it('should list credentials showing one remaining', async () => {
const req = new TypedRequest<IReq_GetCredentials>(url, 'getCredentials');
const response = await req.fire({ identity });
assertEquals(response.credentials.length, 1);
assertEquals(response.credentials[0].accessKeyId, TEST_ACCESS_KEY);
});
it('should reject removing the last credential', async () => {
const req = new TypedRequest<IReq_RemoveCredential>(url, 'removeCredential');
let threw = false;
try {
await req.fire({ identity, accessKeyId: TEST_ACCESS_KEY });
} catch {
threw = true;
}
assertEquals(threw, true);
});
});

215
test/test.objects.test.ts Normal file
View File

@@ -0,0 +1,215 @@
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { afterAll, beforeAll, describe, it } from 'jsr:@std/testing/bdd';
import { TypedRequest } from '@api.global/typedrequest';
import { createTestContainer, getTestPorts, loginAndGetIdentity } from './helpers/server.helper.ts';
import { ObjectStorageContainer } from '../ts/index.ts';
import type * as interfaces from '../ts_interfaces/index.ts';
import type { IReq_CreateBucket, IReq_DeleteBucket } from '../ts_interfaces/requests/buckets.ts';
import type {
IReq_ListObjects,
IReq_PutObject,
IReq_GetObject,
IReq_DeleteObject,
IReq_DeletePrefix,
IReq_GetObjectUrl,
IReq_MoveObject,
IReq_MovePrefix,
} from '../ts_interfaces/requests/objects.ts';
const PORT_INDEX = 3;
const ports = getTestPorts(PORT_INDEX);
const url = `http://localhost:${ports.uiPort}/typedrequest`;
const BUCKET = 'obj-test-bucket';
function toBase64(text: string): string {
return btoa(text);
}
function fromBase64(b64: string): string {
return atob(b64);
}
describe('Object operations', { sanitizeResources: false, sanitizeOps: false }, () => {
let container: ObjectStorageContainer;
let identity: interfaces.data.IIdentity;
beforeAll(async () => {
container = createTestContainer(PORT_INDEX);
await container.start();
identity = await loginAndGetIdentity(ports.uiPort);
// Create test bucket
const req = new TypedRequest<IReq_CreateBucket>(url, 'createBucket');
await req.fire({ identity, bucketName: BUCKET });
});
afterAll(async () => {
// Delete test bucket
try {
const req = new TypedRequest<IReq_DeleteBucket>(url, 'deleteBucket');
await req.fire({ identity, bucketName: BUCKET });
} catch { /* bucket may already be empty/deleted */ }
await container.stop();
});
it('should list objects (initially empty)', async () => {
const req = new TypedRequest<IReq_ListObjects>(url, 'listObjects');
const response = await req.fire({ identity, bucketName: BUCKET });
assertEquals(response.result.objects.length, 0);
assertEquals(response.result.commonPrefixes.length, 0);
});
it('should put a text object', async () => {
const req = new TypedRequest<IReq_PutObject>(url, 'putObject');
const response = await req.fire({
identity,
bucketName: BUCKET,
key: 'hello.txt',
base64Content: toBase64('Hello World'),
contentType: 'text/plain',
});
assertEquals(response.ok, true);
});
it('should put nested objects', async () => {
const req = new TypedRequest<IReq_PutObject>(url, 'putObject');
await req.fire({
identity,
bucketName: BUCKET,
key: 'folder/nested.txt',
base64Content: toBase64('Nested content'),
contentType: 'text/plain',
});
await req.fire({
identity,
bucketName: BUCKET,
key: 'folder/a.txt',
base64Content: toBase64('File A'),
contentType: 'text/plain',
});
await req.fire({
identity,
bucketName: BUCKET,
key: 'folder/b.txt',
base64Content: toBase64('File B'),
contentType: 'text/plain',
});
await req.fire({
identity,
bucketName: BUCKET,
key: 'folder/sub/c.txt',
base64Content: toBase64('File C'),
contentType: 'text/plain',
});
});
it('should list objects at root with delimiter', async () => {
const req = new TypedRequest<IReq_ListObjects>(url, 'listObjects');
const response = await req.fire({ identity, bucketName: BUCKET, delimiter: '/' });
// Root should have hello.txt as direct object
const rootKeys = response.result.objects.map((o) => o.key);
assertEquals(rootKeys.includes('hello.txt'), true);
// folder/ should appear as a common prefix
assertEquals(response.result.commonPrefixes.includes('folder/'), true);
});
it('should list objects with prefix', async () => {
const req = new TypedRequest<IReq_ListObjects>(url, 'listObjects');
const response = await req.fire({
identity,
bucketName: BUCKET,
prefix: 'folder/',
delimiter: '/',
});
const keys = response.result.objects.map((o) => o.key);
assertEquals(keys.includes('folder/nested.txt'), true);
assertEquals(keys.includes('folder/a.txt'), true);
assertEquals(keys.includes('folder/b.txt'), true);
// sub/ should be a common prefix
assertEquals(response.result.commonPrefixes.includes('folder/sub/'), true);
});
it('should get a text object', async () => {
const req = new TypedRequest<IReq_GetObject>(url, 'getObject');
const response = await req.fire({ identity, bucketName: BUCKET, key: 'hello.txt' });
assertEquals(fromBase64(response.content), 'Hello World');
assertEquals(response.size > 0, true);
assertExists(response.lastModified);
});
it('should get object URL', async () => {
const req = new TypedRequest<IReq_GetObjectUrl>(url, 'getObjectUrl');
const response = await req.fire({ identity, bucketName: BUCKET, key: 'hello.txt' });
assertExists(response.url);
assertEquals(response.url.includes(BUCKET), true);
assertEquals(response.url.includes('hello.txt'), true);
});
it('should move an object', async () => {
const req = new TypedRequest<IReq_MoveObject>(url, 'moveObject');
const response = await req.fire({
identity,
bucketName: BUCKET,
sourceKey: 'hello.txt',
destKey: 'moved-hello.txt',
});
assertEquals(response.success, true);
});
it('should verify moved object exists at new key', async () => {
const req = new TypedRequest<IReq_GetObject>(url, 'getObject');
const response = await req.fire({ identity, bucketName: BUCKET, key: 'moved-hello.txt' });
assertEquals(fromBase64(response.content), 'Hello World');
});
it('should verify source key no longer exists after move', async () => {
const req = new TypedRequest<IReq_ListObjects>(url, 'listObjects');
const response = await req.fire({ identity, bucketName: BUCKET, delimiter: '/' });
const rootKeys = response.result.objects.map((o) => o.key);
assertEquals(rootKeys.includes('hello.txt'), false);
assertEquals(rootKeys.includes('moved-hello.txt'), true);
});
it('should move a prefix', async () => {
const req = new TypedRequest<IReq_MovePrefix>(url, 'movePrefix');
const response = await req.fire({
identity,
bucketName: BUCKET,
sourcePrefix: 'folder/',
destPrefix: 'renamed/',
});
assertEquals(response.success, true);
assertEquals((response.movedCount ?? 0) >= 4, true);
});
it('should verify moved prefix contents', async () => {
const req = new TypedRequest<IReq_ListObjects>(url, 'listObjects');
const response = await req.fire({ identity, bucketName: BUCKET, prefix: 'renamed/' });
assertEquals(response.result.objects.length >= 1, true);
});
it('should verify old prefix is empty', async () => {
const req = new TypedRequest<IReq_ListObjects>(url, 'listObjects');
const response = await req.fire({ identity, bucketName: BUCKET, prefix: 'folder/' });
assertEquals(response.result.objects.length, 0);
assertEquals(response.result.commonPrefixes.length, 0);
});
it('should delete a single object', async () => {
const req = new TypedRequest<IReq_DeleteObject>(url, 'deleteObject');
const response = await req.fire({ identity, bucketName: BUCKET, key: 'moved-hello.txt' });
assertEquals(response.ok, true);
});
it('should delete a prefix', async () => {
const req = new TypedRequest<IReq_DeletePrefix>(url, 'deletePrefix');
const response = await req.fire({ identity, bucketName: BUCKET, prefix: 'renamed/' });
assertEquals(response.ok, true);
});
it('should verify bucket is empty after cleanup', async () => {
const req = new TypedRequest<IReq_ListObjects>(url, 'listObjects');
const response = await req.fire({ identity, bucketName: BUCKET });
assertEquals(response.result.objects.length, 0);
});
});

258
test/test.policies.test.ts Normal file
View File

@@ -0,0 +1,258 @@
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { afterAll, beforeAll, describe, it } from 'jsr:@std/testing/bdd';
import { TypedRequest } from '@api.global/typedrequest';
import { createTestContainer, getTestPorts, loginAndGetIdentity } from './helpers/server.helper.ts';
import { ObjectStorageContainer } from '../ts/index.ts';
import type * as interfaces from '../ts_interfaces/index.ts';
import type { IReq_CreateBucket, IReq_DeleteBucket, IReq_GetBucketPolicy } from '../ts_interfaces/requests/buckets.ts';
import type {
IReq_ListNamedPolicies,
IReq_CreateNamedPolicy,
IReq_UpdateNamedPolicy,
IReq_DeleteNamedPolicy,
IReq_GetBucketNamedPolicies,
IReq_AttachPolicyToBucket,
IReq_DetachPolicyFromBucket,
IReq_GetPolicyBuckets,
IReq_SetPolicyBuckets,
} from '../ts_interfaces/requests/policies.ts';
const PORT_INDEX = 5;
const ports = getTestPorts(PORT_INDEX);
const url = `http://localhost:${ports.uiPort}/typedrequest`;
describe('Named policy management', { sanitizeResources: false, sanitizeOps: false }, () => {
let container: ObjectStorageContainer;
let identity: interfaces.data.IIdentity;
let policy1Id: string;
let policy2Id: string;
beforeAll(async () => {
container = createTestContainer(PORT_INDEX);
await container.start();
identity = await loginAndGetIdentity(ports.uiPort);
// Create test buckets
const createBucket = new TypedRequest<IReq_CreateBucket>(url, 'createBucket');
await createBucket.fire({ identity, bucketName: 'pol-bucket-1' });
await createBucket.fire({ identity, bucketName: 'pol-bucket-2' });
});
afterAll(async () => {
// Cleanup
try {
const del = new TypedRequest<IReq_DeleteBucket>(url, 'deleteBucket');
await del.fire({ identity, bucketName: 'pol-bucket-1' });
} catch { /* may already be deleted */ }
try {
const del = new TypedRequest<IReq_DeleteBucket>(url, 'deleteBucket');
await del.fire({ identity, bucketName: 'pol-bucket-2' });
} catch { /* may already be deleted */ }
await container.stop();
});
it('should list policies (initially empty)', async () => {
const req = new TypedRequest<IReq_ListNamedPolicies>(url, 'listNamedPolicies');
const response = await req.fire({ identity });
assertEquals(response.policies.length, 0);
});
it('should create a named policy', async () => {
const req = new TypedRequest<IReq_CreateNamedPolicy>(url, 'createNamedPolicy');
const response = await req.fire({
identity,
name: 'Public Read',
description: 'Allows public read access',
statements: [
{
Sid: 'PublicRead',
Effect: 'Allow',
Principal: '*',
Action: 's3:GetObject',
Resource: 'arn:aws:s3:::${bucket}/*',
},
],
});
assertExists(response.policy.id);
assertEquals(response.policy.name, 'Public Read');
assertEquals(response.policy.statements.length, 1);
assertEquals(response.policy.createdAt > 0, true);
policy1Id = response.policy.id;
});
it('should create a second policy', async () => {
const req = new TypedRequest<IReq_CreateNamedPolicy>(url, 'createNamedPolicy');
const response = await req.fire({
identity,
name: 'Deny Delete',
description: 'Denies delete actions',
statements: [
{
Sid: 'DenyDelete',
Effect: 'Deny',
Principal: '*',
Action: 's3:DeleteObject',
Resource: 'arn:aws:s3:::${bucket}/*',
},
],
});
policy2Id = response.policy.id;
assertExists(policy2Id);
});
it('should list both policies', async () => {
const req = new TypedRequest<IReq_ListNamedPolicies>(url, 'listNamedPolicies');
const response = await req.fire({ identity });
assertEquals(response.policies.length, 2);
});
it('should get bucket named policies (none attached)', async () => {
const req = new TypedRequest<IReq_GetBucketNamedPolicies>(url, 'getBucketNamedPolicies');
const response = await req.fire({ identity, bucketName: 'pol-bucket-1' });
assertEquals(response.attachedPolicies.length, 0);
assertEquals(response.availablePolicies.length, 2);
});
it('should attach policy to bucket', async () => {
const req = new TypedRequest<IReq_AttachPolicyToBucket>(url, 'attachPolicyToBucket');
const response = await req.fire({ identity, policyId: policy1Id, bucketName: 'pol-bucket-1' });
assertEquals(response.ok, true);
});
it('should verify bucket policy was applied with placeholder replaced', async () => {
const req = new TypedRequest<IReq_GetBucketPolicy>(url, 'getBucketPolicy');
const response = await req.fire({ identity, bucketName: 'pol-bucket-1' });
assertExists(response.policy);
const parsed = JSON.parse(response.policy!);
const resource = parsed.Statement[0].Resource;
// Resource may be a string or array depending on the S3 engine
const resourceStr = Array.isArray(resource) ? resource.join(' ') : resource;
// ${bucket} should be replaced with actual bucket name
assertEquals(resourceStr.includes('pol-bucket-1'), true);
assertEquals(resourceStr.includes('${bucket}'), false);
});
it('should get bucket named policies (one attached)', async () => {
const req = new TypedRequest<IReq_GetBucketNamedPolicies>(url, 'getBucketNamedPolicies');
const response = await req.fire({ identity, bucketName: 'pol-bucket-1' });
assertEquals(response.attachedPolicies.length, 1);
assertEquals(response.availablePolicies.length, 1);
assertEquals(response.attachedPolicies[0].id, policy1Id);
});
it('should attach second policy to same bucket', async () => {
const req = new TypedRequest<IReq_AttachPolicyToBucket>(url, 'attachPolicyToBucket');
const response = await req.fire({ identity, policyId: policy2Id, bucketName: 'pol-bucket-1' });
assertEquals(response.ok, true);
});
it('should verify merged policy has statements from both', async () => {
const req = new TypedRequest<IReq_GetBucketPolicy>(url, 'getBucketPolicy');
const response = await req.fire({ identity, bucketName: 'pol-bucket-1' });
assertExists(response.policy);
const parsed = JSON.parse(response.policy!);
assertEquals(parsed.Statement.length >= 2, true);
const sids = parsed.Statement.map((s: any) => s.Sid);
assertEquals(sids.includes('PublicRead'), true);
assertEquals(sids.includes('DenyDelete'), true);
});
it('should get policy buckets', async () => {
const req = new TypedRequest<IReq_GetPolicyBuckets>(url, 'getPolicyBuckets');
const response = await req.fire({ identity, policyId: policy1Id });
assertEquals(response.attachedBuckets.includes('pol-bucket-1'), true);
assertEquals(response.availableBuckets.includes('pol-bucket-2'), true);
});
it('should set policy buckets (batch)', async () => {
const req = new TypedRequest<IReq_SetPolicyBuckets>(url, 'setPolicyBuckets');
const response = await req.fire({
identity,
policyId: policy1Id,
bucketNames: ['pol-bucket-1', 'pol-bucket-2'],
});
assertEquals(response.ok, true);
});
it('should verify policy applied to second bucket', async () => {
const req = new TypedRequest<IReq_GetBucketPolicy>(url, 'getBucketPolicy');
const response = await req.fire({ identity, bucketName: 'pol-bucket-2' });
assertExists(response.policy);
const parsed = JSON.parse(response.policy!);
const resource = parsed.Statement[0].Resource;
const resourceStr = Array.isArray(resource) ? resource.join(' ') : resource;
assertEquals(resourceStr.includes('pol-bucket-2'), true);
});
it('should update a policy', async () => {
const req = new TypedRequest<IReq_UpdateNamedPolicy>(url, 'updateNamedPolicy');
const response = await req.fire({
identity,
policyId: policy1Id,
name: 'Public Read Updated',
description: 'Updated policy',
statements: [
{
Sid: 'PublicReadUpdated',
Effect: 'Allow',
Principal: '*',
Action: ['s3:GetObject', 's3:ListBucket'],
Resource: 'arn:aws:s3:::${bucket}/*',
},
],
});
assertEquals(response.policy.name, 'Public Read Updated');
assertEquals(response.policy.updatedAt >= response.policy.createdAt, true);
});
it('should verify updated policy cascaded to attached buckets', async () => {
const req = new TypedRequest<IReq_GetBucketPolicy>(url, 'getBucketPolicy');
const response = await req.fire({ identity, bucketName: 'pol-bucket-1' });
assertExists(response.policy);
const parsed = JSON.parse(response.policy!);
const sids = parsed.Statement.map((s: any) => s.Sid);
assertEquals(sids.includes('PublicReadUpdated'), true);
});
it('should detach policy from bucket', async () => {
const req = new TypedRequest<IReq_DetachPolicyFromBucket>(url, 'detachPolicyFromBucket');
const response = await req.fire({ identity, policyId: policy2Id, bucketName: 'pol-bucket-1' });
assertEquals(response.ok, true);
});
it('should verify bucket policy updated after detach', async () => {
const req = new TypedRequest<IReq_GetBucketPolicy>(url, 'getBucketPolicy');
const response = await req.fire({ identity, bucketName: 'pol-bucket-1' });
assertExists(response.policy);
const parsed = JSON.parse(response.policy!);
const sids = parsed.Statement.map((s: any) => s.Sid);
assertEquals(sids.includes('DenyDelete'), false);
assertEquals(sids.includes('PublicReadUpdated'), true);
});
it('should delete a named policy', async () => {
const req = new TypedRequest<IReq_DeleteNamedPolicy>(url, 'deleteNamedPolicy');
const response = await req.fire({ identity, policyId: policy2Id });
assertEquals(response.ok, true);
});
it('should handle bucket deletion cleaning up attachments', async () => {
// Delete pol-bucket-1 which has policy1 attached
const delBucket = new TypedRequest<IReq_DeleteBucket>(url, 'deleteBucket');
await delBucket.fire({ identity, bucketName: 'pol-bucket-1' });
// Verify policy1 no longer lists pol-bucket-1
const req = new TypedRequest<IReq_GetPolicyBuckets>(url, 'getPolicyBuckets');
const response = await req.fire({ identity, policyId: policy1Id });
assertEquals(response.attachedBuckets.includes('pol-bucket-1'), false);
});
it('cleanup: delete remaining policy and bucket', async () => {
const delPolicy = new TypedRequest<IReq_DeleteNamedPolicy>(url, 'deleteNamedPolicy');
await delPolicy.fire({ identity, policyId: policy1Id });
const listPolicies = new TypedRequest<IReq_ListNamedPolicies>(url, 'listNamedPolicies');
const response = await listPolicies.fire({ identity });
assertEquals(response.policies.length, 0);
});
});

132
test/test.s3-compat.test.ts Normal file
View File

@@ -0,0 +1,132 @@
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { afterAll, beforeAll, describe, it } from 'jsr:@std/testing/bdd';
import {
S3Client,
ListBucketsCommand,
CreateBucketCommand,
DeleteBucketCommand,
PutObjectCommand,
GetObjectCommand,
ListObjectsV2Command,
CopyObjectCommand,
DeleteObjectCommand,
} from '@aws-sdk/client-s3';
import { createTestContainer, getTestPorts, TEST_ACCESS_KEY, TEST_SECRET_KEY } from './helpers/server.helper.ts';
import { ObjectStorageContainer } from '../ts/index.ts';
const PORT_INDEX = 4;
const ports = getTestPorts(PORT_INDEX);
const BUCKET = 's3-test-bucket';
describe('S3 SDK compatibility', { sanitizeResources: false, sanitizeOps: false }, () => {
let container: ObjectStorageContainer;
let s3: S3Client;
beforeAll(async () => {
container = createTestContainer(PORT_INDEX);
await container.start();
s3 = new S3Client({
endpoint: `http://localhost:${ports.objstPort}`,
region: 'us-east-1',
credentials: {
accessKeyId: TEST_ACCESS_KEY,
secretAccessKey: TEST_SECRET_KEY,
},
forcePathStyle: true,
});
});
afterAll(async () => {
s3.destroy();
await container.stop();
});
it('should list buckets (empty)', async () => {
const response = await s3.send(new ListBucketsCommand({}));
assertEquals((response.Buckets || []).length, 0);
});
it('should create bucket via S3', async () => {
await s3.send(new CreateBucketCommand({ Bucket: BUCKET }));
const response = await s3.send(new ListBucketsCommand({}));
const names = (response.Buckets || []).map((b) => b.Name);
assertEquals(names.includes(BUCKET), true);
});
it('should put object via S3', async () => {
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: 'test.txt',
Body: new TextEncoder().encode('Hello from S3 SDK'),
ContentType: 'text/plain',
}),
);
});
it('should get object via S3', async () => {
const response = await s3.send(
new GetObjectCommand({ Bucket: BUCKET, Key: 'test.txt' }),
);
const body = await response.Body!.transformToString();
assertEquals(body, 'Hello from S3 SDK');
});
it('should list objects via S3', async () => {
const response = await s3.send(
new ListObjectsV2Command({ Bucket: BUCKET }),
);
const keys = (response.Contents || []).map((o) => o.Key);
assertEquals(keys.includes('test.txt'), true);
});
it('should copy object via S3', async () => {
await s3.send(
new CopyObjectCommand({
Bucket: BUCKET,
CopySource: `${BUCKET}/test.txt`,
Key: 'copy.txt',
}),
);
const response = await s3.send(
new GetObjectCommand({ Bucket: BUCKET, Key: 'copy.txt' }),
);
const body = await response.Body!.transformToString();
assertEquals(body, 'Hello from S3 SDK');
});
it('should delete object via S3', async () => {
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'test.txt' }));
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: 'copy.txt' }));
const response = await s3.send(
new ListObjectsV2Command({ Bucket: BUCKET }),
);
assertEquals((response.Contents || []).length, 0);
});
it('should reject requests with wrong credentials', async () => {
const badS3 = new S3Client({
endpoint: `http://localhost:${ports.objstPort}`,
region: 'us-east-1',
credentials: {
accessKeyId: 'wrongkey',
secretAccessKey: 'wrongsecret',
},
forcePathStyle: true,
});
let threw = false;
try {
await badS3.send(new ListBucketsCommand({}));
} catch {
threw = true;
}
badS3.destroy();
assertEquals(threw, true);
});
it('cleanup: delete bucket', async () => {
await s3.send(new DeleteBucketCommand({ Bucket: BUCKET }));
const response = await s3.send(new ListBucketsCommand({}));
assertEquals((response.Buckets || []).length, 0);
});
});

View File

@@ -0,0 +1,112 @@
import { assertEquals, assertExists } from 'jsr:@std/assert';
import { afterAll, beforeAll, describe, it } from 'jsr:@std/testing/bdd';
import { TypedRequest } from '@api.global/typedrequest';
import { createTestContainer, getTestPorts, loginAndGetIdentity } from './helpers/server.helper.ts';
import { ObjectStorageContainer } from '../ts/index.ts';
import type * as interfaces from '../ts_interfaces/index.ts';
import type { IReq_CreateBucket, IReq_DeleteBucket } from '../ts_interfaces/requests/buckets.ts';
import type { IReq_PutObject, IReq_DeletePrefix } from '../ts_interfaces/requests/objects.ts';
import type { IReq_GetServerStatus } from '../ts_interfaces/requests/status.ts';
import type { IReq_GetServerConfig } from '../ts_interfaces/requests/config.ts';
const PORT_INDEX = 7;
const ports = getTestPorts(PORT_INDEX);
const url = `http://localhost:${ports.uiPort}/typedrequest`;
const BUCKET = 'stats-bucket';
describe('Status and config', { sanitizeResources: false, sanitizeOps: false }, () => {
let container: ObjectStorageContainer;
let identity: interfaces.data.IIdentity;
beforeAll(async () => {
container = createTestContainer(PORT_INDEX);
await container.start();
identity = await loginAndGetIdentity(ports.uiPort);
// Create bucket with objects for stats testing
const createBucket = new TypedRequest<IReq_CreateBucket>(url, 'createBucket');
await createBucket.fire({ identity, bucketName: BUCKET });
const putObj = new TypedRequest<IReq_PutObject>(url, 'putObject');
await putObj.fire({
identity,
bucketName: BUCKET,
key: 'file1.txt',
base64Content: btoa('Content of file 1'),
contentType: 'text/plain',
});
await putObj.fire({
identity,
bucketName: BUCKET,
key: 'file2.txt',
base64Content: btoa('Content of file 2'),
contentType: 'text/plain',
});
});
afterAll(async () => {
try {
const delPrefix = new TypedRequest<IReq_DeletePrefix>(url, 'deletePrefix');
await delPrefix.fire({ identity, bucketName: BUCKET, prefix: '' });
const delBucket = new TypedRequest<IReq_DeleteBucket>(url, 'deleteBucket');
await delBucket.fire({ identity, bucketName: BUCKET });
} catch { /* cleanup best-effort */ }
await container.stop();
});
it('should get server status', async () => {
const req = new TypedRequest<IReq_GetServerStatus>(url, 'getServerStatus');
const response = await req.fire({ identity });
const status = response.status;
assertEquals(status.running, true);
assertEquals(status.objstPort, ports.objstPort);
assertEquals(status.uiPort, ports.uiPort);
assertEquals(status.bucketCount, 1);
assertEquals(status.totalObjectCount, 2);
assertEquals(status.totalStorageBytes > 0, true);
assertEquals(status.uptime >= 0, true);
assertEquals(status.startedAt > 0, true);
assertEquals(status.region, 'us-east-1');
assertEquals(status.authEnabled, true);
});
it('should get connection info from status', async () => {
const req = new TypedRequest<IReq_GetServerStatus>(url, 'getServerStatus');
const response = await req.fire({ identity });
const info = response.connectionInfo;
assertExists(info.endpoint);
assertEquals(info.port, ports.objstPort);
assertExists(info.accessKey);
assertEquals(info.region, 'us-east-1');
});
it('should get server config', async () => {
const req = new TypedRequest<IReq_GetServerConfig>(url, 'getServerConfig');
const response = await req.fire({ identity });
const config = response.config;
assertEquals(config.objstPort, ports.objstPort);
assertEquals(config.uiPort, ports.uiPort);
assertEquals(config.region, 'us-east-1');
assertEquals(config.authEnabled, true);
assertEquals(config.corsEnabled, false);
});
it('should reflect correct stats after adding objects', async () => {
// Add a third object
const putObj = new TypedRequest<IReq_PutObject>(url, 'putObject');
await putObj.fire({
identity,
bucketName: BUCKET,
key: 'file3.txt',
base64Content: btoa('Content of file 3'),
contentType: 'text/plain',
});
const req = new TypedRequest<IReq_GetServerStatus>(url, 'getServerStatus');
const response = await req.fire({ identity });
assertEquals(response.status.totalObjectCount, 3);
});
});

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@lossless.zone/objectstorage',
version: '1.4.2',
version: '1.5.0',
description: 'object storage server with management UI powered by smartstorage'
}

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@lossless.zone/objectstorage',
version: '1.4.2',
version: '1.5.0',
description: 'object storage server with management UI powered by smartstorage'
}