appsok/test/app_auth_test.dart

543 lines
17 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:appsok/src/app.dart';
import 'package:appsok/src/features/settings/settings_page.dart';
import 'package:appsok/src/services/jenkins_client.dart';
import 'package:appsok/src/services/token_store.dart';
import 'package:appsok/src/theme/app_theme.dart';
void main() {
setUp(() {
FlutterSecureStorage.setMockInitialValues({});
});
// ── AppSokApp pre-stored session restore ─────────────────────────
testWidgets(
'AppSokApp activates jobs loader when session is stored before build',
(tester) async {
final requestedPaths = <String>[];
FlutterSecureStorage.setMockInitialValues({
'jenkins.baseUrl': 'https://jenkins.example',
'jenkins.username': 'toki',
'jenkins.apiToken': 'generated-api-token',
});
await tester.pumpWidget(
AppSokApp(
jenkinsClient: JenkinsClient(
client: MockClient((request) async {
if (request.url.path.endsWith('whoAmI/api/json')) {
requestedPaths.add('whoAmI');
return http.Response(jsonEncode({'id': 'toki'}), 200);
}
if (request.url.path.endsWith('/api/json')) {
requestedPaths.add('jobs');
return http.Response(
jsonEncode({
'jobs': [
{
'name': 'post-login-job',
'fullName': 'post-login-job',
'url': 'https://jenkins.example/job/post-login-job/',
},
],
}),
200,
);
}
return http.Response(jsonEncode({}), 200);
}),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Jenkins 연결 필요'), findsNothing);
expect(find.text('post-login-job'), findsOneWidget);
expect(requestedPaths, containsAllInOrder(<String>['whoAmI', 'jobs']));
},
);
// ── AppSokApp._saveToken + restore success path ──────────────────
testWidgets(
'AppSokApp: web login with apiToken saves session, validates, activates jobs loader',
(tester) async {
// No stored session at startup.
FlutterSecureStorage.setMockInitialValues({});
final requestedPaths = <String>[];
await tester.pumpWidget(
AppSokApp(
jenkinsClient: JenkinsClient(
client: MockClient((request) async {
requestedPaths.add(request.url.path);
if (request.url.path.endsWith('whoAmI/api/json')) {
return http.Response(jsonEncode({'id': 'toki'}), 200);
}
if (request.url.path.endsWith('/api/json')) {
return http.Response(
jsonEncode({
'jobs': [
{
'name': 'web-login-job',
'fullName': 'web-login-job',
'url': 'https://jenkins.example/job/web-login-job/',
},
],
}),
200,
);
}
return http.Response(jsonEncode({}), 200);
}),
),
// launcher returns apiToken → triggers real _saveToken path
webLoginLauncher: (_) async => const JenkinsWebLoginResult(
username: 'toki',
displayName: 'Toki Lab',
apiToken: 'issued-token-abc',
),
),
);
await tester.pumpAndSettle();
// No session yet — jobs loader not active.
expect(find.text('Jenkins 연결 필요'), findsOneWidget);
// Navigate to Settings.
await tester.tap(
find.descendant(
of: find.byType(NavigationRail),
matching: find.text('설정'),
),
);
await tester.pumpAndSettle();
await tester.enterText(
find.byType(TextField).first,
'https://jenkins.example',
);
await tester.pump();
// Tap Web Login — triggers real _saveToken + restore path.
await tester.tap(find.byKey(const ValueKey('jenkins-web-login-button')));
await tester.pumpAndSettle();
// _saveToken saved and validated: token-saved-label must appear.
expect(find.byKey(const ValueKey('token-saved-label')), findsOneWidget);
// whoAmI was called (validate on save) and again (restore for _startup).
expect(
requestedPaths.where((p) => p.endsWith('whoAmI/api/json')).length,
greaterThanOrEqualTo(1),
);
// Navigate back to 빌드 tab to verify jobs loader is now active.
await tester.tap(
find.descendant(
of: find.byType(NavigationRail),
matching: find.text('빌드'),
),
);
await tester.pumpAndSettle();
expect(find.text('Jenkins 연결 필요'), findsNothing);
expect(find.text('web-login-job'), findsOneWidget);
},
);
// ── AppSokApp._saveToken + restore failure path ──────────────────
testWidgets(
'AppSokApp: web login with apiToken clears session and shows error when restore fails',
(tester) async {
FlutterSecureStorage.setMockInitialValues({});
await tester.pumpWidget(
AppSokApp(
jenkinsClient: JenkinsClient(
client: MockClient((request) async {
// All requests fail with 401.
return http.Response('Unauthorized', 401);
}),
),
webLoginLauncher: (_) async => const JenkinsWebLoginResult(
username: 'toki',
displayName: 'Toki Lab',
apiToken: 'bad-token',
),
),
);
await tester.pumpAndSettle();
// Navigate to Settings.
await tester.tap(
find.descendant(
of: find.byType(NavigationRail),
matching: find.text('설정'),
),
);
await tester.pumpAndSettle();
await tester.enterText(
find.byType(TextField).first,
'https://jenkins.example',
);
await tester.pump();
await tester.tap(find.byKey(const ValueKey('jenkins-web-login-button')));
await tester.pumpAndSettle();
// Save failed: error UI shown, success label absent.
expect(find.byKey(const ValueKey('token-save-error')), findsOneWidget);
expect(find.byKey(const ValueKey('token-saved-label')), findsNothing);
// Session must have been cleared — verify indirectly via jobs loader state.
await tester.tap(
find.descendant(
of: find.byType(NavigationRail),
matching: find.text('빌드'),
),
);
await tester.pumpAndSettle();
expect(find.text('Jenkins 연결 필요'), findsOneWidget);
},
);
testWidgets(
'AppSokApp: clearing credential removes stored session and disables jobs loader',
(tester) async {
final requestedPaths = <String>[];
FlutterSecureStorage.setMockInitialValues({
'jenkins.baseUrl': 'https://jenkins.example',
'jenkins.username': 'toki',
'jenkins.apiToken': 'generated-api-token',
});
await tester.pumpWidget(
AppSokApp(
jenkinsClient: JenkinsClient(
client: MockClient((request) async {
requestedPaths.add(request.url.path);
if (request.url.path.endsWith('whoAmI/api/json')) {
return http.Response(jsonEncode({'id': 'toki'}), 200);
}
if (request.url.path.endsWith('/api/json')) {
return http.Response(
jsonEncode({
'jobs': [
{
'name': 'stored-job',
'fullName': 'stored-job',
'url': 'https://jenkins.example/job/stored-job/',
},
],
}),
200,
);
}
return http.Response(jsonEncode({}), 200);
}),
),
),
);
await tester.pumpAndSettle();
expect(find.text('stored-job'), findsOneWidget);
expect(requestedPaths, containsAllInOrder(<String>['/whoAmI/api/json']));
await tester.tap(
find.descendant(
of: find.byType(NavigationRail),
matching: find.text('설정'),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey('jenkins-clear-session-button')),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('session-cleared-label')),
findsOneWidget,
);
expect(await TokenStore().readSession(), isNull);
await tester.tap(
find.descendant(
of: find.byType(NavigationRail),
matching: find.text('빌드'),
),
);
await tester.pumpAndSettle();
expect(find.text('Jenkins 연결 필요'), findsOneWidget);
expect(find.text('stored-job'), findsNothing);
},
);
// ── SettingsPage: apiToken → tokenSaver → onSessionSaved ────────
testWidgets(
'SettingsPage calls tokenSaver and onSessionSaved when launcher returns apiToken',
(tester) async {
var onSessionSavedCalled = false;
String? savedUsername;
String? savedToken;
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: SettingsPage(
tokenSaver: (baseUrl, username, apiToken) async {
savedUsername = username;
savedToken = apiToken;
return true;
},
onSessionSaved: () => onSessionSavedCalled = true,
webLoginLauncher: (_) async => const JenkinsWebLoginResult(
username: 'toki',
displayName: 'Toki Lab',
apiToken: 'issued-token-abc',
),
),
),
),
);
await tester.enterText(
find.byType(TextField).first,
'https://jenkins.example.com',
);
await tester.pump();
await tester.tap(find.byKey(const ValueKey('jenkins-web-login-button')));
await tester.pumpAndSettle();
expect(savedUsername, 'toki');
expect(savedToken, 'issued-token-abc');
expect(onSessionSavedCalled, isTrue);
expect(find.byKey(const ValueKey('token-saved-label')), findsOneWidget);
expect(tester.takeException(), isNull);
},
);
testWidgets(
'SettingsPage shows token-save-error when tokenSaver returns false',
(tester) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: SettingsPage(
tokenSaver: (baseUrl, username, apiToken) async => false,
onSessionSaved: () {},
webLoginLauncher: (_) async => const JenkinsWebLoginResult(
username: 'toki',
displayName: 'Toki Lab',
apiToken: 'bad-token',
),
),
),
),
);
await tester.enterText(
find.byType(TextField).first,
'https://jenkins.example.com',
);
await tester.pump();
await tester.tap(find.byKey(const ValueKey('jenkins-web-login-button')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('token-save-error')), findsOneWidget);
expect(find.byKey(const ValueKey('token-saved-label')), findsNothing);
expect(tester.takeException(), isNull);
},
);
testWidgets(
'SettingsPage shows error when launcher returns result without apiToken',
(tester) async {
var tokenSaverCalled = false;
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: SettingsPage(
tokenSaver: (baseUrl, username, apiToken) async {
tokenSaverCalled = true;
return true;
},
onSessionSaved: () {},
webLoginLauncher: (_) async => const JenkinsWebLoginResult(
username: 'toki',
displayName: 'Toki Lab',
),
),
),
),
);
await tester.enterText(
find.byType(TextField).first,
'https://jenkins.example.com',
);
await tester.pump();
await tester.tap(find.byKey(const ValueKey('jenkins-web-login-button')));
await tester.pumpAndSettle();
expect(tokenSaverCalled, isFalse);
expect(find.byKey(const ValueKey('web-login-result')), findsNothing);
expect(find.textContaining('API token 발급에 실패했습니다'), findsOneWidget);
expect(tester.takeException(), isNull);
},
);
// ── reauth / network failure restore paths ───────────────────────
testWidgets(
'AppSokApp shows reauth required state when stored credential returns 401',
(tester) async {
FlutterSecureStorage.setMockInitialValues({
'jenkins.baseUrl': 'https://jenkins.example',
'jenkins.username': 'toki',
'jenkins.apiToken': 'revoked-token',
});
await tester.pumpWidget(
AppSokApp(
jenkinsClient: JenkinsClient(
client: MockClient(
(request) async => http.Response('Unauthorized', 401),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('재로그인 필요'), findsOneWidget);
// Credential must be cleared after 401.
expect(await TokenStore().readSession(), isNull);
},
);
testWidgets(
'AppSokApp shows reauth required state when stored credential returns 403',
(tester) async {
FlutterSecureStorage.setMockInitialValues({
'jenkins.baseUrl': 'https://jenkins.example',
'jenkins.username': 'toki',
'jenkins.apiToken': 'forbidden-token',
});
await tester.pumpWidget(
AppSokApp(
jenkinsClient: JenkinsClient(
client: MockClient(
(request) async => http.Response('Forbidden', 403),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('재로그인 필요'), findsOneWidget);
expect(await TokenStore().readSession(), isNull);
},
);
testWidgets(
'AppSokApp shows network retry state when restore fails with IOException and preserves credential',
(tester) async {
FlutterSecureStorage.setMockInitialValues({
'jenkins.baseUrl': 'https://jenkins.example',
'jenkins.username': 'toki',
'jenkins.apiToken': 'valid-token',
});
await tester.pumpWidget(
AppSokApp(
jenkinsClient: JenkinsClient(
client: MockClient(
(request) async =>
throw const SocketException('Network unreachable'),
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Jenkins 연결 실패'), findsOneWidget);
// Credential preserved on network failure.
expect(await TokenStore().readSession(), isNotNull);
},
);
testWidgets(
'AppSokApp retry callback re-runs restore and activates jobs loader on success',
(tester) async {
FlutterSecureStorage.setMockInitialValues({
'jenkins.baseUrl': 'https://jenkins.example',
'jenkins.username': 'toki',
'jenkins.apiToken': 'valid-token',
});
var callCount = 0;
await tester.pumpWidget(
AppSokApp(
jenkinsClient: JenkinsClient(
client: MockClient((request) async {
callCount++;
if (callCount == 1) {
throw const SocketException('Network unreachable');
}
if (request.url.path.endsWith('whoAmI/api/json')) {
return http.Response(jsonEncode({'id': 'toki'}), 200);
}
if (request.url.path.endsWith('/api/json')) {
return http.Response(
jsonEncode({
'jobs': [
{
'name': 'retry-job',
'fullName': 'retry-job',
'url': 'https://jenkins.example/job/retry-job/',
},
],
}),
200,
);
}
return http.Response(jsonEncode({}), 200);
}),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Jenkins 연결 실패'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('job-status-action-button')));
await tester.pumpAndSettle();
expect(find.text('Jenkins 연결 실패'), findsNothing);
expect(find.text('retry-job'), findsOneWidget);
},
);
}