feat(mobile): identité user-first — login/password, SecureStore, scoping par compte
- api: DeviceRegistration device-only, login/changePassword/resolveUser, interception 401 (purge) exonérée sur le login ; type User aligné sur le fil (is_admin) - SecureStore (expo-secure-store v57) : token + profil, jamais en SQLite ; miroir active_user_id - AuthContext : statut loading/signedOut/signedIn, bootstrap register→restore, signIn/signOut, garde-fou switch de compte - app/login.tsx + gate de routes dans _layout (Redirect signedOut) ; i18n fr/en - DB v5 : user_id sur pending_operations + resource_permissions (UNIQUE par user), repos scopés (user_id IS ? OR IS NULL) - syncOutbox : delta permissions par compte (Map), outbox poussée du compte actif uniquement - tests: register/login/401/resolve/changePassword, scoping outbox+permissions, migrations v5 ; e2e live revert register→login admin
This commit is contained in:
+36
-1
@@ -5,7 +5,9 @@ import type {
|
||||
FileDto,
|
||||
FolderDto,
|
||||
ListFilesParams,
|
||||
LoginResponse,
|
||||
OcrJob,
|
||||
ResolvedUser,
|
||||
ResourcePermission,
|
||||
SyncOperation,
|
||||
SyncResult,
|
||||
@@ -25,6 +27,14 @@ export function hasAuthToken(): boolean {
|
||||
return authToken !== null;
|
||||
}
|
||||
|
||||
// Notification de 401 reçus sur un endpoint protégé (hors login lui-même) —
|
||||
// AuthContext s'en sert pour purger la session (token révoqué/expiré).
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
|
||||
export function setUnauthorizedHandler(handler: (() => void) | null): void {
|
||||
onUnauthorized = handler;
|
||||
}
|
||||
|
||||
type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
||||
|
||||
function toQuery(params?: QueryParams): string {
|
||||
@@ -60,7 +70,8 @@ export class ApiError extends Error {
|
||||
async function request<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
timeoutMs: number = DEFAULT_TIMEOUT_MS
|
||||
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
||||
opts: { skipUnauthorizedHandling?: boolean } = {},
|
||||
): Promise<ApiData<T>> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
@@ -76,6 +87,10 @@ async function request<T>(
|
||||
throw new ApiError('NETWORK_ERROR', 'Serveur injoignable');
|
||||
}
|
||||
|
||||
if (response.status === 401 && !opts.skipUnauthorizedHandling) {
|
||||
onUnauthorized?.();
|
||||
}
|
||||
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
| ApiData<T>
|
||||
| ApiErrorBody
|
||||
@@ -136,6 +151,26 @@ export const api = {
|
||||
body: JSON.stringify({ deviceId }),
|
||||
}),
|
||||
|
||||
// Seule porte d'émission de token (V1). Le 401 = mauvaises identifiants
|
||||
// (normal sur l'écran de login) : on ne déclenche pas la purge de session.
|
||||
login: (username: string, password: string, deviceId: string) =>
|
||||
request<LoginResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, device_id: deviceId }),
|
||||
}, DEFAULT_TIMEOUT_MS, { skipUnauthorizedHandling: true }),
|
||||
|
||||
changePassword: (currentPassword: string, newPassword: string) =>
|
||||
request<{ id: string }>('/users/me/password', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
||||
}),
|
||||
|
||||
// Résolution exacte d'un destinataire par username — jamais de listing.
|
||||
resolveUser: (username: string) =>
|
||||
request<ResolvedUser>(`/users/resolve${toQuery({ username })}`),
|
||||
|
||||
listFiles: (params?: ListFilesParams) =>
|
||||
request<FileDto[]>(`/files${toQuery(params)}`),
|
||||
|
||||
|
||||
@@ -44,7 +44,29 @@ export type OcrJob = {
|
||||
|
||||
export type DeviceRegistration = {
|
||||
deviceId: string;
|
||||
};
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
};
|
||||
|
||||
export type LoginRequest = {
|
||||
username: string;
|
||||
password: string;
|
||||
device_id: string;
|
||||
};
|
||||
|
||||
export type LoginResponse = {
|
||||
token: string;
|
||||
expires_at: number;
|
||||
user: User;
|
||||
};
|
||||
|
||||
export type ResolvedUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
export type ListParams = {
|
||||
|
||||
+2
-1
@@ -26,7 +26,8 @@
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-sqlite",
|
||||
"expo-localization"
|
||||
"expo-localization",
|
||||
"expo-secure-store"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+27
-4
@@ -1,7 +1,8 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Stack } from 'expo-router';
|
||||
import { AuthProvider } from '../context/AuthContext';
|
||||
import '../i18n';
|
||||
import { Redirect, Stack } from 'expo-router';
|
||||
import { ActivityIndicator, View } from 'react-native';
|
||||
import { AuthProvider, useAuth } from '../context/AuthContext';
|
||||
import i18n from '../i18n';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -12,11 +13,33 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
function AuthGate() {
|
||||
const { status } = useAuth();
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack>
|
||||
<Stack.Screen name="login" options={{ title: i18n.t('login_title') }} />
|
||||
</Stack>
|
||||
{/* Toute route est protégée tant qu'aucun compte n'est connecté. */}
|
||||
{status === 'signedOut' ? <Redirect href="/login" /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<Stack />
|
||||
<AuthGate />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { ApiError } from '../api/client';
|
||||
import i18n from '../i18n';
|
||||
|
||||
export default function Login() {
|
||||
const router = useRouter();
|
||||
const { signIn, user } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!username.trim() || !password) {
|
||||
setError(i18n.t('login_error_required'));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
// Changer de compte en étant connecté remplace la session locale (garde-fou).
|
||||
if (user) {
|
||||
const confirmed = await new Promise<boolean>((resolve) => {
|
||||
Alert.alert(i18n.t('login_switch_title'), i18n.t('login_switch_message'), [
|
||||
{ text: i18n.t('login_switch_cancel'), style: 'cancel', onPress: () => resolve(false) },
|
||||
{ text: i18n.t('login_switch_confirm'), style: 'destructive', onPress: () => resolve(true) },
|
||||
]);
|
||||
});
|
||||
if (!confirmed) {
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await signIn(username.trim(), password);
|
||||
router.replace('/');
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : i18n.t('login_error_generic'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>{i18n.t('login_subtitle')}</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder={i18n.t('login_username')}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder={i18n.t('login_password')}
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
/>
|
||||
{error ? <Text style={styles.error}>{error}</Text> : null}
|
||||
<Pressable
|
||||
style={[styles.button, submitting && styles.buttonDisabled]}
|
||||
onPress={handleSubmit}
|
||||
disabled={submitting}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{submitting ? i18n.t('login_submitting') : i18n.t('login_submit')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
padding: 24,
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
input: {
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: '#ccc',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 12,
|
||||
fontSize: 15,
|
||||
},
|
||||
error: {
|
||||
color: '#c5221f',
|
||||
fontSize: 14,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#1a73e8',
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
marginTop: 4,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -1,20 +1,33 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useSyncDevice } from '../features/syncDevice';
|
||||
import { api, setAuthToken } from '../api/client';
|
||||
import { api, setAuthToken, setUnauthorizedHandler } from '../api/client';
|
||||
import {
|
||||
getDeviceAuthToken,
|
||||
clearActiveUserId,
|
||||
getDeviceUserId,
|
||||
saveDeviceAuthToken,
|
||||
setActiveUserId,
|
||||
} from '../services/localStorage';
|
||||
import type { AuthContextValue, User } from './AuthContext.types';
|
||||
import {
|
||||
clearStoredSession,
|
||||
getStoredAccount,
|
||||
getStoredToken,
|
||||
setStoredSession,
|
||||
} from '../services/secureStore';
|
||||
import type { AuthContextValue, AuthStatus, User } from './AuthContext.types';
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [deviceUserId, setDeviceUserId] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<AuthStatus>('loading');
|
||||
|
||||
const signOut = async () => {
|
||||
await Promise.allSettled([clearStoredSession(), clearActiveUserId()]);
|
||||
setAuthToken(null);
|
||||
setUser(null);
|
||||
setStatus('signedOut');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -24,16 +37,26 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
if (active) setDeviceUserId(id);
|
||||
|
||||
try {
|
||||
let token = await getDeviceAuthToken();
|
||||
if ( !token ) {
|
||||
const { data } = await api.registerDevice(id);
|
||||
token = data.token;
|
||||
await saveDeviceAuthToken(token);
|
||||
}
|
||||
setAuthToken(token);
|
||||
// Enregistrement du device (idempotent côté serveur) — obligatoire
|
||||
// avant le login (INVALID_DEVICE_ID sinon). Aucun token émis ici.
|
||||
await api.registerDevice(id);
|
||||
} catch (error) {
|
||||
console.warn('device registration failed (offline?)', error);
|
||||
}
|
||||
|
||||
try {
|
||||
const [token, storedUser] = await Promise.all([getStoredToken(), getStoredAccount()]);
|
||||
if (active && token && storedUser) {
|
||||
setAuthToken(token);
|
||||
setActiveUserId(storedUser.id);
|
||||
setUser(storedUser);
|
||||
setStatus('signedIn');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('could not restore session', error);
|
||||
}
|
||||
if (active) setStatus('signedOut');
|
||||
};
|
||||
|
||||
bootstrap();
|
||||
@@ -44,7 +67,30 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ user, deviceUserId }), [user, deviceUserId]);
|
||||
// 401 sur un endpoint protégé (token expiré/révoqué, compte supprimé) →
|
||||
// purge de la session. Le 401 du login est exonéré côté client (option
|
||||
// skipUnauthorizedHandling dans api/client.ts).
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler(() => {
|
||||
void signOut();
|
||||
});
|
||||
return () => setUnauthorizedHandler(null);
|
||||
}, [signOut]);
|
||||
|
||||
const signIn = async (username: string, password: string) => {
|
||||
const id = deviceUserId ?? (await getDeviceUserId());
|
||||
const result = await api.login(username, password, id);
|
||||
await setStoredSession(result.data.token, result.data.user);
|
||||
await setActiveUserId(result.data.user.id);
|
||||
setAuthToken(result.data.token);
|
||||
setUser(result.data.user);
|
||||
setStatus('signedIn');
|
||||
};
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ user, deviceUserId, status, signIn, signOut }),
|
||||
[user, deviceUserId, status, signIn, signOut],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
export type User = {};
|
||||
import type { User } from '../api/types';
|
||||
|
||||
export type { User };
|
||||
|
||||
export type AuthStatus = 'loading' | 'signedOut' | 'signedIn';
|
||||
|
||||
export type AuthContextValue = {
|
||||
user: User | null;
|
||||
deviceUserId: string | null;
|
||||
status: AuthStatus;
|
||||
signIn: (username: string, password: string) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { api, hasAuthToken } from '../api/client';
|
||||
import type { SyncOperation } from '../api/types';
|
||||
import {
|
||||
getActiveUserId,
|
||||
listQueuedOperations,
|
||||
markPendingOperation,
|
||||
scheduleRetries,
|
||||
@@ -69,22 +70,25 @@ export async function pushPendingOps(): Promise<PushResult> {
|
||||
|
||||
// ---- permissions snapshot --------------------------------------------------
|
||||
|
||||
// Delta monotone en mémoire (réinitialisé au démarrage de l'app) : la valeur
|
||||
// max de cached_at du dernier snapshot sert de borne `after` pour le prochain.
|
||||
let lastPermissionCachedAt: number | null = null;
|
||||
// Delta monotone PAR COMPTE (clé = active_user_id, NULL hors compte) : la
|
||||
// valeur max de cached_at du dernier snapshot sert de borne `after` pour le
|
||||
// prochain appel. Le snapshot n'est pas rejoué quand on change de compte.
|
||||
const lastPermissionCachedAtByUser = new Map<string | null, number>();
|
||||
|
||||
export async function refreshPermissions(): Promise<number> {
|
||||
if (!hasAuthToken()) return 0;
|
||||
const perms = await api.getSyncPermissions(lastPermissionCachedAt ?? undefined);
|
||||
const activeUserId = await getActiveUserId();
|
||||
const after = lastPermissionCachedAtByUser.get(activeUserId) ?? undefined;
|
||||
const perms = await api.getSyncPermissions(after);
|
||||
if (perms.data.length === 0) return 0;
|
||||
for (const p of perms.data) {
|
||||
await saveResourcePermission(p);
|
||||
}
|
||||
lastPermissionCachedAt = Math.max(...perms.data.map((p) => p.cachedAt));
|
||||
lastPermissionCachedAtByUser.set(activeUserId, Math.max(...perms.data.map((p) => p.cachedAt)));
|
||||
return perms.data.length;
|
||||
}
|
||||
|
||||
// Visible uniquement pour les tests unitaires (reset de l'état en mémoire).
|
||||
export function resetPermissionCachedAtForTests(): void {
|
||||
lastPermissionCachedAt = null;
|
||||
lastPermissionCachedAtByUser.clear();
|
||||
}
|
||||
+13
-1
@@ -8,5 +8,17 @@
|
||||
"empty_folder": "Empty folder — next syncDevice will refresh it.",
|
||||
"bytes": "B",
|
||||
"kilobytes": "KB",
|
||||
"megabytes": "MB"
|
||||
"megabytes": "MB",
|
||||
"login_title": "Sign in",
|
||||
"login_subtitle": "Sign in to your account",
|
||||
"login_username": "Username",
|
||||
"login_password": "Password",
|
||||
"login_submit": "Sign in",
|
||||
"login_submitting": "Signing in…",
|
||||
"login_error_required": "Username and password are required",
|
||||
"login_error_generic": "Could not sign in right now",
|
||||
"login_switch_title": "Account already signed in",
|
||||
"login_switch_message": "This sign-in will replace the active account on this device. Continue?",
|
||||
"login_switch_confirm": "Replace",
|
||||
"login_switch_cancel": "Cancel"
|
||||
}
|
||||
|
||||
+13
-1
@@ -8,5 +8,17 @@
|
||||
"empty_folder": "Dossier vide — le prochain syncDevice l'actualisera.",
|
||||
"bytes": "o",
|
||||
"kilobytes": "Ko",
|
||||
"megabytes": "Mo"
|
||||
"megabytes": "Mo",
|
||||
"login_title": "Connexion",
|
||||
"login_subtitle": "Connecte-toi à ton compte",
|
||||
"login_username": "Nom d'utilisateur",
|
||||
"login_password": "Mot de passe",
|
||||
"login_submit": "Se connecter",
|
||||
"login_submitting": "Connexion…",
|
||||
"login_error_required": "Nom d'utilisateur et mot de passe requis",
|
||||
"login_error_generic": "Impossible de se connecter pour le moment",
|
||||
"login_switch_title": "Compte déjà connecté",
|
||||
"login_switch_message": "Cette connexion remplacera le compte actif sur cet appareil. Continuer ?",
|
||||
"login_switch_confirm": "Remplacer",
|
||||
"login_switch_cancel": "Annuler"
|
||||
}
|
||||
|
||||
Generated
+10
@@ -16,6 +16,7 @@
|
||||
"expo-linking": "~57.0.9",
|
||||
"expo-localization": "~57.0.1",
|
||||
"expo-router": "~57.0.20",
|
||||
"expo-secure-store": "~57.0.3",
|
||||
"expo-sqlite": "~57.0.2",
|
||||
"expo-status-bar": "~57.0.1",
|
||||
"i18n-js": "^4.5.3",
|
||||
@@ -4330,6 +4331,15 @@
|
||||
"integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/expo-secure-store": {
|
||||
"version": "57.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-57.0.3.tgz",
|
||||
"integrity": "sha512-w7XkSQeUiYXPoKXo1jSrQqql7pyCSyIzOp2k0apsZZBE+RkoLTQIMjcbqc181y6KgupfNnYxkaKe+4MEg94+SA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-server": {
|
||||
"version": "57.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.3.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"expo-linking": "~57.0.9",
|
||||
"expo-localization": "~57.0.1",
|
||||
"expo-router": "~57.0.20",
|
||||
"expo-secure-store": "~57.0.3",
|
||||
"expo-sqlite": "~57.0.2",
|
||||
"expo-status-bar": "~57.0.1",
|
||||
"i18n-js": "^4.5.3",
|
||||
|
||||
@@ -40,6 +40,7 @@ export {
|
||||
DATABASE_VERSION,
|
||||
DEVICE_USER_ID_KEY,
|
||||
AUTH_TOKEN_KEY,
|
||||
ACTIVE_USER_ID_KEY,
|
||||
PREFERENCES_KEY,
|
||||
PERMISSION_TTL_MS,
|
||||
} from './schema';
|
||||
@@ -218,6 +218,47 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_files_uri ON files(uri) WHERE uri IS NOT N
|
||||
await db.execAsync('PRAGMA foreign_keys = ON;');
|
||||
},
|
||||
},
|
||||
{
|
||||
// User-first : pending_operations et resource_permissions deviennent
|
||||
// scopés par compte (`user_id`), NULL = entrées legacy/device-local. Le
|
||||
// UNIQUE de resource_permissions passe à (user_id, resource_id,
|
||||
// resource_type) pour qu'un même fichier partagé à deux comptes ne
|
||||
// collisionne pas côté cache. SQLite ne pouvant pas altérer un UNIQUE, la
|
||||
// table est reconstruite (le schéma v5 se base sur v3/v4 — l'indice
|
||||
// UNIQUE (resource_id, resource_type) a été posé en v3).
|
||||
version: 5,
|
||||
up: async (db) => {
|
||||
await db.execAsync('PRAGMA foreign_keys = OFF;');
|
||||
await db.withExclusiveTransactionAsync(async (txn) => {
|
||||
await txn.execAsync(`
|
||||
CREATE TABLE resource_permissions_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
user_id TEXT,
|
||||
resource_id TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL CHECK (resource_type IN ('folder', 'file')),
|
||||
effective_access TEXT NOT NULL CHECK (effective_access IN ('owner', 'editor', 'commenter', 'viewer')),
|
||||
inherit INTEGER NOT NULL DEFAULT 1,
|
||||
owner_id TEXT,
|
||||
shared_by_id TEXT,
|
||||
expires_at INTEGER,
|
||||
cached_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
UNIQUE (user_id, resource_id, resource_type)
|
||||
);
|
||||
INSERT INTO resource_permissions_new (id, user_id, resource_id, resource_type, effective_access, inherit, owner_id, shared_by_id, expires_at, cached_at, updated_at)
|
||||
SELECT id, NULL, resource_id, resource_type, effective_access, inherit, owner_id, shared_by_id, expires_at, cached_at, updated_at FROM resource_permissions;
|
||||
DROP TABLE resource_permissions;
|
||||
ALTER TABLE resource_permissions_new RENAME TO resource_permissions;
|
||||
CREATE INDEX IF NOT EXISTS idx_permissions_resource ON resource_permissions(resource_id, resource_type);
|
||||
`);
|
||||
await txn.execAsync(`
|
||||
ALTER TABLE pending_operations ADD COLUMN user_id TEXT;
|
||||
`);
|
||||
await txn.execAsync(`PRAGMA user_version = 5;`);
|
||||
});
|
||||
await db.execAsync('PRAGMA foreign_keys = ON;');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
async function readUserVersion(db: MigrationDb): Promise<number> {
|
||||
|
||||
@@ -13,6 +13,9 @@ export {
|
||||
getDeviceUserId,
|
||||
getDeviceAuthToken,
|
||||
saveDeviceAuthToken,
|
||||
getActiveUserId,
|
||||
setActiveUserId,
|
||||
clearActiveUserId,
|
||||
} from './preferences';
|
||||
export {
|
||||
getResourcePermission,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getSession } from '../session';
|
||||
import { getActiveUserId } from './preferences';
|
||||
import type {
|
||||
NewPendingOperation,
|
||||
PendingOperation,
|
||||
@@ -19,10 +20,11 @@ export async function enqueuePendingOperation(
|
||||
operation: NewPendingOperation,
|
||||
): Promise<number> {
|
||||
const db = await getSession();
|
||||
const activeUserId = await getActiveUserId();
|
||||
const row = await db.getFirstAsync<{ id: number }>(
|
||||
`INSERT INTO pending_operations
|
||||
(resource_id, resource_type, ref_type, ref_id, operation, payload, status, attempts, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?)
|
||||
(resource_id, resource_type, ref_type, ref_id, operation, payload, status, attempts, created_at, user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)
|
||||
RETURNING id`,
|
||||
operation.resourceId ?? null,
|
||||
operation.resourceType ?? null,
|
||||
@@ -31,6 +33,7 @@ export async function enqueuePendingOperation(
|
||||
operation.operation,
|
||||
JSON.stringify(operation.payload ?? {}),
|
||||
Date.now(),
|
||||
activeUserId,
|
||||
);
|
||||
return row!.id;
|
||||
}
|
||||
@@ -42,12 +45,16 @@ export async function getPendingOperations(
|
||||
const rows = status
|
||||
? await db.getAllAsync<PendingOperationRow>(
|
||||
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
|
||||
WHERE status = ? ORDER BY created_at ASC, id ASC`,
|
||||
WHERE status = ? AND (user_id = ? OR user_id IS NULL)
|
||||
ORDER BY created_at ASC, id ASC`,
|
||||
status,
|
||||
await getActiveUserId(),
|
||||
)
|
||||
: await db.getAllAsync<PendingOperationRow>(
|
||||
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
|
||||
WHERE user_id = ? OR user_id IS NULL
|
||||
ORDER BY created_at ASC, id ASC`,
|
||||
await getActiveUserId(),
|
||||
);
|
||||
return rows.map(toPendingOperation);
|
||||
}
|
||||
@@ -57,9 +64,11 @@ export async function getNextQueuedOperation(): Promise<PendingOperation | null>
|
||||
const row = await db.getFirstAsync<PendingOperationRow>(
|
||||
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
|
||||
WHERE status = 'pending'
|
||||
AND (user_id = ? OR user_id IS NULL)
|
||||
AND (next_retry_at IS NULL OR next_retry_at <= ?)
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT 1`,
|
||||
await getActiveUserId(),
|
||||
Date.now(),
|
||||
);
|
||||
return row ? toPendingOperation(row) : null;
|
||||
@@ -72,9 +81,11 @@ export async function listQueuedOperations(limit: number): Promise<PendingOperat
|
||||
const rows = await db.getAllAsync<PendingOperationRow>(
|
||||
`SELECT ${PENDING_OPERATION_COLUMNS} FROM pending_operations
|
||||
WHERE status = 'pending'
|
||||
AND (user_id = ? OR user_id IS NULL)
|
||||
AND (next_retry_at IS NULL OR next_retry_at <= ?)
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT ?`,
|
||||
await getActiveUserId(),
|
||||
Date.now(),
|
||||
limit,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { DbSession } from '../session';
|
||||
import { getSession } from '../session';
|
||||
import { PERMISSION_TTL_MS } from '../schema';
|
||||
import { getDeviceUserId } from './preferences';
|
||||
import { getActiveUserId, getDeviceUserId } from './preferences';
|
||||
import type {
|
||||
AccessLevel,
|
||||
NewResourcePermission,
|
||||
@@ -44,9 +44,11 @@ export async function getResourcePermission(
|
||||
): Promise<ResourcePermission | null> {
|
||||
const db = await getSession();
|
||||
const row = await db.getFirstAsync<ResourcePermissionRow>(
|
||||
`SELECT * FROM resource_permissions WHERE resource_id = ? AND resource_type = ?`,
|
||||
`SELECT * FROM resource_permissions
|
||||
WHERE resource_id = ? AND resource_type = ? AND (user_id = ? OR user_id IS NULL)`,
|
||||
resourceId,
|
||||
resourceType,
|
||||
await getActiveUserId(),
|
||||
);
|
||||
return row ? toResourcePermission(row) : null;
|
||||
}
|
||||
@@ -58,9 +60,9 @@ export async function saveResourcePermission(
|
||||
const now = Date.now();
|
||||
await db.runAsync(
|
||||
`INSERT INTO resource_permissions
|
||||
(resource_id, resource_type, effective_access, inherit, owner_id, shared_by_id, expires_at, cached_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(resource_id, resource_type) DO UPDATE SET
|
||||
(user_id, resource_id, resource_type, effective_access, inherit, owner_id, shared_by_id, expires_at, cached_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, resource_id, resource_type) DO UPDATE SET
|
||||
effective_access = excluded.effective_access,
|
||||
inherit = excluded.inherit,
|
||||
owner_id = excluded.owner_id,
|
||||
@@ -68,6 +70,7 @@ export async function saveResourcePermission(
|
||||
expires_at = excluded.expires_at,
|
||||
cached_at = excluded.cached_at,
|
||||
updated_at = excluded.updated_at`,
|
||||
await getActiveUserId(),
|
||||
permission.resource_id,
|
||||
permission.resourceType,
|
||||
permission.effectiveAccess,
|
||||
@@ -140,6 +143,9 @@ export async function canAccess(
|
||||
required: AccessLevel,
|
||||
): Promise<AccessCheck> {
|
||||
const now = Date.now();
|
||||
// Le device est le propriétaire local de ses propres ressources (device_user_id
|
||||
// seedé en owner_id, cf. v4). Owner = device-local : le check reste volontairement
|
||||
// indépendant du compte connecté (fallback hors-cloud, décision offline).
|
||||
const deviceUserId = await getDeviceUserId();
|
||||
|
||||
const exactCache = await getResourcePermission(resourceId, resourceType);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getSession } from '../session';
|
||||
import { AUTH_TOKEN_KEY, DEVICE_USER_ID_KEY, PREFERENCES_KEY } from '../schema';
|
||||
import { ACTIVE_USER_ID_KEY, AUTH_TOKEN_KEY, DEVICE_USER_ID_KEY, PREFERENCES_KEY } from '../schema';
|
||||
import type { UserPreferences } from '../types';
|
||||
|
||||
const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
@@ -58,6 +58,36 @@ export async function saveDeviceAuthToken(token: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
// Miroir non-sensible du compte connecté (le token vit en SecureStore). NULL
|
||||
// = aucun compte actif (mode device-local legacy / tests).
|
||||
export async function getActiveUserId(): Promise<string | null> {
|
||||
const db = await getSession();
|
||||
const row = await db.getFirstAsync<{ value: string }>(
|
||||
'SELECT "value" FROM user_preferences WHERE "key" = ?',
|
||||
ACTIVE_USER_ID_KEY,
|
||||
);
|
||||
return row?.value || null;
|
||||
}
|
||||
|
||||
export async function setActiveUserId(userId: string): Promise<void> {
|
||||
const db = await getSession();
|
||||
await db.runAsync(
|
||||
`INSERT INTO user_preferences ("key", "value", updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT("key") DO UPDATE SET "value" = excluded."value", updated_at = excluded.updated_at`,
|
||||
ACTIVE_USER_ID_KEY,
|
||||
userId,
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function clearActiveUserId(): Promise<void> {
|
||||
const db = await getSession();
|
||||
await db.runAsync(
|
||||
'DELETE FROM user_preferences WHERE "key" = ?',
|
||||
ACTIVE_USER_ID_KEY,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getUserPreferences(): Promise<UserPreferences> {
|
||||
const db = await getSession();
|
||||
const row = await db.getFirstAsync<{ value: string }>(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const DATABASE_NAME = 'dot.db';
|
||||
|
||||
export const DATABASE_VERSION = 4;
|
||||
export const DATABASE_VERSION = 5;
|
||||
|
||||
export const PREFERENCES_KEY = 'user_preferences';
|
||||
|
||||
@@ -8,6 +8,12 @@ export const DEVICE_USER_ID_KEY = 'device_user_id';
|
||||
|
||||
export const AUTH_TOKEN_KEY = 'auth_token';
|
||||
|
||||
// Miroir non-sensible du compte connecté (voir services/secureStore.ts) : le
|
||||
// token lui-même reste en SecureStore ; seul l'id du compte actif est répété en
|
||||
// SQLite pour permettre aux repositories de scoper leurs lectures/écritures
|
||||
// sans avoir à importer expo-secure-store (tests Node inclus).
|
||||
export const ACTIVE_USER_ID_KEY = 'active_user_id';
|
||||
|
||||
export const FOLDER_COLUMNS = [
|
||||
'resource_id',
|
||||
'uri',
|
||||
|
||||
@@ -3,6 +3,9 @@ export {
|
||||
getDeviceUserId,
|
||||
getDeviceAuthToken,
|
||||
saveDeviceAuthToken,
|
||||
getActiveUserId,
|
||||
setActiveUserId,
|
||||
clearActiveUserId,
|
||||
getFolders,
|
||||
getFolderFolders,
|
||||
getFolder,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import type { User } from '../api/types';
|
||||
|
||||
// Clés SecureStore (iOS keychain / Android Keystore). Le token et le profil du
|
||||
// compte connecté ne sont JAMAIS persistés en SQLite : un miroir non-sensible
|
||||
// (active_user_id) est répété en base pour le scoping des repositories.
|
||||
const TOKEN_KEY = 'vaultdrop.auth_token';
|
||||
const ACCOUNT_KEY = 'vaultdrop.account';
|
||||
|
||||
export async function getStoredToken(): Promise<string | null> {
|
||||
try {
|
||||
return await SecureStore.getItemAsync(TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStoredAccount(): Promise<User | null> {
|
||||
try {
|
||||
const raw = await SecureStore.getItemAsync(ACCOUNT_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as User;
|
||||
if (typeof parsed?.id !== 'string' || typeof parsed?.username !== 'string') return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setStoredSession(token: string, user: User): Promise<void> {
|
||||
await SecureStore.setItemAsync(TOKEN_KEY, token);
|
||||
await SecureStore.setItemAsync(ACCOUNT_KEY, JSON.stringify(user));
|
||||
}
|
||||
|
||||
export async function clearStoredSession(): Promise<void> {
|
||||
try {
|
||||
await SecureStore.deleteItemAsync(TOKEN_KEY);
|
||||
await SecureStore.deleteItemAsync(ACCOUNT_KEY);
|
||||
} catch {
|
||||
// clés absentes → rien à supprimer
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { api, setAuthToken, ApiError } from '../api/client';
|
||||
import { api, setAuthToken, setUnauthorizedHandler, ApiError } from '../api/client';
|
||||
import type { ApiData } from '../api/types';
|
||||
|
||||
function jsonResponse(status: number, body: unknown, headers?: HeadersInit): Response {
|
||||
@@ -33,10 +33,12 @@ function pathOf(url: string): string {
|
||||
beforeEach(() => {
|
||||
calls = [];
|
||||
setAuthToken(null);
|
||||
setUnauthorizedHandler(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
setUnauthorizedHandler(null);
|
||||
});
|
||||
|
||||
test('nominal : GET /health', async () => {
|
||||
@@ -47,13 +49,74 @@ test('nominal : GET /health', async () => {
|
||||
assert.deepEqual(res.data, { status: 'healthy' });
|
||||
});
|
||||
|
||||
test('nominal : POST /devices (register) JSON', async () => {
|
||||
stubFetch(() => ok({ deviceId: 'aaaa', token: 'v4.local.x' }));
|
||||
test('nominal : POST /devices (register) — plus aucun token, device-only', async () => {
|
||||
stubFetch(() => ok({ deviceId: 'aaaa' }));
|
||||
const res = await api.registerDevice('aaaa');
|
||||
assert.equal(pathOf(calls[0].url), '/api/v1/devices');
|
||||
assert.equal(calls[0].init.method, 'POST');
|
||||
assert.deepEqual(JSON.parse(String(calls[0].init.body)), { deviceId: 'aaaa' });
|
||||
assert.equal(res.data.token, 'v4.local.x');
|
||||
assert.deepEqual(res.data, { deviceId: 'aaaa' });
|
||||
assert.ok(!('token' in res.data), 'POST /devices ne doit plus émettre de token');
|
||||
});
|
||||
|
||||
test('nominal : POST /auth/login (seule porte de token)', async () => {
|
||||
stubFetch(() =>
|
||||
ok({
|
||||
token: 'v4.local.xyz',
|
||||
expires_at: 1700000000000,
|
||||
user: { id: 'u-1', username: 'alice', is_admin: false },
|
||||
}),
|
||||
);
|
||||
const res = await api.login('alice', 'secret-pass', 'dev-1');
|
||||
assert.equal(pathOf(calls[0].url), '/api/v1/auth/login');
|
||||
assert.equal(calls[0].init.method, 'POST');
|
||||
assert.deepEqual(JSON.parse(String(calls[0].init.body)), {
|
||||
username: 'alice',
|
||||
password: 'secret-pass',
|
||||
device_id: 'dev-1',
|
||||
});
|
||||
assert.equal(res.data.user.id, 'u-1');
|
||||
assert.equal(res.data.user.is_admin, false);
|
||||
});
|
||||
|
||||
test('auth : 401 de login ne déclenche PAS le handler de purge de session', async () => {
|
||||
let purged = 0;
|
||||
setUnauthorizedHandler(() => {
|
||||
purged++;
|
||||
});
|
||||
stubFetch(() => jsonResponse(401, { error: { code: 'UNAUTHORIZED', message: 'invalid credentials' } }));
|
||||
await assert.rejects(() => api.login('bob', 'wrong', 'dev-1'), (err: unknown) => (err as ApiError).code === 'UNAUTHORIZED');
|
||||
assert.equal(purged, 0, 'mauvais identifiants ≠ session à purger');
|
||||
});
|
||||
|
||||
test('auth : 401 d’un endpoint protégé déclenche le handler de purge', async () => {
|
||||
let purged = 0;
|
||||
setUnauthorizedHandler(() => {
|
||||
purged++;
|
||||
});
|
||||
setAuthToken('tok-expired');
|
||||
stubFetch(() => jsonResponse(401, { error: { code: 'UNAUTHORIZED', message: 'token invalid' } }));
|
||||
await assert.rejects(() => api.listFiles(), (err: unknown) => (err as ApiError).code === 'UNAUTHORIZED');
|
||||
assert.equal(purged, 1, 'token révoqué → purge de session');
|
||||
setUnauthorizedHandler(null);
|
||||
});
|
||||
|
||||
test('nominal : PATCH /users/me/password', async () => {
|
||||
stubFetch(() => ok({ id: 'u-1' }));
|
||||
await api.changePassword('old-pass', 'new-pass-8chars');
|
||||
assert.equal(pathOf(calls[0].url), '/api/v1/users/me/password');
|
||||
assert.equal(calls[0].init.method, 'PATCH');
|
||||
assert.deepEqual(JSON.parse(String(calls[0].init.body)), {
|
||||
current_password: 'old-pass',
|
||||
new_password: 'new-pass-8chars',
|
||||
});
|
||||
});
|
||||
|
||||
test('nominal : GET /users/resolve?username= (résolution exacte)', async () => {
|
||||
stubFetch(() => ok({ id: 'u-9', username: 'bob' }));
|
||||
const res = await api.resolveUser('BOB');
|
||||
assert.equal(pathOf(calls[0].url), '/api/v1/users/resolve?username=BOB');
|
||||
assert.equal(res.data.username, 'bob');
|
||||
});
|
||||
|
||||
test('nominal : GET /files — query filtrée (undefined/null ignorés)', async () => {
|
||||
@@ -184,7 +247,7 @@ test('auth : token posé → Authorization: Bearer', async () => {
|
||||
|
||||
test('auth : fusion avec Content-Type existant (POST /devices + token)', async () => {
|
||||
setAuthToken('tok-123');
|
||||
stubFetch(() => ok({ deviceId: 'a', token: 't' }));
|
||||
stubFetch(() => ok({ deviceId: 'a' }));
|
||||
await api.registerDevice('a');
|
||||
const headers = new Headers(calls[0].init.headers);
|
||||
assert.equal(headers.get('Authorization'), 'Bearer tok-123');
|
||||
|
||||
@@ -13,10 +13,17 @@ import { MIGRATIONS, type MigrationDb } from '../services/db/migrations';
|
||||
import type { DbSession } from '../services/db/session';
|
||||
import { __setDbForTests } from '../services/db/session';
|
||||
import { api, setAuthToken } from '../api/client';
|
||||
import { enqueuePendingOperation, getPendingOperations } from '../services/db';
|
||||
import {
|
||||
clearActiveUserId,
|
||||
enqueuePendingOperation,
|
||||
getPendingOperations,
|
||||
setActiveUserId,
|
||||
} from '../services/db';
|
||||
import { pushPendingOps, refreshPermissions, resetPermissionCachedAtForTests } from '../features/syncOutbox';
|
||||
|
||||
const BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL ?? 'http://localhost:8080/api/v1';
|
||||
const ADMIN_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
|
||||
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
|
||||
|
||||
type Harness = MigrationDb & DbSession;
|
||||
|
||||
@@ -75,9 +82,10 @@ beforeEach(async () => {
|
||||
afterEach(() => {
|
||||
__setDbForTests(null);
|
||||
setAuthToken(null);
|
||||
clearActiveUserId();
|
||||
});
|
||||
|
||||
test('bout en bout : register → push outbox → snapshot → relecture serveur', async (t) => {
|
||||
test('bout en bout : register + login admin → push outbox → snapshot → relecture serveur', async (t) => {
|
||||
try {
|
||||
await api.health();
|
||||
} catch (error) {
|
||||
@@ -88,11 +96,15 @@ test('bout en bout : register → push outbox → snapshot → relecture serveur
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Enregistrement du device (idempotent) puis login = SEULE porte de token.
|
||||
deviceId = newDeviceId();
|
||||
const reg = await api.registerDevice(deviceId);
|
||||
setAuthToken(reg.data.token);
|
||||
await api.registerDevice(deviceId);
|
||||
const login = await api.login(ADMIN_USERNAME, ADMIN_PASSWORD, deviceId);
|
||||
setAuthToken(login.data.token);
|
||||
await setActiveUserId(login.data.user.id);
|
||||
assert.equal(login.data.user.is_admin, true, 'l’admin E2E doit exister (ADMIN_*)');
|
||||
|
||||
// 1. Enqueue un CREATE_RESOURCE puis push
|
||||
// 1. Enqueue un CREATE_RESOURCE puis push (scopé au compte connecté)
|
||||
const name = `e2e-${deviceId.slice(0, 8)}`;
|
||||
const resourceId = newDeviceId();
|
||||
const opId = await enqueuePendingOperation({
|
||||
|
||||
@@ -80,7 +80,7 @@ async function seedLegacyTree(h: Harness): Promise<void> {
|
||||
VALUES ('${SONG_URI}', 'song.mp3', '${MUSIC_URI}', 'mp3', 30, 'audio/mpeg', 0, 3000, 'cloud', 3000);`);
|
||||
}
|
||||
|
||||
test('fresh migrate v0 → v4 creates full schema and seeds device id', async () => {
|
||||
test('fresh migrate v0 → v5 creates full schema and seeds device id', async () => {
|
||||
const h = createHarness();
|
||||
await migrateDatabase(h);
|
||||
|
||||
@@ -108,6 +108,11 @@ test('fresh migrate v0 → v4 creates full schema and seeds device id', async ()
|
||||
assert.ok(names.length > 0, `table ${table} must exist`);
|
||||
}
|
||||
|
||||
const permCols = await columnNames(h, 'resource_permissions');
|
||||
assert.ok(permCols.includes('user_id'), 'resource_permissions must have user_id (v5)');
|
||||
const opCols = await columnNames(h, 'pending_operations');
|
||||
assert.ok(opCols.includes('user_id'), 'pending_operations must have user_id (v5)');
|
||||
|
||||
const device = await h.getFirstAsync<{ value: string }>(
|
||||
'SELECT "value" FROM user_preferences WHERE "key" = ?',
|
||||
DEVICE_USER_ID_KEY,
|
||||
@@ -117,6 +122,35 @@ test('fresh migrate v0 → v4 creates full schema and seeds device id', async ()
|
||||
assert.deepEqual(await foreignKeyViolations(h), [], 'no orphaned FKs after fresh migrate');
|
||||
});
|
||||
|
||||
test('v5 : UNIQUE de resource_permissions scopé par (user_id, resource_id, resource_type)', async () => {
|
||||
const h = createHarness();
|
||||
await migrateDatabase(h);
|
||||
|
||||
const row = await h.getFirstAsync<{ sql: string }>(
|
||||
`SELECT sql FROM sqlite_master WHERE type = 'table' AND tbl_name = 'resource_permissions'`,
|
||||
);
|
||||
assert.ok(row, 'table sql present');
|
||||
assert.match(row.sql, /UNIQUE\s*\(user_id,\s*resource_id,\s*resource_type\)/);
|
||||
|
||||
// Deux comptes peuvent partager la même ressource sans collision.
|
||||
await h.runAsync(
|
||||
`INSERT INTO resource_permissions (user_id, resource_id, resource_type, effective_access, inherit, owner_id, cached_at, updated_at)
|
||||
VALUES ('u1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'folder', 'owner', 1, NULL, 1, 1)`,
|
||||
);
|
||||
await h.runAsync(
|
||||
`INSERT INTO resource_permissions (user_id, resource_id, resource_type, effective_access, inherit, owner_id, cached_at, updated_at)
|
||||
VALUES ('u2', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'folder', 'viewer', 0, NULL, 1, 1)`,
|
||||
);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
h.runAsync(
|
||||
`INSERT INTO resource_permissions (user_id, resource_id, resource_type, effective_access, inherit, owner_id, cached_at, updated_at)
|
||||
VALUES ('u1', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'folder', 'editor', 1, NULL, 1, 1)`,
|
||||
),
|
||||
/UNIQUE constraint failed/,
|
||||
);
|
||||
});
|
||||
|
||||
test('user_version is transactional (rollback restores previous version)', async () => {
|
||||
const h = createHarness();
|
||||
await migrateDatabase(h);
|
||||
|
||||
@@ -7,11 +7,13 @@ import { __setDbForTests } from '../services/db/session';
|
||||
import { api, setAuthToken } from '../api/client';
|
||||
import {
|
||||
enqueuePendingOperation,
|
||||
getActiveUserId,
|
||||
getPendingOperations,
|
||||
getResourcePermission,
|
||||
listQueuedOperations,
|
||||
markPendingOperation,
|
||||
scheduleRetries,
|
||||
setActiveUserId,
|
||||
MAX_PENDING_ATTEMPTS,
|
||||
} from '../services/db';
|
||||
import {
|
||||
@@ -332,3 +334,69 @@ test('refreshPermissions garde la référence dans le temps pour after', async (
|
||||
assert.equal((await getResourcePermission('d'.repeat(32), 'folder'))?.effectiveAccess, 'viewer');
|
||||
assert.equal(pathOf(calls[1].url), '/api/v1/sync/permissions?after=42');
|
||||
});
|
||||
|
||||
test('outbox scopée par compte : seul le compte actif voit/pousse ses ops', async () => {
|
||||
await setActiveUserId('u1');
|
||||
const idA = await enqueue({ operation: 'create_resource', payload: { name: 'A' } });
|
||||
await setActiveUserId('u2');
|
||||
const idB = await enqueue({ operation: 'create_resource', payload: { name: 'B' } });
|
||||
|
||||
assert.equal(await getActiveUserId(), 'u2');
|
||||
const visibleAsU2 = (await getPendingOperations()).map((op) => op.id);
|
||||
assert.deepEqual(visibleAsU2, [idB], 'u2 ne voit que ses opérations');
|
||||
|
||||
await setActiveUserId('u1');
|
||||
assert.deepEqual(
|
||||
(await getPendingOperations()).map((op) => op.id),
|
||||
[idA],
|
||||
'u1 ne voit que ses opérations',
|
||||
);
|
||||
|
||||
stubFetch(() => ok({ applied: 1, failed: null }));
|
||||
await setActiveUserId('u2');
|
||||
const result = await pushPendingOps();
|
||||
assert.deepEqual(result, { pushed: 1, retried: 0 });
|
||||
const body = JSON.parse(String(calls[0].init.body));
|
||||
assert.equal(body.operations.length, 1, 'u2 ne pousse pas les opérations de u1');
|
||||
assert.equal(body.operations[0].operation_id, idB);
|
||||
|
||||
await setActiveUserId('u1');
|
||||
const leftover = await getPendingOperations();
|
||||
assert.deepEqual(leftover.map((op) => op.id), [idA], 'l’op u1 reste intacte et non-poussée');
|
||||
});
|
||||
|
||||
test('permissions scopées par compte : snapshot et miroir par compte', async () => {
|
||||
const permA = {
|
||||
resource_id: 'a'.repeat(32),
|
||||
resourceType: 'folder',
|
||||
effectiveAccess: 'owner',
|
||||
inherit: true,
|
||||
ownerId: 'u1',
|
||||
sharedById: null,
|
||||
expiresAt: null,
|
||||
cachedAt: 1000,
|
||||
updatedAt: 1000,
|
||||
};
|
||||
|
||||
await setActiveUserId('u1');
|
||||
stubFetch((url) => (new URL(url).searchParams.get('after') === null ? ok([permA]) : ok([])));
|
||||
await refreshPermissions();
|
||||
assert.equal(
|
||||
(await getResourcePermission('a'.repeat(32), 'folder'))?.effectiveAccess,
|
||||
'owner',
|
||||
'u1 voit la permission de son snapshot',
|
||||
);
|
||||
|
||||
// u2 n'a pas encore de snapshot : pas d'`after` (repart du début) et la
|
||||
// permission de u1 n'est pas visible tant que son propre snapshot n'est pas
|
||||
// arrivé.
|
||||
await setActiveUserId('u2');
|
||||
assert.equal(await getResourcePermission('a'.repeat(32), 'folder'), null);
|
||||
await refreshPermissions();
|
||||
assert.equal(pathOf(calls[1].url), '/api/v1/sync/permissions', 'u2 repart sans after');
|
||||
assert.equal(
|
||||
(await getResourcePermission('a'.repeat(32), 'folder'))?.effectiveAccess,
|
||||
'owner',
|
||||
'u2 voit sa propre copie après son snapshot',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user