diff --git a/changelog.md b/changelog.md index 7d5247a..387dadf 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/deno.lock b/deno.lock index 449fef4..82deece 100644 --- a/deno.lock +++ b/deno.lock @@ -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==", diff --git a/package.json b/package.json index 74e95ea..b665418 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "main": "mod.ts", "type": "module", "scripts": { + "test": "deno task test", "watch": "tswatch", "build": "tsbundle", "bundle": "tsbundle", diff --git a/test/helpers/server.helper.ts b/test/helpers/server.helper.ts new file mode 100644 index 0000000..f3e6204 --- /dev/null +++ b/test/helpers/server.helper.ts @@ -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, +): 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 { + const req = new TypedRequest( + `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; +} diff --git a/test/test.auth.test.ts b/test/test.auth.test.ts new file mode 100644 index 0000000..03ee2d7 --- /dev/null +++ b/test/test.auth.test.ts @@ -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( + 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( + 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(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(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(url, 'verifyIdentity'); + const response = await req.fire({ identity: null as any }); + assertEquals(response.valid, false); + }); + + it('should logout successfully', async () => { + const req = new TypedRequest(url, 'adminLogout'); + const response = await req.fire({ identity }); + assertEquals(response.ok, true); + }); + + it('should reject protected endpoint without identity', async () => { + const req = new TypedRequest(url, 'getServerStatus'); + let threw = false; + try { + await req.fire({ identity: null as any }); + } catch { + threw = true; + } + assertEquals(threw, true); + }); +}); diff --git a/test/test.buckets.test.ts b/test/test.buckets.test.ts new file mode 100644 index 0000000..e609da0 --- /dev/null +++ b/test/test.buckets.test.ts @@ -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(url, 'listBuckets'); + const response = await req.fire({ identity }); + assertEquals(response.buckets.length, 0); + }); + + it('should create a bucket', async () => { + const req = new TypedRequest(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(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(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(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(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(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(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(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(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(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(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(url, 'deleteBucket'); + const response = await req.fire({ identity, bucketName: 'test-bucket-1' }); + assertEquals(response.ok, true); + }); +}); diff --git a/test/test.container-lifecycle.test.ts b/test/test.container-lifecycle.test.ts new file mode 100644 index 0000000..edfef76 --- /dev/null +++ b/test/test.container-lifecycle.test.ts @@ -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(); + }); +}); diff --git a/test/test.credentials.test.ts b/test/test.credentials.test.ts new file mode 100644 index 0000000..14298b3 --- /dev/null +++ b/test/test.credentials.test.ts @@ -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(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(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(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(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(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(url, 'removeCredential'); + let threw = false; + try { + await req.fire({ identity, accessKeyId: TEST_ACCESS_KEY }); + } catch { + threw = true; + } + assertEquals(threw, true); + }); +}); diff --git a/test/test.objects.test.ts b/test/test.objects.test.ts new file mode 100644 index 0000000..452715e --- /dev/null +++ b/test/test.objects.test.ts @@ -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(url, 'createBucket'); + await req.fire({ identity, bucketName: BUCKET }); + }); + + afterAll(async () => { + // Delete test bucket + try { + const req = new TypedRequest(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(url, 'listObjects'); + const response = await req.fire({ identity, bucketName: BUCKET }); + assertEquals(response.result.objects.length, 0); + }); +}); diff --git a/test/test.policies.test.ts b/test/test.policies.test.ts new file mode 100644 index 0000000..3382ec7 --- /dev/null +++ b/test/test.policies.test.ts @@ -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(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(url, 'deleteBucket'); + await del.fire({ identity, bucketName: 'pol-bucket-1' }); + } catch { /* may already be deleted */ } + try { + const del = new TypedRequest(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(url, 'listNamedPolicies'); + const response = await req.fire({ identity }); + assertEquals(response.policies.length, 0); + }); + + it('should create a named policy', async () => { + const req = new TypedRequest(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(url, 'deleteBucket'); + await delBucket.fire({ identity, bucketName: 'pol-bucket-1' }); + + // Verify policy1 no longer lists pol-bucket-1 + const req = new TypedRequest(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(url, 'deleteNamedPolicy'); + await delPolicy.fire({ identity, policyId: policy1Id }); + + const listPolicies = new TypedRequest(url, 'listNamedPolicies'); + const response = await listPolicies.fire({ identity }); + assertEquals(response.policies.length, 0); + }); +}); diff --git a/test/test.s3-compat.test.ts b/test/test.s3-compat.test.ts new file mode 100644 index 0000000..fea6a99 --- /dev/null +++ b/test/test.s3-compat.test.ts @@ -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); + }); +}); diff --git a/test/test.status-config.test.ts b/test/test.status-config.test.ts new file mode 100644 index 0000000..498d5d6 --- /dev/null +++ b/test/test.status-config.test.ts @@ -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(url, 'createBucket'); + await createBucket.fire({ identity, bucketName: BUCKET }); + + const putObj = new TypedRequest(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(url, 'deletePrefix'); + await delPrefix.fire({ identity, bucketName: BUCKET, prefix: '' }); + const delBucket = new TypedRequest(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(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(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(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(url, 'putObject'); + await putObj.fire({ + identity, + bucketName: BUCKET, + key: 'file3.txt', + base64Content: btoa('Content of file 3'), + contentType: 'text/plain', + }); + + const req = new TypedRequest(url, 'getServerStatus'); + const response = await req.fire({ identity }); + assertEquals(response.status.totalObjectCount, 3); + }); +}); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 4169d9d..224d4c9 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -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' } diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 4169d9d..224d4c9 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -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' }