Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10,956 changes: 7,373 additions & 3,583 deletions frontend/package-lock.json

Large diffs are not rendered by default.

23 changes: 20 additions & 3 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,27 @@
},
"packageManager": "npm@10.8.2",
"dependencies": {
"@expo/metro-runtime": "~57.0.8",
"expo": "~57.0.7",
"expo-sqlite": "~57.0.1",
"expo-status-bar": "~57.0.1",
"react": "19.2.3",
"react-native": "0.86.0"
"react-dom": "19.2.3",
"react-native": "0.86.0",
"react-native-web": "^0.21.2"
},
"devDependencies": {
"@testing-library/react-native": "13.3.3",
"@types/jest": "29.5.14",
"@types/react": "~19.2.2",
"@types/sql.js": "^1.4.11",
"eslint": "^9.39.5",
"eslint-config-expo": "^57.0.0",
"eslint-config-prettier": "^10.1.8",
"jest": "~29.7.0",
"jest-expo": "57.0.2",
"prettier": "^3.9.5",
"react-test-renderer": "19.2.3",
"sql.js": "^1.14.1",
"typescript": "~6.0.3",
"vitest": "^4.1.10"
Expand All @@ -32,13 +40,22 @@
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"test": "npm run test:vitest && npm run test:jest",
"test:jest": "jest --runInBand",
"test:vitest": "vitest run tests",
"typecheck": "tsc --noEmit",
"check": "npm run lint && npm run format:check && npm run typecheck && npm run test"
},
"private": true
"private": true,
"jest": {
"preset": "jest-expo",
"roots": [
"<rootDir>/src"
]
}
}
117 changes: 117 additions & 0 deletions frontend/src/api/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { afterEach, describe, expect, it, jest } from '@jest/globals';

import { AuthAccessError, type AuthAccessResponse } from '../contracts/auth';
import { ApiError, ApiResponseError, type ApiRequest } from '../infrastructure/network/client';
import { createAuthAccess } from './auth';

const credentials = { username: 'timeflow_user', password: 'password123' };
const response: AuthAccessResponse = {
account_id: 'acc_001',
access_token: 'access-token',
expires_in: 3600,
};

afterEach(() => {
jest.useRealTimers();
});

describe('createAuthAccess', () => {
it('posts credentials to the unified access endpoint', async () => {
const request = jest.fn(async () => response) as unknown as ApiRequest;

await expect(createAuthAccess(request)(credentials)).resolves.toEqual(response);
expect(request).toHaveBeenCalledWith('/auth/access', {
body: JSON.stringify(credentials),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
signal: expect.anything(),
});
});

it('exposes a documented business error code', async () => {
const request = jest.fn(async () => {
throw new ApiError(401, { error: { code: 'AUTH_INVALID_CREDENTIALS' } });
}) as unknown as ApiRequest;

await expect(createAuthAccess(request)(credentials)).rejects.toEqual(
new AuthAccessError('business', 'AUTH_INVALID_CREDENTIALS'),
);
});

it('distinguishes a network failure', async () => {
const request = jest.fn(async () => {
throw new TypeError('Failed to fetch');
}) as unknown as ApiRequest;

await expect(createAuthAccess(request)(credentials)).rejects.toEqual(
new AuthAccessError('network'),
);
});

it('aborts an authentication request after 15 seconds', () => {
jest.useFakeTimers();
let requestSignal: AbortSignal | undefined;
const request = jest.fn((_path: string, init?: RequestInit) => {
requestSignal = init?.signal ?? undefined;
return new Promise<never>(() => undefined);
}) as unknown as ApiRequest;

void createAuthAccess(request)(credentials);
jest.advanceTimersByTime(15_000);

expect(requestSignal).toBeDefined();
expect(requestSignal?.aborted).toBe(true);
});

it('reports an aborted authentication request as a timeout', async () => {
const request = jest.fn(async () => {
const error = new Error('The operation was aborted');
error.name = 'AbortError';
throw error;
}) as unknown as ApiRequest;

await expect(createAuthAccess(request)(credentials)).rejects.toMatchObject({
reason: 'timeout',
});
});

it('reports invalid success JSON as an invalid response', async () => {
const request = jest.fn(async () => {
throw new ApiResponseError(200);
}) as unknown as ApiRequest;

await expect(createAuthAccess(request)(credentials)).rejects.toEqual(
new AuthAccessError('invalid_response'),
);
});

it('rejects an invalid token response', async () => {
const request = jest.fn(async () => ({ account_id: 'acc_001' })) as unknown as ApiRequest;

await expect(createAuthAccess(request)(credentials)).rejects.toEqual(
new AuthAccessError('invalid_response'),
);
});

it('rejects a whitespace-only account id', async () => {
const request = jest.fn(async () => ({
...response,
account_id: ' ',
})) as unknown as ApiRequest;

await expect(createAuthAccess(request)(credentials)).rejects.toEqual(
new AuthAccessError('invalid_response'),
);
});

it('rejects a whitespace-only access token', async () => {
const request = jest.fn(async () => ({
...response,
access_token: ' ',
})) as unknown as ApiRequest;

await expect(createAuthAccess(request)(credentials)).rejects.toEqual(
new AuthAccessError('invalid_response'),
);
});
});
100 changes: 100 additions & 0 deletions frontend/src/api/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import {
AuthAccessError,
type AuthAccess,
type AuthAccessResponse,
type AuthErrorCode,
} from '../contracts/auth';
import {
ApiError,
ApiResponseError,
apiFetch,
type ApiRequest,
} from '../infrastructure/network/client';

