Merge pull request #1549 from aliasvault/1548-add-support-to-block-specific-unsupported-client-versions-in-server-api
Add support to block specific unsupported client versions in server API
This commit is contained in:
@@ -109,6 +109,12 @@ export const useVaultSync = (): { syncVault: (options?: VaultSyncOptions) => Pro
|
||||
|
||||
const statusError = webApi.validateStatusResponse(statusResponse);
|
||||
if (statusError) {
|
||||
// Version compatibility errors require logout
|
||||
if (statusError === 'clientVersionNotSupported' || statusError === 'serverVersionNotSupported') {
|
||||
await app.logout(t('common.errors.' + statusError));
|
||||
return false;
|
||||
}
|
||||
// Other errors just show the error
|
||||
onError?.(t('common.errors.' + statusError));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ import type { MobileLoginResult } from '@/utils/types/messaging/MobileLoginResul
|
||||
|
||||
import { storage } from '#imports';
|
||||
|
||||
/** Track if username prefill has been attempted (only do it once on mount) */
|
||||
let usernamePrefillAttempted = false;
|
||||
|
||||
/**
|
||||
* Login page
|
||||
*/
|
||||
@@ -168,6 +171,9 @@ const Login: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset prefill flag so next logout will prefill again
|
||||
usernamePrefillAttempted = false;
|
||||
|
||||
// Navigate to reinitialize page which will take care of the proper redirect.
|
||||
navigate('/reinitialize', { replace: true });
|
||||
|
||||
@@ -188,10 +194,16 @@ const Login: React.FC = () => {
|
||||
}
|
||||
setClientUrl(clientUrl);
|
||||
|
||||
// Check for saved username (from forced logout) and prefill
|
||||
const savedUsername = await storage.getItem('local:username') as string | null;
|
||||
if (savedUsername) {
|
||||
setCredentials(prev => ({ ...prev, username: savedUsername }));
|
||||
/*
|
||||
* Check for saved username (from forced logout) and prefill once on mount
|
||||
* If user clears it, don't repopulate
|
||||
*/
|
||||
if (!usernamePrefillAttempted) {
|
||||
usernamePrefillAttempted = true;
|
||||
const savedUsername = await storage.getItem('local:username') as string | null;
|
||||
if (savedUsername) {
|
||||
setCredentials(prev => ({ ...prev, username: savedUsername }));
|
||||
}
|
||||
}
|
||||
|
||||
setIsInitialLoading(false);
|
||||
|
||||
@@ -623,6 +623,8 @@ class VaultSync(
|
||||
is VaultSyncError.SessionExpired,
|
||||
is VaultSyncError.AuthenticationFailed,
|
||||
is VaultSyncError.PasswordChanged,
|
||||
is VaultSyncError.ClientVersionNotSupported,
|
||||
is VaultSyncError.ServerVersionNotSupported,
|
||||
-> {
|
||||
VaultSyncResult(
|
||||
success = false,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { StyleSheet, View, Text, SafeAreaView, TextInput, ActivityIndicator, Animated, ScrollView, KeyboardAvoidingView, Platform, Dimensions } from 'react-native';
|
||||
|
||||
import { useApiUrl } from '@/utils/ApiUrlUtility';
|
||||
@@ -41,6 +41,9 @@ export default function LoginScreen() : React.ReactNode {
|
||||
const [fadeAnim] = useState(new Animated.Value(0));
|
||||
const { loadApiUrl, getDisplayUrl } = useApiUrl();
|
||||
|
||||
// Track if username prefill has been attempted (only do it once on mount)
|
||||
const usernamePrefillAttemptedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
@@ -52,19 +55,18 @@ export default function LoginScreen() : React.ReactNode {
|
||||
/**
|
||||
* Check for saved username (from forced logout) and prefill the username field.
|
||||
* This enables users to easily re-login after a forced logout.
|
||||
* Only prefill if the username field is empty (user hasn't started typing).
|
||||
* Only prefill once on mount - if user clears it, don't repopulate.
|
||||
*/
|
||||
const loadSavedUsername = async () : Promise<void> => {
|
||||
if (usernamePrefillAttemptedRef.current) {
|
||||
return;
|
||||
}
|
||||
usernamePrefillAttemptedRef.current = true;
|
||||
|
||||
try {
|
||||
const savedUsername = await NativeVaultManager.getUsername();
|
||||
if (savedUsername) {
|
||||
setCredentials(prev => {
|
||||
// Only prefill if username is empty - don't overwrite user input
|
||||
if (prev.username === '') {
|
||||
return { ...prev, username: savedUsername };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
setCredentials(prev => ({ ...prev, username: savedUsername }));
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors - username prefill is optional
|
||||
@@ -222,14 +224,17 @@ export default function LoginScreen() : React.ReactNode {
|
||||
}
|
||||
}
|
||||
|
||||
let checkSuccess = true;
|
||||
let upgradeRequired = false;
|
||||
|
||||
/*
|
||||
* Sync vault from server (downloads, stores, and validates compatibility)
|
||||
* This will handle the forced logout recovery check in case our local vault is dirty
|
||||
* or is ahead of server in case of RPO event.
|
||||
*
|
||||
* Critical errors (auth, version) are handled internally via app.logout(message)
|
||||
* which shows a native alert. We check the return value to know if sync succeeded.
|
||||
*/
|
||||
await syncVault({
|
||||
const syncSuccess = await syncVault({
|
||||
/**
|
||||
* Update login status during sync.
|
||||
*/
|
||||
@@ -237,21 +242,18 @@ export default function LoginScreen() : React.ReactNode {
|
||||
setLoginStatus(status);
|
||||
},
|
||||
/**
|
||||
* Handle the status update.
|
||||
* Handle non-critical errors (shown via custom dialog).
|
||||
*/
|
||||
onError: (message) => {
|
||||
checkSuccess = false;
|
||||
|
||||
// Show modal with error message
|
||||
// Show modal with error message for non-critical errors
|
||||
showAlert(t('common.error'), message);
|
||||
// Error will trigger logout through the sync process
|
||||
setIsLoading(false);
|
||||
},
|
||||
/**
|
||||
* On upgrade required.
|
||||
*/
|
||||
onUpgradeRequired: async () : Promise<void> => {
|
||||
checkSuccess = false;
|
||||
upgradeRequired = true;
|
||||
|
||||
// Still login to ensure the user is logged in.
|
||||
await authContext.login();
|
||||
@@ -262,8 +264,12 @@ export default function LoginScreen() : React.ReactNode {
|
||||
},
|
||||
});
|
||||
|
||||
if (!checkSuccess) {
|
||||
// If the syncvault checks have failed, we can't continue with the login process.
|
||||
if (!syncSuccess || upgradeRequired) {
|
||||
/*
|
||||
* Sync failed or upgrade required - don't continue with login
|
||||
* Critical errors already showed alert via app.logout()
|
||||
*/
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -275,6 +281,9 @@ export default function LoginScreen() : React.ReactNode {
|
||||
|
||||
await authContext.login();
|
||||
|
||||
// Reset prefill flag so next logout will prefill again
|
||||
usernamePrefillAttemptedRef.current = false;
|
||||
|
||||
authContext.setOfflineMode(false);
|
||||
setTwoFactorRequired(false);
|
||||
setTwoFactorCode('');
|
||||
|
||||
@@ -4,10 +4,11 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { NavigationContainerRef, ParamListBase } from '@react-navigation/native';
|
||||
import * as LocalAuthentication from 'expo-local-authentication';
|
||||
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Alert, Platform } from 'react-native';
|
||||
import { Platform } from 'react-native';
|
||||
import EncryptionUtility from '@/utils/EncryptionUtility';
|
||||
|
||||
import { useDb } from '@/context/DbContext';
|
||||
import { dialogEventEmitter } from '@/events/DialogEventEmitter';
|
||||
import NativeVaultManager from '@/specs/NativeVaultManager';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
@@ -221,11 +222,9 @@ export const AuthProvider: React.FC<{
|
||||
await AsyncStorage.multiRemove(['accessToken', 'refreshToken', 'authMethods']);
|
||||
|
||||
if (errorMessage) {
|
||||
Alert.alert(
|
||||
i18n.t('common.error'),
|
||||
errorMessage,
|
||||
[{ text: i18n.t('common.ok'), style: 'default' }]
|
||||
);
|
||||
// Use event emitter to show dialog via DialogContext
|
||||
// This allows Android to use the custom styled dialog
|
||||
dialogEventEmitter.emitAlert(i18n.t('common.error'), errorMessage);
|
||||
}
|
||||
|
||||
setIsLoggedIn(false);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { Alert, Platform } from 'react-native';
|
||||
|
||||
import { ConfirmDialog, type IConfirmDialogButton } from '@/components/common/ConfirmDialog';
|
||||
import { dialogEventEmitter } from '@/events/DialogEventEmitter';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
interface DialogConfig {
|
||||
@@ -82,10 +83,52 @@ interface DialogProviderProps {
|
||||
export function DialogProvider({ children }: DialogProviderProps): React.ReactNode {
|
||||
const [dialogConfig, setDialogConfig] = useState<DialogConfig | null>(null);
|
||||
|
||||
// Use a ref to store pending dialog config that survives re-renders/navigation
|
||||
const pendingDialogRef = useRef<DialogConfig | null>(null);
|
||||
|
||||
// Check for pending dialogs on every render
|
||||
useEffect(() => {
|
||||
if (pendingDialogRef.current && !dialogConfig) {
|
||||
setDialogConfig(pendingDialogRef.current);
|
||||
pendingDialogRef.current = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to dialog events from outside React (e.g., AuthContext logout)
|
||||
useEffect(() => {
|
||||
const unsubscribe = dialogEventEmitter.subscribe((title, message) => {
|
||||
// On iOS, use native Alert
|
||||
if (Platform.OS === 'ios') {
|
||||
Alert.alert(title, message, [{ text: 'OK', style: 'default' }]);
|
||||
return;
|
||||
}
|
||||
|
||||
// On Android, use custom dialog with ref persistence
|
||||
const config: DialogConfig = {
|
||||
title,
|
||||
message,
|
||||
buttons: [{
|
||||
text: 'OK',
|
||||
style: 'default',
|
||||
onPress: (): void => {
|
||||
pendingDialogRef.current = null;
|
||||
setDialogConfig(null);
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
pendingDialogRef.current = config;
|
||||
setDialogConfig(config);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Hide the dialog.
|
||||
*/
|
||||
const hideDialog = useCallback((): void => {
|
||||
pendingDialogRef.current = null;
|
||||
setDialogConfig(null);
|
||||
}, []);
|
||||
|
||||
@@ -93,7 +136,7 @@ export function DialogProvider({ children }: DialogProviderProps): React.ReactNo
|
||||
* Show a simple alert with an OK button.
|
||||
*/
|
||||
const showAlert = useCallback((title: string, message: string, onOk?: () => void): void => {
|
||||
// On iOS, use native Alert for simple alerts too
|
||||
// On iOS, use native Alert
|
||||
if (Platform.OS === 'ios') {
|
||||
Alert.alert(title, message, [{
|
||||
text: i18n.t('common.ok'),
|
||||
@@ -103,7 +146,8 @@ export function DialogProvider({ children }: DialogProviderProps): React.ReactNo
|
||||
return;
|
||||
}
|
||||
|
||||
setDialogConfig({
|
||||
// On Android, use custom dialog with ref persistence
|
||||
const config: DialogConfig = {
|
||||
title,
|
||||
message,
|
||||
buttons: [{
|
||||
@@ -111,10 +155,15 @@ export function DialogProvider({ children }: DialogProviderProps): React.ReactNo
|
||||
style: 'default',
|
||||
onPress: (): void => {
|
||||
onOk?.();
|
||||
pendingDialogRef.current = null;
|
||||
setDialogConfig(null);
|
||||
},
|
||||
}],
|
||||
});
|
||||
};
|
||||
|
||||
// Store in ref so it persists through navigation/re-renders
|
||||
pendingDialogRef.current = config;
|
||||
setDialogConfig(config);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Simple event emitter for showing dialogs from outside React components.
|
||||
* This allows AuthContext to trigger dialogs in DialogContext without direct coupling.
|
||||
*/
|
||||
|
||||
type AlertListener = (title: string, message: string) => void;
|
||||
|
||||
class DialogEventEmitter {
|
||||
private listeners: AlertListener[] = [];
|
||||
|
||||
/**
|
||||
* Subscribe to alert events.
|
||||
* @returns Unsubscribe function
|
||||
*/
|
||||
subscribe(listener: AlertListener): () => void {
|
||||
this.listeners.push(listener);
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter(l => l !== listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an alert event to all listeners.
|
||||
*/
|
||||
emitAlert(title: string, message: string): void {
|
||||
this.listeners.forEach(listener => listener(title, message));
|
||||
}
|
||||
}
|
||||
|
||||
export const dialogEventEmitter = new DialogEventEmitter();
|
||||
@@ -104,6 +104,7 @@ export const useVaultSync = (): {
|
||||
|
||||
if (result.wasOffline) {
|
||||
await dbContext.setIsOffline(true);
|
||||
console.log('[useVaultSync] Set offline mode');
|
||||
onOffline?.();
|
||||
// Return true to continue with local vault
|
||||
return true;
|
||||
@@ -155,7 +156,8 @@ export const useVaultSync = (): {
|
||||
console.warn('Vault sync: Failed to register credential identities:', error);
|
||||
}
|
||||
|
||||
return hasNewVault;
|
||||
// Return true for successful sync (regardless of whether vault changed)
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof VaultVersionIncompatibleError) {
|
||||
await app.logout(t(err.message));
|
||||
@@ -224,6 +226,10 @@ function getVaultSyncErrorCodeFromString(error: string): VaultSyncErrorCode | nu
|
||||
|
||||
/**
|
||||
* Handle sync errors by mapping error codes to appropriate actions.
|
||||
*
|
||||
* For critical errors requiring logout (auth, version), we ALWAYS use app.logout(message)
|
||||
* which shows a native Alert.alert that persists through navigation on both platforms.
|
||||
* The onError callback is only used for non-critical errors that don't require logout.
|
||||
*/
|
||||
async function handleSyncError(
|
||||
err: unknown,
|
||||
@@ -235,23 +241,26 @@ async function handleSyncError(
|
||||
onOffline?: () => void
|
||||
): Promise<boolean> {
|
||||
switch (errorCode) {
|
||||
// Authentication errors - logout with message (shows native alert)
|
||||
case VaultSyncErrorCode.SESSION_EXPIRED:
|
||||
case VaultSyncErrorCode.AUTHENTICATION_FAILED:
|
||||
await app.logout('Your session has expired. Please login again.');
|
||||
await app.logout(t('auth.errors.sessionExpired'));
|
||||
return false;
|
||||
|
||||
case VaultSyncErrorCode.PASSWORD_CHANGED:
|
||||
await app.logout(t('vault.errors.passwordChanged'));
|
||||
return false;
|
||||
|
||||
// Version compatibility errors - logout with message (shows native alert)
|
||||
case VaultSyncErrorCode.CLIENT_VERSION_NOT_SUPPORTED:
|
||||
onError?.(t('vault.errors.versionNotSupported'));
|
||||
await app.logout(t('vault.errors.versionNotSupported'));
|
||||
return false;
|
||||
|
||||
case VaultSyncErrorCode.SERVER_VERSION_NOT_SUPPORTED:
|
||||
await app.logout(t('vault.errors.serverVersionNotSupported'));
|
||||
return false;
|
||||
|
||||
// Network errors - set offline mode, don't logout
|
||||
case VaultSyncErrorCode.SERVER_UNAVAILABLE:
|
||||
case VaultSyncErrorCode.NETWORK_ERROR:
|
||||
case VaultSyncErrorCode.TIMEOUT:
|
||||
@@ -260,8 +269,8 @@ async function handleSyncError(
|
||||
// Return true to continue with local vault
|
||||
return true;
|
||||
|
||||
// Unknown errors - use onError callback if provided
|
||||
default:
|
||||
// Unknown error
|
||||
const errorMessage = err instanceof Error ? err.message : t('common.errors.unknownError');
|
||||
onError?.(errorMessage);
|
||||
return false;
|
||||
|
||||
@@ -513,7 +513,8 @@ extension VaultStore {
|
||||
wasOffline: true,
|
||||
error: error.message
|
||||
)
|
||||
case .sessionExpired, .authenticationFailed, .passwordChanged:
|
||||
case .sessionExpired, .authenticationFailed, .passwordChanged,
|
||||
.clientVersionNotSupported, .serverVersionNotSupported:
|
||||
return VaultSyncResult(
|
||||
success: false,
|
||||
action: .error,
|
||||
|
||||
@@ -110,10 +110,11 @@ public class AuthController(IAliasServerDbContextFactory dbContextFactory, UserM
|
||||
|
||||
if (AppInfo.MinimumClientVersions.TryGetValue(platform, out var minimumVersion))
|
||||
{
|
||||
if (VersionHelper.IsVersionEqualOrNewer(clientVersion, minimumVersion))
|
||||
{
|
||||
clientSupported = true;
|
||||
}
|
||||
// Check if version meets minimum requirement AND is not in blocked list
|
||||
var meetsMinimum = VersionHelper.IsVersionEqualOrNewer(clientVersion, minimumVersion);
|
||||
var isBlocked = VersionHelper.IsVersionBlocked(platform, clientVersion, AppInfo.UnsupportedClientVersions);
|
||||
|
||||
clientSupported = meetsMinimum && !isBlocked;
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -59,4 +59,36 @@ public static class VersionHelper
|
||||
// Compare the versions
|
||||
return v1 >= v2;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a version is blocked for a specific platform.
|
||||
/// Checks both platform-specific blocks and global blocks (using "*" key).
|
||||
/// </summary>
|
||||
/// <param name="platform">The platform to check (e.g., "chrome", "ios").</param>
|
||||
/// <param name="version">The version to check.</param>
|
||||
/// <param name="blockedVersions">Dictionary of platform to blocked versions. Use "*" for global blocks.</param>
|
||||
/// <returns>True if the version is blocked for this platform, false otherwise.</returns>
|
||||
public static bool IsVersionBlocked(string platform, string version, IReadOnlyDictionary<string, HashSet<string>> blockedVersions)
|
||||
{
|
||||
if (string.IsNullOrEmpty(version) || blockedVersions == null || blockedVersions.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check global blocks (applies to all platforms)
|
||||
if (blockedVersions.TryGetValue("*", out var globalBlocked) && globalBlocked.Contains(version))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check platform-specific blocks
|
||||
if (!string.IsNullOrEmpty(platform) &&
|
||||
blockedVersions.TryGetValue(platform, out var platformBlocked) &&
|
||||
platformBlocked.Contains(version))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ public static class AppInfo
|
||||
/// for all clients as we are using a monorepo to build all clients from the same source code. But it's
|
||||
/// possible to override the minimum client version for a specific client if needed.
|
||||
/// </summary>
|
||||
public const string MinimumClientVersion = "0.26.1";
|
||||
public const string MinimumClientVersion = "0.12.0";
|
||||
|
||||
/// <summary>
|
||||
/// Gets a dictionary of minimum supported client versions that the WebApi supports.
|
||||
@@ -68,6 +68,24 @@ public static class AppInfo
|
||||
{ "android", MinimumClientVersion },
|
||||
}.AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// Gets a dictionary of specific client versions that are explicitly unsupported (blocked) per platform.
|
||||
/// This is useful for blocking specific versions with (critical) bugs while still allowing
|
||||
/// older versions that predate the bug to continue working.
|
||||
/// For example: if 0.26.0 has a critical bug fixed in 0.26.1, we can block only 0.26.0
|
||||
/// without affecting users on 0.25.x who may still have compatible vaults.
|
||||
/// Use "*" as the platform key to block a version across all platforms.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, HashSet<string>> UnsupportedClientVersions { get; } = new Dictionary<string, HashSet<string>>
|
||||
{
|
||||
// Block version across all platforms: "*" applies to all clients.
|
||||
{ "*", ["0.26.0"] }, // Version with vault migration bug, fixed in 0.26.1
|
||||
|
||||
// Platform-specific blocks (examples):
|
||||
// { "chrome", ["0.25.0"] },
|
||||
// { "ios", ["0.24.0", "0.24.1"] },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the build number, typically used in CI/CD pipelines.
|
||||
/// Can be overridden at build time.
|
||||
|
||||
@@ -68,4 +68,96 @@ public class VersionTests
|
||||
var version2 = "1.1.0";
|
||||
Assert.That(VersionHelper.IsVersionEqualOrNewer(version1, version2), Is.True);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that a version in the global blocked list is correctly identified as blocked.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void VersionBlockedReturnsTrueForGloballyBlockedVersion()
|
||||
{
|
||||
var blockedVersions = new Dictionary<string, HashSet<string>>
|
||||
{
|
||||
{ "*", ["0.26.0", "0.27.0"] },
|
||||
};
|
||||
|
||||
Assert.That(VersionHelper.IsVersionBlocked("chrome", "0.26.0", blockedVersions), Is.True);
|
||||
Assert.That(VersionHelper.IsVersionBlocked("ios", "0.27.0", blockedVersions), Is.True);
|
||||
Assert.That(VersionHelper.IsVersionBlocked("android", "0.26.0", blockedVersions), Is.True);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that a version in the platform-specific blocked list is correctly identified as blocked.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void VersionBlockedReturnsTrueForPlatformSpecificBlockedVersion()
|
||||
{
|
||||
var blockedVersions = new Dictionary<string, HashSet<string>>
|
||||
{
|
||||
{ "chrome", ["0.25.0"] },
|
||||
{ "ios", ["0.24.0"] },
|
||||
};
|
||||
|
||||
// Platform-specific blocks should work
|
||||
Assert.That(VersionHelper.IsVersionBlocked("chrome", "0.25.0", blockedVersions), Is.True);
|
||||
Assert.That(VersionHelper.IsVersionBlocked("ios", "0.24.0", blockedVersions), Is.True);
|
||||
|
||||
// Other platforms should not be blocked
|
||||
Assert.That(VersionHelper.IsVersionBlocked("firefox", "0.25.0", blockedVersions), Is.False);
|
||||
Assert.That(VersionHelper.IsVersionBlocked("android", "0.24.0", blockedVersions), Is.False);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that global and platform-specific blocks work together.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void VersionBlockedCombinesGlobalAndPlatformSpecific()
|
||||
{
|
||||
var blockedVersions = new Dictionary<string, HashSet<string>>
|
||||
{
|
||||
{ "*", ["0.26.0"] },
|
||||
{ "chrome", ["0.25.0"] },
|
||||
};
|
||||
|
||||
// Global block applies to all platforms for exact version
|
||||
Assert.That(VersionHelper.IsVersionBlocked("chrome", "0.26.0", blockedVersions), Is.True);
|
||||
Assert.That(VersionHelper.IsVersionBlocked("ios", "0.26.0", blockedVersions), Is.True);
|
||||
Assert.That(VersionHelper.IsVersionBlocked("ios", "0.26.1", blockedVersions), Is.False);
|
||||
|
||||
// Platform-specific block only applies to that platform
|
||||
Assert.That(VersionHelper.IsVersionBlocked("chrome", "0.25.0", blockedVersions), Is.True);
|
||||
Assert.That(VersionHelper.IsVersionBlocked("firefox", "0.25.0", blockedVersions), Is.False);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that a version not in the blocked list is correctly identified as not blocked.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void VersionBlockedReturnsFalseForNonBlockedVersion()
|
||||
{
|
||||
var blockedVersions = new Dictionary<string, HashSet<string>>
|
||||
{
|
||||
{ "*", ["0.26.0"] },
|
||||
};
|
||||
|
||||
Assert.That(VersionHelper.IsVersionBlocked("chrome", "0.25.3", blockedVersions), Is.False);
|
||||
Assert.That(VersionHelper.IsVersionBlocked("chrome", "0.26.1", blockedVersions), Is.False);
|
||||
Assert.That(VersionHelper.IsVersionBlocked("chrome", "0.27.0", blockedVersions), Is.False);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that empty or null inputs are handled correctly.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void VersionBlockedHandlesEmptyInputs()
|
||||
{
|
||||
var blockedVersions = new Dictionary<string, HashSet<string>>
|
||||
{
|
||||
{ "*", ["0.26.0"] },
|
||||
};
|
||||
|
||||
var emptyBlockedVersions = new Dictionary<string, HashSet<string>>();
|
||||
|
||||
Assert.That(VersionHelper.IsVersionBlocked("chrome", string.Empty, blockedVersions), Is.False);
|
||||
Assert.That(VersionHelper.IsVersionBlocked("chrome", "0.26.0", emptyBlockedVersions), Is.False);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user