diff --git a/src/libs/collections/src/assert/stores.rs b/src/libs/collections/src/assert/stores.rs index 042a9ed062..957fcccf7d 100644 --- a/src/libs/collections/src/assert/stores.rs +++ b/src/libs/collections/src/assert/stores.rs @@ -22,9 +22,12 @@ pub fn assert_permission_with( ) -> bool { match permission { Permission::Public => true, - Permission::Private => assert_caller(caller, owner), + Permission::Private => is_owner_and_valid(caller, owner, controllers), Permission::Managed => { - assert_caller(caller, owner) || controller_can_write(caller, controllers) + // if owner, then it's either not a controller or a valid controller + // else if valid controller + is_owner_and_valid(caller, owner, controllers) + || controller_can_write(caller, controllers) } Permission::Controllers => is_allowed_controller(caller, controllers), } @@ -48,13 +51,16 @@ pub fn assert_create_permission_with( ) -> bool { match permission { Permission::Public => true, - Permission::Private => assert_not_anonymous(caller), - Permission::Managed => assert_not_anonymous(caller), Permission::Controllers => is_allowed_controller(caller, controllers), + _ => { + assert_not_anonymous(caller) + && (is_not_controller(caller, controllers) + || controller_can_write(caller, controllers)) + } } } -fn assert_caller(caller: Principal, owner: Principal) -> bool { +fn is_owner(caller: Principal, owner: Principal) -> bool { principal_not_anonymous_and_equal(caller, owner) } @@ -65,3 +71,376 @@ fn assert_not_anonymous(caller: Principal) -> bool { pub fn public_permission(permission: &Permission) -> bool { matches!(permission, Permission::Public) } + +fn is_controller(caller: Principal, controllers: &Controllers) -> bool { + controllers.contains_key(&caller) +} + +fn is_not_controller(caller: Principal, controllers: &Controllers) -> bool { + !is_controller(caller, controllers) +} + +fn is_owner_and_valid(caller: Principal, owner: Principal, controllers: &Controllers) -> bool { + is_owner(caller, owner) + && (is_not_controller(caller, controllers) || controller_can_write(caller, controllers)) +} + +#[cfg(test)] +mod tests { + use super::*; + use junobuild_shared::types::state::{Controller, ControllerScope, Controllers}; + use std::collections::HashMap; + + fn test_principal(id: u8) -> Principal { + Principal::from_slice(&[id]) + } + + fn create_controller(scope: ControllerScope, expires_at: Option) -> Controller { + Controller { + metadata: HashMap::new(), + created_at: 1000, + updated_at: 1000, + expires_at, + scope, + kind: None, + } + } + + fn mock_time() -> u64 { + 1_000_000_000_000 + } + + #[test] + fn test_is_owner() { + let owner = test_principal(1); + let caller = test_principal(1); + let other = test_principal(2); + + assert!(is_owner(caller, owner)); + assert!(!is_owner(other, owner)); + assert!(!is_owner(Principal::anonymous(), owner)); + } + + #[test] + fn test_is_controller() { + let mut controllers = Controllers::new(); + let controller_principal = test_principal(1); + let non_controller = test_principal(2); + + controllers.insert( + controller_principal, + create_controller(ControllerScope::Write, None), + ); + + assert!(is_controller(controller_principal, &controllers)); + assert!(!is_controller(non_controller, &controllers)); + } + + #[test] + fn test_public_permission_allows_anyone() { + let controllers = Controllers::new(); + let owner = test_principal(1); + let caller = test_principal(2); + + assert!(assert_permission( + &Permission::Public, + owner, + caller, + &controllers + )); + } + + #[test] + fn test_private_permission_allows_owner_not_controller() { + let controllers = Controllers::new(); + let owner = test_principal(1); + + assert!(assert_permission( + &Permission::Private, + owner, + owner, + &controllers + )); + } + + #[test] + fn test_private_permission_allows_owner_valid_controller() { + let mut controllers = Controllers::new(); + let owner = test_principal(1); + + controllers.insert( + owner, + create_controller(ControllerScope::Write, Some(mock_time() + 1000)), + ); + + assert!(assert_permission( + &Permission::Private, + owner, + owner, + &controllers + )); + } + + #[test] + fn test_private_permission_rejects_owner_expired_controller() { + let mut controllers = Controllers::new(); + let owner = test_principal(1); + + controllers.insert( + owner, + create_controller(ControllerScope::Write, Some(mock_time() - 1)), + ); + + assert!(!assert_permission( + &Permission::Private, + owner, + owner, + &controllers + )); + } + + #[test] + fn test_private_permission_rejects_non_owner() { + let controllers = Controllers::new(); + let owner = test_principal(1); + let caller = test_principal(2); + + assert!(!assert_permission( + &Permission::Private, + owner, + caller, + &controllers + )); + } + + #[test] + fn test_managed_permission_allows_owner_not_controller() { + let controllers = Controllers::new(); + let owner = test_principal(1); + + assert!(assert_permission( + &Permission::Managed, + owner, + owner, + &controllers + )); + } + + #[test] + fn test_managed_permission_allows_owner_valid_controller() { + let mut controllers = Controllers::new(); + let owner = test_principal(1); + + controllers.insert( + owner, + create_controller(ControllerScope::Write, Some(mock_time() + 1000)), + ); + + assert!(assert_permission( + &Permission::Managed, + owner, + owner, + &controllers + )); + } + + #[test] + fn test_managed_permission_rejects_owner_expired_controller() { + let mut controllers = Controllers::new(); + let owner = test_principal(1); + + controllers.insert( + owner, + create_controller(ControllerScope::Write, Some(mock_time() - 1)), + ); + + assert!(!assert_permission( + &Permission::Managed, + owner, + owner, + &controllers + )); + } + + #[test] + fn test_managed_permission_allows_valid_write_controller() { + let mut controllers = Controllers::new(); + let owner = test_principal(1); + let controller = test_principal(2); + + controllers.insert( + controller, + create_controller(ControllerScope::Write, Some(mock_time() + 1000)), + ); + + assert!(assert_permission( + &Permission::Managed, + owner, + controller, + &controllers + )); + } + + #[test] + fn test_managed_permission_rejects_expired_write_controller() { + let mut controllers = Controllers::new(); + let owner = test_principal(1); + let controller = test_principal(2); + + controllers.insert( + controller, + create_controller(ControllerScope::Write, Some(mock_time() - 1)), + ); + + assert!(!assert_permission( + &Permission::Managed, + owner, + controller, + &controllers + )); + } + + #[test] + fn test_managed_permission_rejects_submit_controller() { + let mut controllers = Controllers::new(); + let owner = test_principal(1); + let controller = test_principal(2); + + controllers.insert(controller, create_controller(ControllerScope::Submit, None)); + + assert!(!assert_permission( + &Permission::Managed, + owner, + controller, + &controllers + )); + } + + #[test] + fn test_controllers_permission_allows_valid_controller() { + let mut controllers = Controllers::new(); + let owner = test_principal(1); + let controller = test_principal(2); + + controllers.insert( + controller, + create_controller(ControllerScope::Write, Some(mock_time() + 1000)), + ); + + assert!(assert_permission( + &Permission::Controllers, + owner, + controller, + &controllers + )); + } + + #[test] + fn test_controllers_permission_rejects_expired_controller() { + let mut controllers = Controllers::new(); + let owner = test_principal(1); + let controller = test_principal(2); + + controllers.insert( + controller, + create_controller(ControllerScope::Write, Some(mock_time() - 1)), + ); + + assert!(!assert_permission( + &Permission::Controllers, + owner, + controller, + &controllers + )); + } + + // Create permission tests + #[test] + fn test_create_public_allows_anyone() { + let controllers = Controllers::new(); + let caller = test_principal(1); + + assert!(assert_create_permission( + &Permission::Public, + caller, + &controllers + )); + } + + #[test] + fn test_create_private_allows_non_controller() { + let controllers = Controllers::new(); + let caller = test_principal(1); + + assert!(assert_create_permission( + &Permission::Private, + caller, + &controllers + )); + } + + #[test] + fn test_create_private_allows_valid_controller() { + let mut controllers = Controllers::new(); + let caller = test_principal(1); + + controllers.insert( + caller, + create_controller(ControllerScope::Write, Some(mock_time() + 1000)), + ); + + assert!(assert_create_permission( + &Permission::Private, + caller, + &controllers + )); + } + + #[test] + fn test_create_private_rejects_expired_controller() { + let mut controllers = Controllers::new(); + let caller = test_principal(1); + + controllers.insert( + caller, + create_controller(ControllerScope::Write, Some(mock_time() - 1)), + ); + + assert!(!assert_create_permission( + &Permission::Private, + caller, + &controllers + )); + } + + #[test] + fn test_create_controllers_allows_valid_write_controller() { + let mut controllers = Controllers::new(); + let caller = test_principal(1); + + controllers.insert( + caller, + create_controller(ControllerScope::Write, Some(mock_time() + 1000)), + ); + + assert!(assert_create_permission( + &Permission::Controllers, + caller, + &controllers + )); + } + + #[test] + fn test_create_controllers_rejects_submit_controller() { + let mut controllers = Controllers::new(); + let caller = test_principal(1); + + controllers.insert(caller, create_controller(ControllerScope::Submit, None)); + + assert!(!assert_create_permission( + &Permission::Controllers, + caller, + &controllers + )); + } +} diff --git a/src/libs/shared/src/segments/controllers.rs b/src/libs/shared/src/segments/controllers.rs index fe7f4aaf84..5d46abdf51 100644 --- a/src/libs/shared/src/segments/controllers.rs +++ b/src/libs/shared/src/segments/controllers.rs @@ -80,7 +80,7 @@ pub fn delete_controllers(remove_controllers: &[UserId], controllers: &mut Contr } } -/// Checks if a caller is a controller with admin or write scope (permissions). +/// Checks if a caller is a non-expired controller with admin or write scope (permissions). /// /// # Arguments /// - `caller`: `UserId` of the caller. @@ -95,11 +95,14 @@ pub fn controller_can_write(caller: UserId, controllers: &Controllers) -> bool { .iter() .any(|(&controller_id, controller)| match controller.scope { ControllerScope::Submit => false, - _ => principal_equal(controller_id, caller), + _ => { + principal_equal(controller_id, caller) + && is_controller_not_expired(controller) + } })) } -/// Checks if a caller is a valid controller regardless of its scope (admin, write or submit). +/// Checks if a caller is a non-expired controller regardless of scope (admin, write, or submit). /// /// # Arguments /// - `caller`: `UserId` of the caller. @@ -110,9 +113,46 @@ pub fn controller_can_write(caller: UserId, controllers: &Controllers) -> bool { pub fn is_valid_controller(caller: UserId, controllers: &Controllers) -> bool { principal_not_anonymous(caller) && (caller_is_self(caller) - || controllers - .iter() - .any(|(&controller_id, _)| principal_equal(controller_id, caller))) + || controllers.iter().any(|(&controller_id, controller)| { + principal_equal(controller_id, caller) && is_controller_not_expired(controller) + })) +} + +/// Checks if a controller (access key) has not expired. +/// +/// Admin controllers never expire. Other controllers are considered not expired if: +/// - They have no expiration date set, or +/// - Their expiration date is in the future +/// +/// # Arguments +/// - `controller`: The controller to check +/// +/// # Returns +/// `true` if the controller has not expired, `false` otherwise. +fn is_controller_not_expired(controller: &Controller) -> bool { + !is_controller_expired(controller) +} + +/// Checks if a controller (access key) has expired. +/// +/// Admin controllers never expire. Other controllers are considered expired if: +/// - They have an expiration date set, and +/// - That expiration date is in the past +/// +/// # Arguments +/// - `controller`: The controller to check +/// +/// # Returns +/// `true` if the controller has expired, `false` otherwise. +fn is_controller_expired(controller: &Controller) -> bool { + // Admin controller cannot expire + if matches!(controller.scope, ControllerScope::Admin) { + return false; + } + + controller + .expires_at + .map_or(false, |expires_at| expires_at < time()) } /// Checks if a caller is an admin controller. @@ -325,6 +365,42 @@ mod tests { } } + #[test] + fn test_is_controller_expired_admin_never_expires() { + let admin = create_controller(ControllerScope::Admin, Some(mock_time() - 1000), None); + assert!(!is_controller_expired(&admin)); + } + + #[test] + fn test_is_controller_expired_no_expiration() { + let controller = create_controller(ControllerScope::Write, None, None); + assert!(!is_controller_expired(&controller)); + } + + #[test] + fn test_is_controller_expired_future_expiration() { + let controller = create_controller(ControllerScope::Write, Some(time() + 1_000_000), None); + assert!(!is_controller_expired(&controller)); + } + + #[test] + fn test_is_controller_not_expired() { + let admin = create_controller(ControllerScope::Admin, Some(time() - 1000), None); + assert!(is_controller_not_expired(&admin)); + + let expired = create_controller(ControllerScope::Write, Some(time() - 1), None); + assert!(!is_controller_not_expired(&expired)); + + let valid = create_controller(ControllerScope::Write, Some(time() + 1000), None); + assert!(is_controller_not_expired(&valid)); + } + + #[test] + fn test_is_controller_expired_past_expiration() { + let controller = create_controller(ControllerScope::Write, Some(mock_time() - 1), None); + assert!(is_controller_expired(&controller)); + } + #[test] fn test_controller_can_write_anonymous_rejected() { let controllers = Controllers::new(); diff --git a/src/tests/specs/satellite/stock/controllers/satellite.controllers.datastore.submit.spec.ts b/src/tests/specs/satellite/stock/controllers/satellite.controllers.datastore.submit.spec.ts new file mode 100644 index 0000000000..4d9bc847b3 --- /dev/null +++ b/src/tests/specs/satellite/stock/controllers/satellite.controllers.datastore.submit.spec.ts @@ -0,0 +1,165 @@ +import { idlFactorySatellite, type SatelliteActor, type SatelliteDid } from '$declarations'; +import { type Actor, PocketIc } from '@dfinity/pic'; +import { assertNonNullish, fromNullable, toNullable } from '@dfinity/utils'; +import { Ed25519KeyIdentity } from '@icp-sdk/core/identity'; +import { JUNO_DATASTORE_ERROR_CANNOT_WRITE } from '@junobuild/errors'; +import { inject } from 'vitest'; +import { CONTROLLER_METADATA } from '../../../../constants/controller-tests.constants'; +import { mockListParams } from '../../../../mocks/list.mocks'; +import { createDoc as createDocUtils } from '../../../../utils/satellite-doc-tests.utils'; +import { controllersInitArgs, SATELLITE_WASM_PATH } from '../../../../utils/setup-tests.utils'; + +describe.each([ + { title: 'heap', memory: { Heap: null } }, + { title: 'stable', memory: { Stable: null } } +])('Satellite > Controllers > Datastore $title > Submit', ({ memory }) => { + let pic: PocketIc; + let actor: Actor; + + const controller = Ed25519KeyIdentity.generate(); + + const TEST_COLLECTION = 'test'; + + const currentDate = new Date(2021, 6, 10, 0, 0, 0, 0); + + const createDoc = (): Promise => + createDocUtils({ + actor, + collection: TEST_COLLECTION + }); + + let testWriteController: Ed25519KeyIdentity; + let testSubmitController: Ed25519KeyIdentity; + + beforeAll(async () => { + pic = await PocketIc.create(inject('PIC_URL')); + + await pic.setTime(currentDate.getTime()); + + const { actor: c } = await pic.setupCanister({ + idlFactory: idlFactorySatellite, + wasm: SATELLITE_WASM_PATH, + arg: controllersInitArgs(controller), + sender: controller.getPrincipal() + }); + + actor = c; + + actor.setIdentity(controller); + + const setRule: SatelliteDid.SetRule = { + memory: toNullable(memory), + max_size: toNullable(), + read: { Managed: null }, + mutable_permissions: toNullable(), + write: { Managed: null }, + version: toNullable(), + max_capacity: toNullable(), + rate_config: toNullable(), + max_changes_per_user: toNullable() + }; + + const { set_rule } = actor; + await set_rule({ Db: null }, TEST_COLLECTION, setRule); + }); + + afterAll(async () => { + await pic?.tearDown(); + }); + + beforeEach(async () => { + actor.setIdentity(controller); + + testWriteController = Ed25519KeyIdentity.generate(); + testSubmitController = Ed25519KeyIdentity.generate(); + + const { set_controllers } = actor; + + await set_controllers({ + controller: { + ...CONTROLLER_METADATA, + scope: { Write: null } + }, + controllers: [testWriteController.getPrincipal()] + }); + + await set_controllers({ + controller: { + ...CONTROLLER_METADATA, + scope: { Submit: null } + }, + controllers: [testSubmitController.getPrincipal()] + }); + + actor.setIdentity(testSubmitController); + }); + + it('should throw on set document', async () => { + await pic.advanceTime(100); + + await expect(createDoc()).rejects.toThrowError(JUNO_DATASTORE_ERROR_CANNOT_WRITE); + }); + + it('should return empty on get document', async () => { + actor.setIdentity(testWriteController); + + const key = await createDoc(); + + const { get_doc } = actor; + + expect(fromNullable(await get_doc(TEST_COLLECTION, key))).not.toBeUndefined(); + + actor.setIdentity(testSubmitController); + + await expect(get_doc(TEST_COLLECTION, key)).resolves.toEqual([]); + }); + + it('should throw on delete document', async () => { + actor.setIdentity(testWriteController); + + const key = await createDoc(); + + actor.setIdentity(testSubmitController); + + const { del_doc } = actor; + + await expect(del_doc(TEST_COLLECTION, key, { version: [1n] })).rejects.toThrowError( + JUNO_DATASTORE_ERROR_CANNOT_WRITE + ); + }); + + it('should throw on update document', async () => { + actor.setIdentity(testWriteController); + + const key = await createDoc(); + + const { get_doc } = actor; + + const doc = fromNullable(await get_doc(TEST_COLLECTION, key)); + + assertNonNullish(doc); + + actor.setIdentity(testSubmitController); + + const { set_doc } = actor; + + await expect( + set_doc(TEST_COLLECTION, key, { + ...doc, + version: doc.version + }) + ).rejects.toThrowError(JUNO_DATASTORE_ERROR_CANNOT_WRITE); + }); + + it('should return empty on list documents', async () => { + actor.setIdentity(testWriteController); + + const { list_docs } = actor; + + expect((await list_docs(TEST_COLLECTION, mockListParams)).items_length).toBeGreaterThan(0n); + + actor.setIdentity(testSubmitController); + + expect((await list_docs(TEST_COLLECTION, mockListParams)).items_length).toEqual(0n); + }); +}); diff --git a/src/tests/specs/satellite/stock/controllers/satellite.controllers.datastore.write.spec.ts b/src/tests/specs/satellite/stock/controllers/satellite.controllers.datastore.write.spec.ts new file mode 100644 index 0000000000..553b2d8745 --- /dev/null +++ b/src/tests/specs/satellite/stock/controllers/satellite.controllers.datastore.write.spec.ts @@ -0,0 +1,172 @@ +import { idlFactorySatellite, type SatelliteActor, type SatelliteDid } from '$declarations'; +import { toBigIntNanoSeconds } from '$lib/utils/date.utils'; +import { type Actor, PocketIc } from '@dfinity/pic'; +import { assertNonNullish, fromNullable, toNullable } from '@dfinity/utils'; +import { Ed25519KeyIdentity } from '@icp-sdk/core/identity'; +import { JUNO_DATASTORE_ERROR_CANNOT_WRITE } from '@junobuild/errors'; +import { inject } from 'vitest'; +import { CONTROLLER_METADATA } from '../../../../constants/controller-tests.constants'; +import { mockListParams } from '../../../../mocks/list.mocks'; +import { tick } from '../../../../utils/pic-tests.utils'; +import { createDoc as createDocUtils } from '../../../../utils/satellite-doc-tests.utils'; +import { controllersInitArgs, SATELLITE_WASM_PATH } from '../../../../utils/setup-tests.utils'; + +describe.each([ + { title: 'heap', memory: { Heap: null } }, + { title: 'stable', memory: { Stable: null } } +])('Satellite > Controllers > Datastore $title > Write', ({ memory }) => { + let pic: PocketIc; + let actor: Actor; + + const controller = Ed25519KeyIdentity.generate(); + + const TEST_COLLECTION = 'test'; + + const currentDate = new Date(2021, 6, 10, 0, 0, 0, 0); + + const createDoc = (): Promise => + createDocUtils({ + actor, + collection: TEST_COLLECTION + }); + + beforeAll(async () => { + pic = await PocketIc.create(inject('PIC_URL')); + + await pic.setTime(currentDate.getTime()); + + const { actor: c } = await pic.setupCanister({ + idlFactory: idlFactorySatellite, + wasm: SATELLITE_WASM_PATH, + arg: controllersInitArgs(controller), + sender: controller.getPrincipal() + }); + + actor = c; + + actor.setIdentity(controller); + + const setRule: SatelliteDid.SetRule = { + memory: toNullable(memory), + max_size: toNullable(), + read: { Managed: null }, + mutable_permissions: toNullable(), + write: { Managed: null }, + version: toNullable(), + max_capacity: toNullable(), + rate_config: toNullable(), + max_changes_per_user: toNullable() + }; + + const { set_rule } = actor; + await set_rule({ Db: null }, TEST_COLLECTION, setRule); + }); + + afterAll(async () => { + await pic?.tearDown(); + }); + + const generateController = async (futureMilliseconds?: number) => { + actor.setIdentity(controller); + + const testController = Ed25519KeyIdentity.generate(); + + const { set_controllers } = actor; + + await set_controllers({ + controller: { + ...CONTROLLER_METADATA, + scope: { Write: null }, + expires_at: [ + toBigIntNanoSeconds(new Date((await pic.getTime()) + (futureMilliseconds ?? 0))) + ] + }, + controllers: [testController.getPrincipal()] + }); + + actor.setIdentity(testController); + }; + + it('should throw on set document', async () => { + await generateController(); + + await pic.advanceTime(100); + + await expect(createDoc()).rejects.toThrowError(JUNO_DATASTORE_ERROR_CANNOT_WRITE); + }); + + it('should return empty on get document', async () => { + const futureMilliseconds = 10_000; + + await generateController(futureMilliseconds); + + const key = await createDoc(); + + const { get_doc } = actor; + + expect(fromNullable(await get_doc(TEST_COLLECTION, key))).not.toBeUndefined(); + + await pic.advanceTime(futureMilliseconds + 1); + await tick(pic); + + await expect(get_doc(TEST_COLLECTION, key)).resolves.toEqual([]); + }); + + it('should throw on delete document', async () => { + const futureMilliseconds = 10_000; + + await generateController(futureMilliseconds); + + const key = await createDoc(); + + await pic.advanceTime(futureMilliseconds + 1); + await tick(pic); + + const { del_doc } = actor; + + await expect(del_doc(TEST_COLLECTION, key, { version: [1n] })).rejects.toThrowError( + JUNO_DATASTORE_ERROR_CANNOT_WRITE + ); + }); + + it('should throw on update document', async () => { + const futureMilliseconds = 10_000; + + await generateController(futureMilliseconds); + + const key = await createDoc(); + + const { get_doc } = actor; + + const doc = fromNullable(await get_doc(TEST_COLLECTION, key)); + + assertNonNullish(doc); + + await pic.advanceTime(futureMilliseconds + 1); + await tick(pic); + + const { set_doc } = actor; + + await expect( + set_doc(TEST_COLLECTION, key, { + ...doc, + version: doc.version + }) + ).rejects.toThrowError(JUNO_DATASTORE_ERROR_CANNOT_WRITE); + }); + + it('should return empty on list documents', async () => { + const futureMilliseconds = 10_000; + + await generateController(futureMilliseconds); + + const { list_docs } = actor; + + expect((await list_docs(TEST_COLLECTION, mockListParams)).items_length).toBeGreaterThan(0n); + + await pic.advanceTime(futureMilliseconds + 1); + await tick(pic); + + expect((await list_docs(TEST_COLLECTION, mockListParams)).items_length).toEqual(0n); + }); +}); diff --git a/src/tests/specs/satellite/stock/controllers/satellite.controllers.guards.spec.ts b/src/tests/specs/satellite/stock/controllers/satellite.controllers.guards.spec.ts new file mode 100644 index 0000000000..be5e6dfbbb --- /dev/null +++ b/src/tests/specs/satellite/stock/controllers/satellite.controllers.guards.spec.ts @@ -0,0 +1,278 @@ +import { idlFactorySatellite, type SatelliteActor } from '$declarations'; +import { toBigIntNanoSeconds } from '$lib/utils/date.utils'; +import { type Actor, PocketIc } from '@dfinity/pic'; +import { toNullable } from '@dfinity/utils'; +import { Ed25519KeyIdentity } from '@icp-sdk/core/identity'; +import { + JUNO_AUTH_ERROR_NOT_CONTROLLER, + JUNO_AUTH_ERROR_NOT_WRITE_CONTROLLER +} from '@junobuild/errors'; +import { inject } from 'vitest'; +import { CONTROLLER_METADATA } from '../../../../constants/controller-tests.constants'; +import { mockListProposalsParams } from '../../../../mocks/list.mocks'; +import { controllersInitArgs, SATELLITE_WASM_PATH } from '../../../../utils/setup-tests.utils'; + +describe('Satellite > Controllers > Guards', () => { + let pic: PocketIc; + let actor: Actor; + + const controller = Ed25519KeyIdentity.generate(); + + const TEST_COLLECTION = 'test'; + + const currentDate = new Date(2021, 6, 10, 0, 0, 0, 0); + + beforeAll(async () => { + pic = await PocketIc.create(inject('PIC_URL')); + + await pic.setTime(currentDate.getTime()); + + const { actor: c } = await pic.setupCanister({ + idlFactory: idlFactorySatellite, + wasm: SATELLITE_WASM_PATH, + arg: controllersInitArgs(controller), + sender: controller.getPrincipal() + }); + + actor = c; + + actor.setIdentity(controller); + }); + + afterAll(async () => { + await pic?.tearDown(); + }); + + describe.each([ + { title: 'write', scope: { Write: null } }, + { title: 'submit', scope: { Submit: null } } + ])('Caller is valid controller $title', ({ scope }) => { + const testController = Ed25519KeyIdentity.generate(); + + beforeAll(async () => { + actor.setIdentity(controller); + + const { set_controllers } = actor; + + await set_controllers({ + controller: { + ...CONTROLLER_METADATA, + scope, + expires_at: [toBigIntNanoSeconds(currentDate)] + }, + controllers: [testController.getPrincipal()] + }); + + actor.setIdentity(testController); + + await pic.advanceTime(100); + }); + + it('should throw on get_proposal', async () => { + const { get_proposal } = actor; + + await expect(get_proposal(123n)).rejects.toThrowError(JUNO_AUTH_ERROR_NOT_CONTROLLER); + }); + + it('should throw on list_proposals', async () => { + const { list_proposals } = actor; + + await expect(list_proposals(mockListProposalsParams)).rejects.toThrowError( + JUNO_AUTH_ERROR_NOT_CONTROLLER + ); + }); + + it('should throw on count_proposals', async () => { + const { count_proposals } = actor; + + await expect(count_proposals()).rejects.toThrowError(JUNO_AUTH_ERROR_NOT_CONTROLLER); + }); + + it('should throw on init_proposal', async () => { + const { init_proposal } = actor; + + await expect( + init_proposal({ + AssetsUpgrade: { + clear_existing_assets: toNullable() + } + }) + ).rejects.toThrowError(JUNO_AUTH_ERROR_NOT_CONTROLLER); + }); + + it('should throw on submit_proposal', async () => { + const { submit_proposal } = actor; + + await expect(submit_proposal(123n)).rejects.toThrowError(JUNO_AUTH_ERROR_NOT_CONTROLLER); + }); + + it('should throw on init_proposal_asset_upload', async () => { + const { init_proposal_asset_upload } = actor; + + await expect( + init_proposal_asset_upload( + { + collection: TEST_COLLECTION, + description: toNullable(), + encoding_type: [], + full_path: '/test', + name: '/test', + token: toNullable() + }, + 123n + ) + ).rejects.toThrowError(JUNO_AUTH_ERROR_NOT_CONTROLLER); + }); + + it('should throw on init_proposal_many_assets_upload', async () => { + const { init_proposal_many_assets_upload } = actor; + + await expect( + init_proposal_many_assets_upload( + [ + { + collection: TEST_COLLECTION, + description: toNullable(), + encoding_type: [], + full_path: '/test', + name: '/test', + token: toNullable() + } + ], + 123n + ) + ).rejects.toThrowError(JUNO_AUTH_ERROR_NOT_CONTROLLER); + }); + + it('should throw on upload_proposal_asset_chunk', async () => { + const { upload_proposal_asset_chunk } = actor; + + await expect( + upload_proposal_asset_chunk({ + batch_id: 123n, + content: Uint8Array.from([1, 2]), + order_id: [0n] + }) + ).rejects.toThrowError(JUNO_AUTH_ERROR_NOT_CONTROLLER); + }); + + it('should throw on commit_proposal_asset_upload', async () => { + const { commit_proposal_asset_upload } = actor; + + await expect( + commit_proposal_asset_upload({ + batch_id: 12n, + chunk_ids: [1n], + headers: [] + }) + ).rejects.toThrowError(JUNO_AUTH_ERROR_NOT_CONTROLLER); + }); + + it('should throw on commit_proposal_many_assets_upload', async () => { + const { commit_proposal_many_assets_upload } = actor; + + await expect( + commit_proposal_many_assets_upload([ + { + batch_id: 12n, + chunk_ids: [1n], + headers: [] + } + ]) + ).rejects.toThrowError(JUNO_AUTH_ERROR_NOT_CONTROLLER); + }); + + it('should throw on memory_size', async () => { + const { memory_size } = actor; + + await expect(memory_size()).rejects.toThrowError(JUNO_AUTH_ERROR_NOT_CONTROLLER); + }); + }); + + describe('Caller with write', () => { + const testController = Ed25519KeyIdentity.generate(); + + beforeAll(async () => { + actor.setIdentity(controller); + + const { set_controllers } = actor; + + await set_controllers({ + controller: { + ...CONTROLLER_METADATA, + scope: { Write: null }, + expires_at: [toBigIntNanoSeconds(currentDate)] + }, + controllers: [testController.getPrincipal()] + }); + + actor.setIdentity(testController); + + await pic.advanceTime(100); + }); + + it('should throw on del_docs', async () => { + const { del_docs } = actor; + + await expect(del_docs(TEST_COLLECTION)).rejects.toThrowError( + JUNO_AUTH_ERROR_NOT_WRITE_CONTROLLER + ); + }); + + it('should throw on count_collection_docs', async () => { + const { count_collection_docs } = actor; + + await expect(count_collection_docs(TEST_COLLECTION)).rejects.toThrowError( + JUNO_AUTH_ERROR_NOT_WRITE_CONTROLLER + ); + }); + + it('should throw on reject_proposal', async () => { + const { reject_proposal } = actor; + + await expect( + reject_proposal({ + proposal_id: 1123n, + sha256: Uint8Array.from([1, 2]) + }) + ).rejects.toThrowError(JUNO_AUTH_ERROR_NOT_WRITE_CONTROLLER); + }); + + it('should throw on commit_proposal', async () => { + const { commit_proposal } = actor; + + await expect( + commit_proposal({ + proposal_id: 1123n, + sha256: Uint8Array.from([1, 2]) + }) + ).rejects.toThrowError(JUNO_AUTH_ERROR_NOT_WRITE_CONTROLLER); + }); + + it('should throw on delete_proposal_assets', async () => { + const { delete_proposal_assets } = actor; + + await expect( + delete_proposal_assets({ + proposal_ids: [1n] + }) + ).rejects.toThrowError(JUNO_AUTH_ERROR_NOT_WRITE_CONTROLLER); + }); + + it('should throw on del_assets', async () => { + const { del_assets } = actor; + + await expect(del_assets(TEST_COLLECTION)).rejects.toThrowError( + JUNO_AUTH_ERROR_NOT_WRITE_CONTROLLER + ); + }); + + it('should throw on count_collection_assets', async () => { + const { count_collection_assets } = actor; + + await expect(count_collection_assets(TEST_COLLECTION)).rejects.toThrowError( + JUNO_AUTH_ERROR_NOT_WRITE_CONTROLLER + ); + }); + }); +}); diff --git a/src/tests/specs/satellite/stock/controllers/satellite.controllers.storage.submit.spec.ts b/src/tests/specs/satellite/stock/controllers/satellite.controllers.storage.submit.spec.ts new file mode 100644 index 0000000000..adcec8f07d --- /dev/null +++ b/src/tests/specs/satellite/stock/controllers/satellite.controllers.storage.submit.spec.ts @@ -0,0 +1,184 @@ +import { idlFactorySatellite, type SatelliteActor, type SatelliteDid } from '$declarations'; +import { type Actor, PocketIc } from '@dfinity/pic'; +import { assertNonNullish, fromNullable, toNullable } from '@dfinity/utils'; +import { Ed25519KeyIdentity } from '@icp-sdk/core/identity'; +import { + JUNO_STORAGE_ERROR_ASSET_NOT_FOUND, + JUNO_STORAGE_ERROR_UPLOAD_NOT_ALLOWED +} from '@junobuild/errors'; +import { inject } from 'vitest'; +import { CONTROLLER_METADATA } from '../../../../constants/controller-tests.constants'; +import { mockListParams } from '../../../../mocks/list.mocks'; +import { uploadAsset } from '../../../../utils/satellite-storage-tests.utils'; +import { controllersInitArgs, SATELLITE_WASM_PATH } from '../../../../utils/setup-tests.utils'; + +describe.each([ + { title: 'heap', memory: { Heap: null } }, + { title: 'stable', memory: { Stable: null } } +])('Satellite > Controllers > Datastore $title > Submit', ({ memory }) => { + let pic: PocketIc; + let actor: Actor; + + const controller = Ed25519KeyIdentity.generate(); + + const TEST_COLLECTION = 'test'; + + const currentDate = new Date(2021, 6, 10, 0, 0, 0, 0); + + const upload = async (params: { + full_path: string; + name: string; + collection: string; + headers?: [string, string][]; + encoding_type?: [] | [string]; + }) => { + await uploadAsset({ + ...params, + actor + }); + }; + + let testWriteController: Ed25519KeyIdentity; + let testSubmitController: Ed25519KeyIdentity; + + beforeAll(async () => { + pic = await PocketIc.create(inject('PIC_URL')); + + await pic.setTime(currentDate.getTime()); + + const { actor: c } = await pic.setupCanister({ + idlFactory: idlFactorySatellite, + wasm: SATELLITE_WASM_PATH, + arg: controllersInitArgs(controller), + sender: controller.getPrincipal() + }); + + actor = c; + + actor.setIdentity(controller); + + const setRule: SatelliteDid.SetRule = { + memory: toNullable(memory), + max_size: toNullable(), + read: { Managed: null }, + mutable_permissions: toNullable(), + write: { Managed: null }, + version: toNullable(), + max_capacity: toNullable(), + rate_config: toNullable(), + max_changes_per_user: toNullable() + }; + + const { set_rule } = actor; + await set_rule({ Storage: null }, TEST_COLLECTION, setRule); + }); + + afterAll(async () => { + await pic?.tearDown(); + }); + + beforeEach(async () => { + actor.setIdentity(controller); + + testWriteController = Ed25519KeyIdentity.generate(); + testSubmitController = Ed25519KeyIdentity.generate(); + + const { set_controllers } = actor; + + await set_controllers({ + controller: { + ...CONTROLLER_METADATA, + scope: { Write: null } + }, + controllers: [testWriteController.getPrincipal()] + }); + + await set_controllers({ + controller: { + ...CONTROLLER_METADATA, + scope: { Submit: null } + }, + controllers: [testSubmitController.getPrincipal()] + }); + + actor.setIdentity(testSubmitController); + }); + + it('should throw on upload asset', async () => { + await pic.advanceTime(100); + + const name = 'hello.html'; + const full_path = `/${TEST_COLLECTION}/${name}`; + + await expect(upload({ full_path, name, collection: TEST_COLLECTION })).rejects.toThrowError( + JUNO_STORAGE_ERROR_UPLOAD_NOT_ALLOWED + ); + }); + + it('should return empty on get asset', async () => { + actor.setIdentity(testWriteController); + + const name = 'hello.html'; + const full_path = `/${TEST_COLLECTION}/${name}`; + + await upload({ full_path, name, collection: TEST_COLLECTION }); + + const { get_asset } = actor; + + expect(fromNullable(await get_asset(TEST_COLLECTION, full_path))).not.toBeUndefined(); + + actor.setIdentity(testSubmitController); + + await expect(get_asset(TEST_COLLECTION, full_path)).resolves.toEqual([]); + }); + + it('should throw on delete asset', async () => { + actor.setIdentity(testWriteController); + + const name = 'hello1.html'; + const full_path = `/${TEST_COLLECTION}/${name}`; + + await upload({ full_path, name, collection: TEST_COLLECTION }); + + actor.setIdentity(testSubmitController); + + const { del_asset } = actor; + + await expect(del_asset(TEST_COLLECTION, full_path)).rejects.toThrowError( + JUNO_STORAGE_ERROR_ASSET_NOT_FOUND + ); + }); + + it('should throw on update asset', async () => { + actor.setIdentity(testWriteController); + + const name = 'hello2.html'; + const full_path = `/${TEST_COLLECTION}/${name}`; + + await upload({ full_path, name, collection: TEST_COLLECTION }); + + const { get_asset } = actor; + + const asset = fromNullable(await get_asset(TEST_COLLECTION, full_path)); + + assertNonNullish(asset); + + actor.setIdentity(testSubmitController); + + await expect(upload({ full_path, name, collection: TEST_COLLECTION })).rejects.toThrowError( + JUNO_STORAGE_ERROR_UPLOAD_NOT_ALLOWED + ); + }); + + it('should return empty on list assets', async () => { + actor.setIdentity(testWriteController); + + const { list_assets } = actor; + + expect((await list_assets(TEST_COLLECTION, mockListParams)).items_length).toBeGreaterThan(0n); + + actor.setIdentity(testSubmitController); + + expect((await list_assets(TEST_COLLECTION, mockListParams)).items_length).toEqual(0n); + }); +}); diff --git a/src/tests/specs/satellite/stock/controllers/satellite.controllers.storage.write.spec.ts b/src/tests/specs/satellite/stock/controllers/satellite.controllers.storage.write.spec.ts new file mode 100644 index 0000000000..2dae04971a --- /dev/null +++ b/src/tests/specs/satellite/stock/controllers/satellite.controllers.storage.write.spec.ts @@ -0,0 +1,191 @@ +import { idlFactorySatellite, type SatelliteActor, type SatelliteDid } from '$declarations'; +import { toBigIntNanoSeconds } from '$lib/utils/date.utils'; +import { type Actor, PocketIc } from '@dfinity/pic'; +import { assertNonNullish, fromNullable, toNullable } from '@dfinity/utils'; +import { Ed25519KeyIdentity } from '@icp-sdk/core/identity'; +import { + JUNO_STORAGE_ERROR_ASSET_NOT_FOUND, + JUNO_STORAGE_ERROR_UPLOAD_NOT_ALLOWED +} from '@junobuild/errors'; +import { inject } from 'vitest'; +import { CONTROLLER_METADATA } from '../../../../constants/controller-tests.constants'; +import { mockListParams } from '../../../../mocks/list.mocks'; +import { tick } from '../../../../utils/pic-tests.utils'; +import { uploadAsset } from '../../../../utils/satellite-storage-tests.utils'; +import { controllersInitArgs, SATELLITE_WASM_PATH } from '../../../../utils/setup-tests.utils'; + +describe.each([ + { title: 'heap', memory: { Heap: null } }, + { title: 'stable', memory: { Stable: null } } +])('Satellite > Controllers > Datastore $title > Write', ({ memory }) => { + let pic: PocketIc; + let actor: Actor; + + const controller = Ed25519KeyIdentity.generate(); + + const TEST_COLLECTION = 'test'; + + const currentDate = new Date(2021, 6, 10, 0, 0, 0, 0); + + const upload = async (params: { + full_path: string; + name: string; + collection: string; + headers?: [string, string][]; + encoding_type?: [] | [string]; + }) => { + await uploadAsset({ + ...params, + actor + }); + }; + + beforeAll(async () => { + pic = await PocketIc.create(inject('PIC_URL')); + + await pic.setTime(currentDate.getTime()); + + const { actor: c } = await pic.setupCanister({ + idlFactory: idlFactorySatellite, + wasm: SATELLITE_WASM_PATH, + arg: controllersInitArgs(controller), + sender: controller.getPrincipal() + }); + + actor = c; + + actor.setIdentity(controller); + + const setRule: SatelliteDid.SetRule = { + memory: toNullable(memory), + max_size: toNullable(), + read: { Managed: null }, + mutable_permissions: toNullable(), + write: { Managed: null }, + version: toNullable(), + max_capacity: toNullable(), + rate_config: toNullable(), + max_changes_per_user: toNullable() + }; + + const { set_rule } = actor; + await set_rule({ Storage: null }, TEST_COLLECTION, setRule); + }); + + afterAll(async () => { + await pic?.tearDown(); + }); + + const generateController = async (futureMilliseconds?: number) => { + actor.setIdentity(controller); + + const testController = Ed25519KeyIdentity.generate(); + + const { set_controllers } = actor; + + await set_controllers({ + controller: { + ...CONTROLLER_METADATA, + scope: { Write: null }, + expires_at: [ + toBigIntNanoSeconds(new Date((await pic.getTime()) + (futureMilliseconds ?? 0))) + ] + }, + controllers: [testController.getPrincipal()] + }); + + actor.setIdentity(testController); + }; + + it('should throw on upload asset', async () => { + await generateController(); + + await pic.advanceTime(100); + + const name = 'hello.html'; + const full_path = `/${TEST_COLLECTION}/${name}`; + + await expect(upload({ full_path, name, collection: TEST_COLLECTION })).rejects.toThrowError( + JUNO_STORAGE_ERROR_UPLOAD_NOT_ALLOWED + ); + }); + + it('should return empty on get asset', async () => { + const futureMilliseconds = 10_000; + + await generateController(futureMilliseconds); + + const name = 'hello.html'; + const full_path = `/${TEST_COLLECTION}/${name}`; + + await upload({ full_path, name, collection: TEST_COLLECTION }); + + const { get_asset } = actor; + + expect(fromNullable(await get_asset(TEST_COLLECTION, full_path))).not.toBeUndefined(); + + await pic.advanceTime(futureMilliseconds + 1); + await tick(pic); + + await expect(get_asset(TEST_COLLECTION, full_path)).resolves.toEqual([]); + }); + + it('should throw on delete asset', async () => { + const futureMilliseconds = 10_000; + + await generateController(futureMilliseconds); + + const name = 'hello1.html'; + const full_path = `/${TEST_COLLECTION}/${name}`; + + await upload({ full_path, name, collection: TEST_COLLECTION }); + + await pic.advanceTime(futureMilliseconds + 1); + await tick(pic); + + const { del_asset } = actor; + + await expect(del_asset(TEST_COLLECTION, full_path)).rejects.toThrowError( + JUNO_STORAGE_ERROR_ASSET_NOT_FOUND + ); + }); + + it('should throw on update asset', async () => { + const futureMilliseconds = 10_000; + + await generateController(futureMilliseconds); + + const name = 'hello2.html'; + const full_path = `/${TEST_COLLECTION}/${name}`; + + await upload({ full_path, name, collection: TEST_COLLECTION }); + + const { get_asset } = actor; + + const asset = fromNullable(await get_asset(TEST_COLLECTION, full_path)); + + assertNonNullish(asset); + + await pic.advanceTime(futureMilliseconds + 1); + await tick(pic); + + await expect(upload({ full_path, name, collection: TEST_COLLECTION })).rejects.toThrowError( + JUNO_STORAGE_ERROR_UPLOAD_NOT_ALLOWED + ); + }); + + it('should return empty on list assets', async () => { + const futureMilliseconds = 10_000; + + await generateController(futureMilliseconds); + + const { list_assets } = actor; + + expect((await list_assets(TEST_COLLECTION, mockListParams)).items_length).toBeGreaterThan(0n); + + await pic.advanceTime(futureMilliseconds + 1); + await tick(pic); + + expect((await list_assets(TEST_COLLECTION, mockListParams)).items_length).toEqual(0n); + }); +});