mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-21 06:34:30 +01:00
Merge Tag v0.2.22
This commit is contained in:
18
lib/atoms.ts
18
lib/atoms.ts
@@ -115,9 +115,27 @@ export const pomodoroAtom = atom<PomodoroAtom>({
|
||||
minimized: false,
|
||||
})
|
||||
|
||||
import { prepareDataForHashing, generateCryptoHash } from '@/lib/utils';
|
||||
|
||||
export const userSelectAtom = atom<boolean>(false)
|
||||
export const aboutOpenAtom = atom<boolean>(false)
|
||||
|
||||
/**
|
||||
* Asynchronous atom that calculates a freshness token (hash) based on the current client-side data.
|
||||
* This token can be compared with a server-generated token to detect data discrepancies.
|
||||
*/
|
||||
export const clientFreshnessTokenAtom = atom(async (get) => {
|
||||
const settings = get(settingsAtom);
|
||||
const habits = get(habitsAtom);
|
||||
const coins = get(coinsAtom);
|
||||
const wishlist = get(wishlistAtom);
|
||||
const users = get(usersAtom);
|
||||
|
||||
const dataString = prepareDataForHashing(settings, habits, coins, wishlist, users);
|
||||
const hash = await generateCryptoHash(dataString);
|
||||
return hash;
|
||||
});
|
||||
|
||||
// Derived atom for completion cache
|
||||
export const completionCacheAtom = atom((get) => {
|
||||
const habits = get(habitsAtom).habits;
|
||||
|
||||
@@ -37,4 +37,4 @@ export function verifyPassword(password?: string, storedHash?: string): boolean
|
||||
const newHash = saltAndHashPassword(password, salt).split(':')[1]
|
||||
// Compare the new hash with the stored hash
|
||||
return newHash === hash
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,13 @@ import {
|
||||
serializeRRule,
|
||||
convertHumanReadableFrequencyToMachineReadable,
|
||||
convertMachineReadableFrequencyToHumanReadable,
|
||||
getUnsupportedRRuleReason
|
||||
getUnsupportedRRuleReason,
|
||||
prepareDataForHashing,
|
||||
generateCryptoHash
|
||||
} from './utils'
|
||||
import { CoinTransaction, ParsedResultType } from './types'
|
||||
import { CoinTransaction, ParsedResultType, Settings, HabitsData, CoinsData, WishlistData, UserData } from './types'
|
||||
import { DateTime } from "luxon";
|
||||
import { getDefaultSettings, getDefaultHabitsData, getDefaultCoinsData, getDefaultWishlistData, getDefaultUsersData } from './types';
|
||||
import { RRule, Weekday } from 'rrule';
|
||||
import { Habit } from '@/lib/types';
|
||||
import { INITIAL_DUE } from './constants';
|
||||
@@ -956,3 +959,96 @@ describe('convertMachineReadableFrequencyToHumanReadable', () => {
|
||||
expect(humanReadable).toBe('invalid')
|
||||
})
|
||||
})
|
||||
|
||||
describe('freshness utilities', () => {
|
||||
const mockSettings: Settings = getDefaultSettings();
|
||||
const mockHabits: HabitsData = getDefaultHabitsData();
|
||||
const mockCoins: CoinsData = getDefaultCoinsData();
|
||||
const mockWishlist: WishlistData = getDefaultWishlistData();
|
||||
const mockUsers: UserData = getDefaultUsersData();
|
||||
|
||||
// Add a user to mockUsers for more realistic testing
|
||||
mockUsers.users.push({
|
||||
id: 'user-123',
|
||||
username: 'testuser',
|
||||
isAdmin: false,
|
||||
});
|
||||
mockHabits.habits.push({
|
||||
id: 'habit-123',
|
||||
name: 'Test Habit',
|
||||
description: 'A habit for testing',
|
||||
frequency: 'FREQ=DAILY',
|
||||
coinReward: 10,
|
||||
completions: [],
|
||||
userIds: ['user-123']
|
||||
});
|
||||
|
||||
|
||||
describe('prepareDataForHashing', () => {
|
||||
test('should produce a consistent string for the same data', () => {
|
||||
const data1 = { settings: mockSettings, habits: mockHabits, coins: mockCoins, wishlist: mockWishlist, users: mockUsers };
|
||||
const data2 = { settings: mockSettings, habits: mockHabits, coins: mockCoins, wishlist: mockWishlist, users: mockUsers }; // Identical data
|
||||
|
||||
const string1 = prepareDataForHashing(data1.settings, data1.habits, data1.coins, data1.wishlist, data1.users);
|
||||
const string2 = prepareDataForHashing(data2.settings, data2.habits, data2.coins, data2.wishlist, data2.users);
|
||||
|
||||
expect(string1).toBe(string2);
|
||||
});
|
||||
|
||||
test('should produce a different string if settings data changes', () => {
|
||||
const string1 = prepareDataForHashing(mockSettings, mockHabits, mockCoins, mockWishlist, mockUsers);
|
||||
const modifiedSettings = { ...mockSettings, system: { ...mockSettings.system, timezone: 'America/Chicago' } };
|
||||
const string2 = prepareDataForHashing(modifiedSettings, mockHabits, mockCoins, mockWishlist, mockUsers);
|
||||
expect(string1).not.toBe(string2);
|
||||
});
|
||||
|
||||
test('should produce a different string if habits data changes', () => {
|
||||
const string1 = prepareDataForHashing(mockSettings, mockHabits, mockCoins, mockWishlist, mockUsers);
|
||||
const modifiedHabits = { ...mockHabits, habits: [...mockHabits.habits, { id: 'new-habit', name: 'New', description: '', frequency: 'FREQ=DAILY', coinReward: 5, completions: [] }] };
|
||||
const string2 = prepareDataForHashing(mockSettings, modifiedHabits, mockCoins, mockWishlist, mockUsers);
|
||||
expect(string1).not.toBe(string2);
|
||||
});
|
||||
|
||||
test('should handle empty data consistently', () => {
|
||||
const emptySettings = getDefaultSettings();
|
||||
const emptyHabits = getDefaultHabitsData();
|
||||
const emptyCoins = getDefaultCoinsData();
|
||||
const emptyWishlist = getDefaultWishlistData();
|
||||
const emptyUsers = getDefaultUsersData();
|
||||
|
||||
const string1 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
|
||||
const string2 = prepareDataForHashing(emptySettings, emptyHabits, emptyCoins, emptyWishlist, emptyUsers);
|
||||
expect(string1).toBe(string2);
|
||||
expect(string1).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateCryptoHash', () => {
|
||||
test('should generate a SHA-256 hex string', async () => {
|
||||
const dataString = 'test string';
|
||||
const hash = await generateCryptoHash(dataString);
|
||||
expect(hash).toMatch(/^[a-f0-9]{64}$/); // SHA-256 hex is 64 chars
|
||||
});
|
||||
|
||||
test('should generate different hashes for different strings', async () => {
|
||||
const hash1 = await generateCryptoHash('test string 1');
|
||||
const hash2 = await generateCryptoHash('test string 2');
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
test('should generate the same hash for the same string', async () => {
|
||||
const hash1 = await generateCryptoHash('consistent string');
|
||||
const hash2 = await generateCryptoHash('consistent string');
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
|
||||
// Test with a known SHA-256 value if possible, or ensure crypto.subtle.digest is available
|
||||
// For "hello world", SHA-256 is "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
|
||||
test('should generate correct hash for a known string', async () => {
|
||||
const knownString = "hello world";
|
||||
const expectedHash = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
|
||||
const actualHash = await generateCryptoHash(knownString);
|
||||
expect(actualHash).toBe(expectedHash);
|
||||
});
|
||||
});
|
||||
})
|
||||
|
||||
50
lib/utils.ts
50
lib/utils.ts
@@ -2,11 +2,12 @@ import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { DateTime, DateTimeFormatOptions } from "luxon"
|
||||
import { datetime, RRule } from 'rrule'
|
||||
import { Freq, Habit, CoinTransaction, Permission, ParsedFrequencyResult, ParsedResultType, User } from '@/lib/types'
|
||||
import { Freq, Habit, CoinTransaction, Permission, ParsedFrequencyResult, ParsedResultType, User, Settings, HabitsData, CoinsData, WishlistData, UserData } from '@/lib/types'
|
||||
import { DUE_MAP, INITIAL_DUE, RECURRENCE_RULE_MAP } from "./constants"
|
||||
import * as chrono from 'chrono-node'
|
||||
import _ from "lodash"
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import stableStringify from 'json-stable-stringify';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
@@ -481,3 +482,50 @@ export function hasPermission(
|
||||
// Otherwise, check specific permissions.
|
||||
return checkPermission(currentUser.permissions, resource, action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares a consistent string representation of the data for hashing.
|
||||
* It combines all relevant data pieces into a single object and then stringifies it stably.
|
||||
*/
|
||||
export function prepareDataForHashing(
|
||||
settings: Settings,
|
||||
habits: HabitsData,
|
||||
coins: CoinsData,
|
||||
wishlist: WishlistData,
|
||||
users: UserData
|
||||
): string {
|
||||
// Combine all data into a single object.
|
||||
// The order of keys in this object itself doesn't matter due to stableStringify,
|
||||
// but being explicit helps in understanding what's being hashed.
|
||||
const combinedData = {
|
||||
settings,
|
||||
habits,
|
||||
coins,
|
||||
wishlist,
|
||||
users,
|
||||
};
|
||||
const stringifiedData = stableStringify(combinedData);
|
||||
// Handle cases where stringify might return undefined.
|
||||
if (stringifiedData === undefined) {
|
||||
throw new Error("Failed to stringify data for hashing. stableStringify returned undefined.");
|
||||
}
|
||||
return stringifiedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a SHA-256 hash for a given string using the Web Crypto API.
|
||||
* This function is suitable for both client-side and server-side (Node.js 19+) environments.
|
||||
* @param dataString The string to hash.
|
||||
* @returns A promise that resolves to the hex string of the hash.
|
||||
*/
|
||||
export async function generateCryptoHash(dataString: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(dataString);
|
||||
// globalThis.crypto should be available in modern browsers and Node.js (v19+)
|
||||
// For Node.js v15-v18, you might need: const { subtle } = require('node:crypto').webcrypto;
|
||||
const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
// Convert buffer to hex string
|
||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
return hashHex;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user