const AUTH_ACCESS_TIMEOUT_MS = 15_000;

const AUTH_ERROR_CODES = new Set<AuthErrorCode>([
'AUTH_INVALID_USERNAME',
'AUTH_INVALID_PASSWORD',
'AUTH_INVALID_CREDENTIALS',
]);

/** 纯前端传输适配器;账号创建或密码校验均由服务端统一接口决定。 */
export function createAuthAccess(request: ApiRequest = apiFetch): AuthAccess {
return async (credentials) => {
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), AUTH_ACCESS_TIMEOUT_MS);

try {
const response = await request<unknown>('/auth/access', {
body: JSON.stringify(credentials),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
signal: abortController.signal,
});

// 只有完整 Token 响应才能进入页面的成功回调,避免伪造或误判登录成功。
if (!isAuthAccessResponse(response)) {
throw new AuthAccessError('invalid_response');
}

return response;
} catch (error) {
if (error instanceof AuthAccessError) {
throw error;
}
if (error instanceof ApiError) {
throw new AuthAccessError('business', readAuthErrorCode(error.body));
}
if (error instanceof ApiResponseError) {
throw new AuthAccessError('invalid_response');
}
if (isAbortError(error)) {
throw new AuthAccessError('timeout');
}
throw new AuthAccessError('network');
} finally {
clearTimeout(timeoutId);
}
};
}

export const accessAuth = createAuthAccess();

function isAuthAccessResponse(value: unknown): value is AuthAccessResponse {
if (!isRecord(value)) {
return false;
}

return (
isNonBlankString(value.account_id) &&
isNonBlankString(value.access_token) &&
typeof value.expires_in === 'number' &&
Number.isFinite(value.expires_in) &&
value.expires_in > 0
);
}

function readAuthErrorCode(body: unknown): AuthErrorCode | undefined {
if (!isRecord(body)) {
return undefined;
}

// 定义了错误码但未限定 JSON 外壳,因此兼容两种常见响应结构。
const candidate = isRecord(body.error) ? body.error.code : body.code;
return typeof candidate === 'string' && AUTH_ERROR_CODES.has(candidate as AuthErrorCode)
? (candidate as AuthErrorCode)
: undefined;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}

function isNonBlankString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}

function isAbortError(value: unknown): boolean {
return value instanceof Error && value.name === 'AbortError';
}
39 changes: 39 additions & 0 deletions frontend/src/app/AppRoot.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import { fireEvent, render, screen, waitFor } from '@testing-library/react-native';

import { accessAuth } from '../api/auth';
import type { AuthAccessResponse } from '../contracts/auth';
import { AppRoot } from './AppRoot';

jest.mock('../api/auth', () => ({
accessAuth: jest.fn(),
}));

const mockedAccessAuth = accessAuth as jest.MockedFunction<typeof accessAuth>;
const tokenResponse: AuthAccessResponse = {
account_id: 'acc_001',
access_token: 'access-token',
expires_in: 3600,
};

beforeEach(() => {
mockedAccessAuth.mockReset();
});

describe('AppRoot', () => {
it('leaves the login form after authentication without exposing the token', async () => {
mockedAccessAuth.mockResolvedValue(tokenResponse);
render(<AppRoot />);

fireEvent.changeText(screen.getByLabelText('用户名'), 'timeflow_user');
fireEvent.changeText(screen.getByLabelText('密码'), 'password123');
fireEvent.press(screen.getByRole('button', { name: '继续' }));

await waitFor(() => {
expect(screen.getByText('登录成功')).toBeTruthy();
});
expect(screen.queryByText('登录或注册')).toBeNull();
expect(screen.getByText('账号:acc_001')).toBeTruthy();
expect(screen.queryByText('access-token')).toBeNull();
});
});
31 changes: 23 additions & 8 deletions frontend/src/app/AppRoot.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,45 @@
import { StatusBar } from 'expo-status-bar';
import { useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';

import { accessAuth } from '../api/auth';
import type { AuthAccessResponse } from '../contracts/auth';
import { LoginScreen } from '../screens/LoginScreen';
import { colors, spacing } from '../shared/ui/theme';
import { AppProviders } from './AppProviders';

export function AppRoot() {
const [session, setSession] = useState<AuthAccessResponse>();

return (
<AppProviders>
<View style={styles.container}>
<Text style={styles.title}>Timeflow</Text>
<StatusBar style="auto" />
</View>
{session ? (
<View style={styles.authenticatedScreen}>
<Text style={styles.title}>登录成功</Text>
<Text style={styles.account}>账号:{session.account_id}</Text>
</View>
) : (
<LoginScreen authAccess={accessAuth} onAuthenticated={setSession} />
)}
</AppProviders>
);
}

const styles = StyleSheet.create({
container: {
account: {
color: colors.mutedText,
fontSize: 16,
marginTop: spacing.sm,
},
authenticatedScreen: {
alignItems: 'center',
backgroundColor: colors.background,
flex: 1,
justifyContent: 'center',
padding: spacing.xl,
},
title: {
color: colors.text,
fontSize: 24,
padding: spacing.md,
fontSize: 28,
fontWeight: '700',
},
});
Loading
Loading