diff --git a/test/core/auth/auth_state_test.dart b/test/core/auth/auth_state_test.dart new file mode 100644 index 0000000..43cbff5 --- /dev/null +++ b/test/core/auth/auth_state_test.dart @@ -0,0 +1,280 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tatlock_ui/core/auth/auth_state.dart'; +import 'package:tatlock_ui/core/auth/permissions.dart'; +import 'package:tatlock_ui/core/auth/user_preferences.dart'; + +void main() { + group('AuthState', () { + group('default constructor', () { + test('creates unauthenticated state by default', () { + const state = AuthState(); + + expect(state.isAuthenticated, isFalse); + expect(state.accessToken, isNull); + expect(state.refreshToken, isNull); + expect(state.expiresAt, isNull); + expect(state.userId, isNull); + expect(state.userName, isNull); + expect(state.userEmail, isNull); + expect(state.roles, isEmpty); + expect(state.preferences, isNull); + }); + }); + + group('authenticated state', () { + test('stores all user data', () { + final expiresAt = DateTime.now().add(const Duration(hours: 1)); + const preferences = UserPreferences( + theme: 'dark', + defaultRoom: 'kitchen', + ); + + final state = AuthState( + isAuthenticated: true, + accessToken: 'access_token', + refreshToken: 'refresh_token', + expiresAt: expiresAt, + userId: 'user-123', + authentikId: 'authentik-456', + userName: 'Test User', + userEmail: 'test@example.com', + avatarUrl: 'https://example.com/avatar.jpg', + roles: const [ + Role( + id: '1', + name: 'admin.general:admin', + domain: Domain.admin, + category: 'general', + action: Action.admin, + ), + ], + preferences: preferences, + ); + + expect(state.isAuthenticated, isTrue); + expect(state.accessToken, equals('access_token')); + expect(state.refreshToken, equals('refresh_token')); + expect(state.expiresAt, equals(expiresAt)); + expect(state.userId, equals('user-123')); + expect(state.authentikId, equals('authentik-456')); + expect(state.userName, equals('Test User')); + expect(state.userEmail, equals('test@example.com')); + expect(state.avatarUrl, equals('https://example.com/avatar.jpg')); + expect(state.roles.length, equals(1)); + expect(state.preferences?.theme, equals('dark')); + }); + }); + + group('isTokenExpired', () { + test('returns true when expiresAt is null', () { + const state = AuthState(isAuthenticated: true); + + expect(state.isTokenExpired, isTrue); + }); + + test('returns true when token is expired', () { + final expiredTime = DateTime.now().subtract(const Duration(hours: 1)); + final state = AuthState( + isAuthenticated: true, + expiresAt: expiredTime, + ); + + expect(state.isTokenExpired, isTrue); + }); + + test('returns true when token expires within 1 minute', () { + final soonExpires = DateTime.now().add(const Duration(seconds: 30)); + final state = AuthState( + isAuthenticated: true, + expiresAt: soonExpires, + ); + + expect(state.isTokenExpired, isTrue); + }); + + test('returns false when token is valid', () { + final futureExpires = DateTime.now().add(const Duration(hours: 1)); + final state = AuthState( + isAuthenticated: true, + expiresAt: futureExpires, + ); + + expect(state.isTokenExpired, isFalse); + }); + }); + + group('hasPermission', () { + test('returns true when role grants permission', () { + const state = AuthState( + isAuthenticated: true, + roles: [ + Role( + id: '1', + name: 'control-room.general:admin', + domain: Domain.controlRoom, + category: 'general', + action: Action.admin, + ), + ], + ); + + expect(state.hasPermission(Domain.controlRoom, Action.admin), isTrue); + expect(state.hasPermission(Domain.controlRoom, Action.viewer), isTrue); + }); + + test('returns false when no role grants permission', () { + const state = AuthState( + isAuthenticated: true, + roles: [ + Role( + id: '1', + name: 'media.general:viewer', + domain: Domain.media, + category: 'general', + action: Action.viewer, + ), + ], + ); + + expect(state.hasPermission(Domain.controlRoom, Action.viewer), isFalse); + expect(state.hasPermission(Domain.media, Action.admin), isFalse); + }); + + test('respects category parameter', () { + const state = AuthState( + isAuthenticated: true, + roles: [ + Role( + id: '1', + name: 'control-room.servers:admin', + domain: Domain.controlRoom, + category: 'servers', + action: Action.admin, + ), + ], + ); + + expect( + state.hasPermission( + Domain.controlRoom, + Action.admin, + category: 'servers', + ), + isTrue, + ); + expect( + state.hasPermission( + Domain.controlRoom, + Action.admin, + category: 'general', + ), + isFalse, + ); + }); + + test('returns false for empty roles list', () { + const state = AuthState(isAuthenticated: true, roles: []); + + expect(state.hasPermission(Domain.controlRoom, Action.viewer), isFalse); + }); + }); + + group('isGlobalAdmin', () { + test('returns true when user has admin.general:admin role', () { + const state = AuthState( + isAuthenticated: true, + roles: [ + Role( + id: '1', + name: 'admin.general:admin', + domain: Domain.admin, + category: 'general', + action: Action.admin, + ), + ], + ); + + expect(state.isGlobalAdmin, isTrue); + }); + + test('returns false when user lacks admin role', () { + const state = AuthState( + isAuthenticated: true, + roles: [ + Role( + id: '1', + name: 'control-room.general:admin', + domain: Domain.controlRoom, + category: 'general', + action: Action.admin, + ), + ], + ); + + expect(state.isGlobalAdmin, isFalse); + }); + + test('returns false for empty roles list', () { + const state = AuthState(isAuthenticated: true, roles: []); + + expect(state.isGlobalAdmin, isFalse); + }); + }); + + group('copyWith', () { + test('creates a copy with modified values', () { + const original = AuthState( + isAuthenticated: true, + accessToken: 'old_token', + userName: 'Original User', + ); + + final updated = original.copyWith( + accessToken: 'new_token', + userName: 'Updated User', + ); + + // Original unchanged + expect(original.accessToken, equals('old_token')); + expect(original.userName, equals('Original User')); + + // Updated has new values + expect(updated.accessToken, equals('new_token')); + expect(updated.userName, equals('Updated User')); + + // Preserved unchanged values + expect(updated.isAuthenticated, equals(original.isAuthenticated)); + }); + }); + + group('equality', () { + test('two identical states are equal', () { + const state1 = AuthState( + isAuthenticated: true, + userId: 'user-123', + userName: 'Test User', + ); + const state2 = AuthState( + isAuthenticated: true, + userId: 'user-123', + userName: 'Test User', + ); + + expect(state1, equals(state2)); + }); + + test('different states are not equal', () { + const state1 = AuthState( + isAuthenticated: true, + userId: 'user-123', + ); + const state2 = AuthState( + isAuthenticated: true, + userId: 'user-456', + ); + + expect(state1, isNot(equals(state2))); + }); + }); + }); +} diff --git a/test/core/auth/permission_gate_test.dart b/test/core/auth/permission_gate_test.dart new file mode 100644 index 0000000..631eed7 --- /dev/null +++ b/test/core/auth/permission_gate_test.dart @@ -0,0 +1,172 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tatlock_ui/core/auth/auth_state.dart'; +import 'package:tatlock_ui/core/auth/permissions.dart'; + +// Note: PermissionGate widget tests require complex provider mocking. +// These unit tests verify the permission logic that PermissionGate relies on. +// Widget integration tests should be done with a running app or container testing. + +void main() { + group('PermissionGate logic', () { + test('hasPermission returns true when role grants permission', () { + final state = AuthState( + isAuthenticated: true, + roles: const [ + Role( + id: '1', + name: 'control-room.general:admin', + domain: Domain.controlRoom, + category: 'general', + action: Action.admin, + ), + ], + ); + + expect(state.hasPermission(Domain.controlRoom, Action.admin), isTrue); + expect(state.hasPermission(Domain.controlRoom, Action.viewer), isTrue); + }); + + test('hasPermission returns false when role does not grant permission', () { + final state = AuthState( + isAuthenticated: true, + roles: const [ + Role( + id: '1', + name: 'media.general:viewer', + domain: Domain.media, + category: 'general', + action: Action.viewer, + ), + ], + ); + + expect(state.hasPermission(Domain.controlRoom, Action.admin), isFalse); + expect(state.hasPermission(Domain.media, Action.admin), isFalse); + }); + + test('hasPermission returns false when not authenticated', () { + const state = AuthState(isAuthenticated: false); + + expect(state.hasPermission(Domain.controlRoom, Action.viewer), isFalse); + }); + + test('hasPermission respects category', () { + final state = AuthState( + isAuthenticated: true, + roles: const [ + Role( + id: '1', + name: 'control-room.servers:admin', + domain: Domain.controlRoom, + category: 'servers', + action: Action.admin, + ), + ], + ); + + expect( + state.hasPermission(Domain.controlRoom, Action.admin, category: 'servers'), + isTrue, + ); + expect( + state.hasPermission(Domain.controlRoom, Action.admin, category: 'general'), + isFalse, + ); + }); + + test('isGlobalAdmin returns true for admin.general:admin role', () { + final state = AuthState( + isAuthenticated: true, + roles: const [ + Role( + id: '1', + name: 'admin.general:admin', + domain: Domain.admin, + category: 'general', + action: Action.admin, + ), + ], + ); + + expect(state.isGlobalAdmin, isTrue); + }); + + test('isGlobalAdmin returns false for non-admin roles', () { + final state = AuthState( + isAuthenticated: true, + roles: const [ + Role( + id: '1', + name: 'control-room.general:admin', + domain: Domain.controlRoom, + category: 'general', + action: Action.admin, + ), + ], + ); + + expect(state.isGlobalAdmin, isFalse); + }); + + test('global admin has access to all domains', () { + final state = AuthState( + isAuthenticated: true, + roles: const [ + Role( + id: '1', + name: 'admin.general:admin', + domain: Domain.admin, + category: 'general', + action: Action.admin, + ), + ], + ); + + // Global admin should have access to everything + expect(state.hasPermission(Domain.controlRoom, Action.admin), isTrue); + expect(state.hasPermission(Domain.media, Action.editor), isTrue); + expect(state.hasPermission(Domain.library, Action.viewer), isTrue); + expect( + state.hasPermission(Domain.documents, Action.user, category: 'custom'), + isTrue, + ); + }); + + test('empty roles list denies all permissions', () { + const state = AuthState(isAuthenticated: true, roles: []); + + expect(state.hasPermission(Domain.controlRoom, Action.viewer), isFalse); + expect(state.isGlobalAdmin, isFalse); + }); + + test('multiple roles are evaluated correctly', () { + final state = AuthState( + isAuthenticated: true, + roles: const [ + Role( + id: '1', + name: 'media.general:viewer', + domain: Domain.media, + category: 'general', + action: Action.viewer, + ), + Role( + id: '2', + name: 'control-room.general:admin', + domain: Domain.controlRoom, + category: 'general', + action: Action.admin, + ), + ], + ); + + // Should have permissions from both roles + expect(state.hasPermission(Domain.media, Action.viewer), isTrue); + expect(state.hasPermission(Domain.controlRoom, Action.admin), isTrue); + + // But not permissions not granted by any role + expect(state.hasPermission(Domain.media, Action.admin), isFalse); + expect(state.hasPermission(Domain.library, Action.viewer), isFalse); + }); + }); +} diff --git a/test/core/auth/permissions_test.dart b/test/core/auth/permissions_test.dart new file mode 100644 index 0000000..ce184a5 --- /dev/null +++ b/test/core/auth/permissions_test.dart @@ -0,0 +1,283 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tatlock_ui/core/auth/permissions.dart'; + +void main() { + group('Domain', () { + group('fromString', () { + test('parses valid domain strings', () { + expect(Domain.fromString('control-room'), equals(Domain.controlRoom)); + expect(Domain.fromString('library'), equals(Domain.library)); + expect(Domain.fromString('media'), equals(Domain.media)); + expect(Domain.fromString('ai'), equals(Domain.ai)); + expect(Domain.fromString('housekeeper'), equals(Domain.housekeeper)); + expect(Domain.fromString('developer'), equals(Domain.developer)); + expect(Domain.fromString('documents'), equals(Domain.documents)); + expect(Domain.fromString('gaming'), equals(Domain.gaming)); + expect(Domain.fromString('admin'), equals(Domain.admin)); + }); + + test('returns null for invalid domain', () { + expect(Domain.fromString('invalid'), isNull); + expect(Domain.fromString(''), isNull); + expect(Domain.fromString('CONTROL-ROOM'), isNull); + }); + }); + + test('value returns correct string', () { + expect(Domain.controlRoom.value, equals('control-room')); + expect(Domain.library.value, equals('library')); + expect(Domain.admin.value, equals('admin')); + }); + }); + + group('Action', () { + group('fromString', () { + test('parses valid action strings', () { + expect(Action.fromString('viewer'), equals(Action.viewer)); + expect(Action.fromString('user'), equals(Action.user)); + expect(Action.fromString('editor'), equals(Action.editor)); + expect(Action.fromString('admin'), equals(Action.admin)); + }); + + test('returns null for invalid action', () { + expect(Action.fromString('invalid'), isNull); + expect(Action.fromString(''), isNull); + expect(Action.fromString('ADMIN'), isNull); + }); + }); + + group('level', () { + test('has correct hierarchy levels', () { + expect(Action.viewer.level, equals(1)); + expect(Action.user.level, equals(2)); + expect(Action.editor.level, equals(3)); + expect(Action.admin.level, equals(4)); + }); + + test('levels are ordered correctly', () { + expect(Action.viewer.level, lessThan(Action.user.level)); + expect(Action.user.level, lessThan(Action.editor.level)); + expect(Action.editor.level, lessThan(Action.admin.level)); + }); + }); + + group('grants', () { + test('admin grants all actions', () { + expect(Action.admin.grants(Action.admin), isTrue); + expect(Action.admin.grants(Action.editor), isTrue); + expect(Action.admin.grants(Action.user), isTrue); + expect(Action.admin.grants(Action.viewer), isTrue); + }); + + test('editor grants editor and below', () { + expect(Action.editor.grants(Action.admin), isFalse); + expect(Action.editor.grants(Action.editor), isTrue); + expect(Action.editor.grants(Action.user), isTrue); + expect(Action.editor.grants(Action.viewer), isTrue); + }); + + test('user grants user and below', () { + expect(Action.user.grants(Action.admin), isFalse); + expect(Action.user.grants(Action.editor), isFalse); + expect(Action.user.grants(Action.user), isTrue); + expect(Action.user.grants(Action.viewer), isTrue); + }); + + test('viewer only grants viewer', () { + expect(Action.viewer.grants(Action.admin), isFalse); + expect(Action.viewer.grants(Action.editor), isFalse); + expect(Action.viewer.grants(Action.user), isFalse); + expect(Action.viewer.grants(Action.viewer), isTrue); + }); + }); + }); + + group('Role', () { + group('grants', () { + test('grants permission for matching domain and category', () { + const role = Role( + id: '1', + name: 'control-room.general:admin', + domain: Domain.controlRoom, + category: 'general', + action: Action.admin, + ); + + expect(role.grants(Domain.controlRoom, Action.admin), isTrue); + expect(role.grants(Domain.controlRoom, Action.editor), isTrue); + expect(role.grants(Domain.controlRoom, Action.user), isTrue); + expect(role.grants(Domain.controlRoom, Action.viewer), isTrue); + }); + + test('denies permission for different domain', () { + const role = Role( + id: '1', + name: 'control-room.general:admin', + domain: Domain.controlRoom, + category: 'general', + action: Action.admin, + ); + + expect(role.grants(Domain.media, Action.viewer), isFalse); + expect(role.grants(Domain.library, Action.viewer), isFalse); + }); + + test('denies permission for different category', () { + const role = Role( + id: '1', + name: 'control-room.servers:admin', + domain: Domain.controlRoom, + category: 'servers', + action: Action.admin, + ); + + expect( + role.grants(Domain.controlRoom, Action.admin, category: 'general'), + isFalse, + ); + expect( + role.grants(Domain.controlRoom, Action.admin, category: 'servers'), + isTrue, + ); + }); + + test('global admin grants all permissions', () { + const globalAdmin = Role( + id: '1', + name: 'admin.general:admin', + domain: Domain.admin, + category: 'general', + action: Action.admin, + ); + + // Should grant any domain, category, action + expect(globalAdmin.grants(Domain.controlRoom, Action.admin), isTrue); + expect(globalAdmin.grants(Domain.media, Action.editor), isTrue); + expect(globalAdmin.grants(Domain.library, Action.viewer), isTrue); + expect( + globalAdmin.grants(Domain.documents, Action.user, category: 'specific'), + isTrue, + ); + }); + + test('non-global admin role does not grant everything', () { + const domainAdmin = Role( + id: '1', + name: 'admin.specific:admin', + domain: Domain.admin, + category: 'specific', // Not 'general' + action: Action.admin, + ); + + // Should not grant arbitrary permissions + expect(domainAdmin.grants(Domain.controlRoom, Action.viewer), isFalse); + }); + }); + }); + + group('RoleListPermissions extension', () { + final roles = [ + const Role( + id: '1', + name: 'control-room.general:admin', + domain: Domain.controlRoom, + category: 'general', + action: Action.admin, + ), + const Role( + id: '2', + name: 'media.general:viewer', + domain: Domain.media, + category: 'general', + action: Action.viewer, + ), + ]; + + group('hasPermission', () { + test('returns true when any role grants permission', () { + expect( + roles.hasPermission(Domain.controlRoom, Action.admin), + isTrue, + ); + expect( + roles.hasPermission(Domain.controlRoom, Action.viewer), + isTrue, + ); + expect( + roles.hasPermission(Domain.media, Action.viewer), + isTrue, + ); + }); + + test('returns false when no role grants permission', () { + expect( + roles.hasPermission(Domain.media, Action.editor), + isFalse, + ); + expect( + roles.hasPermission(Domain.library, Action.viewer), + isFalse, + ); + }); + + test('returns false for empty role list', () { + expect( + [].hasPermission(Domain.controlRoom, Action.viewer), + isFalse, + ); + }); + }); + + group('isGlobalAdmin', () { + test('returns true when global admin role present', () { + final adminRoles = [ + const Role( + id: '1', + name: 'admin.general:admin', + domain: Domain.admin, + category: 'general', + action: Action.admin, + ), + ]; + + expect(adminRoles.isGlobalAdmin, isTrue); + }); + + test('returns false when no global admin role', () { + expect(roles.isGlobalAdmin, isFalse); + }); + + test('returns false for empty role list', () { + expect([].isGlobalAdmin, isFalse); + }); + + test('returns false for admin domain with non-general category', () { + final limitedAdmin = [ + const Role( + id: '1', + name: 'admin.specific:admin', + domain: Domain.admin, + category: 'specific', + action: Action.admin, + ), + ]; + + expect(limitedAdmin.isGlobalAdmin, isFalse); + }); + + test('returns false for admin domain with non-admin action', () { + final viewerAdmin = [ + const Role( + id: '1', + name: 'admin.general:viewer', + domain: Domain.admin, + category: 'general', + action: Action.viewer, + ), + ]; + + expect(viewerAdmin.isGlobalAdmin, isFalse); + }); + }); + }); +} diff --git a/test/core/auth/user_preferences_test.dart b/test/core/auth/user_preferences_test.dart new file mode 100644 index 0000000..0a721b4 --- /dev/null +++ b/test/core/auth/user_preferences_test.dart @@ -0,0 +1,147 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tatlock_ui/core/auth/user_preferences.dart'; + +void main() { + group('UserPreferences', () { + group('default constructor', () { + test('creates with default values', () { + const prefs = UserPreferences(); + + expect(prefs.theme, equals('system')); + expect(prefs.defaultRoom, equals('front-hall')); + expect(prefs.preferencesJson, isEmpty); + }); + }); + + group('custom constructor', () { + test('creates with custom values', () { + const prefs = UserPreferences( + theme: 'dark', + defaultRoom: 'kitchen', + preferencesJson: {'sidebar_collapsed': true, 'font_size': 14}, + ); + + expect(prefs.theme, equals('dark')); + expect(prefs.defaultRoom, equals('kitchen')); + expect(prefs.preferencesJson['sidebar_collapsed'], isTrue); + expect(prefs.preferencesJson['font_size'], equals(14)); + }); + }); + + group('fromJson', () { + test('deserializes from JSON', () { + final json = { + 'theme': 'light', + 'defaultRoom': 'living-room', + 'preferencesJson': {'key': 'value'}, + }; + + final prefs = UserPreferences.fromJson(json); + + expect(prefs.theme, equals('light')); + expect(prefs.defaultRoom, equals('living-room')); + expect(prefs.preferencesJson['key'], equals('value')); + }); + + test('uses defaults for missing fields', () { + final json = {}; + + final prefs = UserPreferences.fromJson(json); + + expect(prefs.theme, equals('system')); + expect(prefs.defaultRoom, equals('front-hall')); + expect(prefs.preferencesJson, isEmpty); + }); + + test('handles partial JSON', () { + final json = {'theme': 'dark'}; + + final prefs = UserPreferences.fromJson(json); + + expect(prefs.theme, equals('dark')); + expect(prefs.defaultRoom, equals('front-hall')); + }); + }); + + group('toJson', () { + test('serializes to JSON', () { + const prefs = UserPreferences( + theme: 'dark', + defaultRoom: 'office', + preferencesJson: {'notifications': true}, + ); + + final json = prefs.toJson(); + + expect(json['theme'], equals('dark')); + expect(json['defaultRoom'], equals('office')); + expect(json['preferencesJson']['notifications'], isTrue); + }); + + test('round-trip serialization preserves data', () { + const original = UserPreferences( + theme: 'light', + defaultRoom: 'bedroom', + preferencesJson: {'compact_mode': false}, + ); + + final json = original.toJson(); + final restored = UserPreferences.fromJson(json); + + expect(restored.theme, equals(original.theme)); + expect(restored.defaultRoom, equals(original.defaultRoom)); + expect( + restored.preferencesJson['compact_mode'], + equals(original.preferencesJson['compact_mode']), + ); + }); + }); + + group('copyWith', () { + test('creates a copy with modified values', () { + const original = UserPreferences( + theme: 'system', + defaultRoom: 'front-hall', + ); + + final updated = original.copyWith(theme: 'dark'); + + // Original unchanged + expect(original.theme, equals('system')); + + // Updated has new value + expect(updated.theme, equals('dark')); + + // Preserved unchanged values + expect(updated.defaultRoom, equals(original.defaultRoom)); + }); + }); + + group('equality', () { + test('two identical preferences are equal', () { + const prefs1 = UserPreferences(theme: 'dark', defaultRoom: 'office'); + const prefs2 = UserPreferences(theme: 'dark', defaultRoom: 'office'); + + expect(prefs1, equals(prefs2)); + }); + + test('different preferences are not equal', () { + const prefs1 = UserPreferences(theme: 'dark'); + const prefs2 = UserPreferences(theme: 'light'); + + expect(prefs1, isNot(equals(prefs2))); + }); + }); + + group('theme validation', () { + test('accepts valid theme values', () { + const themes = ['system', 'light', 'dark']; + + for (final theme in themes) { + final prefs = UserPreferences(theme: theme); + expect(prefs.theme, equals(theme)); + } + }); + }); + }); +}