4 Commits

16 changed files with 1210 additions and 4 deletions

View File

@@ -1,5 +1,18 @@
# 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
- Adds a repository license file.
## 2026-03-15 - 1.4.1 - fix(readme)
refresh README with clearer feature documentation, UI screenshots, and client usage examples

View File

@@ -1,6 +1,6 @@
{
"name": "@lossless.zone/objectstorage",
"version": "1.4.1",
"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==",

19
license Normal file
View File

@@ -0,0 +1,19 @@
Copyright (c) 2014 Task Venture Capital GmbH (hello@task.vc)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,10 +1,11 @@
{
"name": "@lossless.zone/objectstorage",
"version": "1.4.1",
"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.1',
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.1',
version: '1.5.0',
description: 'object storage server with management UI powered by smartstorage'
}