From eec53c15b2e69e5a211046b1cd0eb42a114f2e75 Mon Sep 17 00:00:00 2001 From: Levi Date: Tue, 15 Sep 2026 00:41:47 +0800 Subject: [PATCH] Extend existing provider contracts and remove duplicate WebView CI --- .github/workflows/webview-profiles.yml | 54 ---- internal/webview/README.md | 16 +- .../goprovider/provider_integration_test.go | 2 + internal/webview/integrationtest/contract.go | 9 +- internal/webview/integrationtest/profiles.go | 234 +++++++++++++++ .../rpcprovider/provider_integration_test.go | 5 + ui/flutter/third_party/README.md | 3 +- ui/flutter/tool/webview_profile_check.dart | 271 ------------------ 8 files changed, 261 insertions(+), 333 deletions(-) delete mode 100644 .github/workflows/webview-profiles.yml create mode 100644 internal/webview/integrationtest/profiles.go delete mode 100644 ui/flutter/tool/webview_profile_check.dart diff --git a/.github/workflows/webview-profiles.yml b/.github/workflows/webview-profiles.yml deleted file mode 100644 index 85bb6d99..00000000 --- a/.github/workflows/webview-profiles.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: WebView host profiles -on: - push: - paths: ['go.mod', 'go.sum', 'internal/webview/**', 'pkg/download/extension_webview*', 'ui/flutter/third_party/**', 'ui/flutter/lib/app/rpc/webview*', 'ui/flutter/tool/webview_profile_check.dart', '.github/workflows/webview-profiles.yml'] - pull_request: - paths: ['go.mod', 'go.sum', 'internal/webview/**', 'pkg/download/extension_webview*', 'ui/flutter/third_party/**', 'ui/flutter/lib/app/rpc/webview*', 'ui/flutter/tool/webview_profile_check.dart', '.github/workflows/webview-profiles.yml'] - workflow_dispatch: -jobs: - android-plugin: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '17' - - uses: subosito/flutter-action@v2 - with: - flutter-version: '3.41.6' - - run: flutter pub get - working-directory: ui/flutter - - uses: gradle/actions/setup-gradle@v4 - with: - gradle-version: '8.14' - - name: Compile Android native plugin - working-directory: ui/flutter/android - run: | - printf 'flutter.sdk=%s\nsdk.dir=%s\n' "$FLUTTER_ROOT" "$ANDROID_HOME" > local.properties - gradle :flutter_inappwebview_android:compileDebugJavaWithJavac - apple-plugin: - runs-on: macos-14 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: '1.25.4' - - uses: subosito/flutter-action@v2 - with: - flutter-version: '3.41.6' - - name: Build Go host - run: go build -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib ./bind/desktop - - name: Check native macOS profiles - working-directory: ui/flutter - run: | - flutter pub get - flutter build macos --debug -t tool/webview_profile_check.dart - build/macos/Build/Products/Debug/Gopeed.app/Contents/MacOS/Gopeed - - name: Compile iOS native plugin - working-directory: ui/flutter - run: | - flutter build ios --simulator --config-only - cd ios - pod install - xcodebuild -project Pods/Pods.xcodeproj -target flutter_inappwebview_ios -sdk iphonesimulator -configuration Debug CODE_SIGNING_ALLOWED=NO build diff --git a/internal/webview/README.md b/internal/webview/README.md index 5e849659..a644bd7f 100644 --- a/internal/webview/README.md +++ b/internal/webview/README.md @@ -46,10 +46,14 @@ ignoring the proxy. - `go test -race ./internal/webview/proxy ./pkg/download/engine/webview` - `go test ./pkg/download -run 'TestWebViewProfileBound|TestDownloader_ExtensionRuntimeWebView'` -- In webview_go: `go run ./examples/profilecheck` (Linux: `xvfb-run -a ...`). -- In ui/flutter: `flutter run -d macos -t tool/webview_profile_check.dart`. +- Windows/Linux: `go test -tags webview -v ./internal/webview/goprovider` (Linux: run under `xvfb-run -a`). +- macOS: start the normal Flutter app, then run `go test -tags webview -v ./internal/webview/rpcprovider` with its socket address. See `rpcprovider/README.md`. +- In webview_go: `go test -tags webview_integration -v -count=1 .` (Linux: run under `xvfb-run -a`). -The native smoke tests cover routing and persistent profile isolation. The Flutter -smoke test also checks concurrent pages, HttpOnly cookies, LocalStorage, IndexedDB, -profile-scoped clearing, and reopening. The self-signed TLS fixture is trusted only -by that test's callback; production certificate validation is unchanged. +The shared provider contract covers HTTP/HTTPS proxy routing, concurrent pages, +HttpOnly cookies, LocalStorage, IndexedDB, profile-scoped clearing, reopening, +and removal without affecting another profile. HTTPS routing is checked with a +rejecting upstream tunnel; no test-only certificate bypass is added to the app. +Go proxy unit tests separately cover successful TLS forwarding and authentication. +These cases run in the existing `test.yml` WebView jobs; existing mobile build jobs +compile the Android/iOS bridge. No separate Flutter test application is required. diff --git a/internal/webview/goprovider/provider_integration_test.go b/internal/webview/goprovider/provider_integration_test.go index 92f226e7..e6d9e63e 100644 --- a/internal/webview/goprovider/provider_integration_test.go +++ b/internal/webview/goprovider/provider_integration_test.go @@ -13,3 +13,5 @@ func TestProviderContract(t *testing.T) { CookieDomainMode: integrationtest.CookieDomainModeRequired, }) } + +func TestProviderProfiles(t *testing.T) { integrationtest.RunProfileContract(t, New()) } diff --git a/internal/webview/integrationtest/contract.go b/internal/webview/integrationtest/contract.go index 4116a211..364fe5c2 100644 --- a/internal/webview/integrationtest/contract.go +++ b/internal/webview/integrationtest/contract.go @@ -248,7 +248,14 @@ func openTestPage(t *testing.T, provider enginewebview.Provider, opts enginewebv t.Fatalf("failed to open webview: %v", err) } t.Cleanup(func() { - _ = runtime.Close() + if err := runtime.Close(); err != nil { + t.Errorf("close contract page: %v", err) + } + if remover, ok := provider.(enginewebview.ProfileRemover); ok { + if err := remover.RemoveProfile(profileID, path); err != nil { + t.Errorf("remove contract profile: %v", err) + } + } }) return page, server, server.URL diff --git a/internal/webview/integrationtest/profiles.go b/internal/webview/integrationtest/profiles.go new file mode 100644 index 00000000..101a23a3 --- /dev/null +++ b/internal/webview/integrationtest/profiles.go @@ -0,0 +1,234 @@ +package integrationtest + +import ( + "crypto/sha256" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "reflect" + "runtime" + "sync/atomic" + "testing" + "time" + + webviewproxy "github.com/GopeedLab/gopeed/internal/webview/proxy" + webview "github.com/GopeedLab/gopeed/pkg/download/engine/webview" +) + +// RunProfileContract exercises the same host API through both native and RPC +// providers. It never bypasses the host bridge with direct Flutter plugin calls. +func RunProfileContract(t *testing.T, provider webview.Provider) { + t.Helper() + waitForProviderAvailable(t, provider, defaultAvailabilityTimeout) + remover, ok := provider.(webview.ProfileRemover) + if !ok { + t.Fatal("provider does not implement host profile removal") + } + proxyURL, secureHits := profileProxy(t) + a := newContractProfile(t, provider, remover, proxyURL) + b := newContractProfile(t, provider, remover, proxyURL) + first := a.open(t) + assertProfileValue(t, first, `async () => { localStorage.setItem('owner','a'); return 'a'; }`, "a") + assertProfileValue(t, first, profileDatabaseScript(true), "saved") + if err := first.SetCookie(webview.Cookie{Name: "session", Value: "a", Domain: "profiles.invalid", Path: "/", HTTPOnly: true, Expires: time.Now().Add(time.Hour)}); err != nil { + t.Fatal(err) + } + second := b.open(t) + assertProfileValue(t, second, `() => localStorage.getItem('owner')`, nil) + assertProfileValue(t, second, profileDatabaseScript(false), nil) + assertProfileCookies(t, second, "") + assertProfileValue(t, second, `() => { localStorage.setItem('owner','b'); return 'b'; }`, "b") + if err := second.SetCookie(webview.Cookie{Name: "session", Value: "b", Domain: "profiles.invalid", Path: "/", HTTPOnly: true}); err != nil { + t.Fatal(err) + } + shared := a.open(t) + assertProfileValue(t, shared, `() => localStorage.getItem('owner')`, "a") + assertProfileValue(t, shared, profileDatabaseScript(false), "saved") + assertProfileCookies(t, shared, "a") + assertProfileValue(t, shared, `() => document.cookie`, "") // HttpOnly stays hidden. + if err := second.ClearCookies(); err != nil { + t.Fatal(err) + } + assertProfileCookies(t, first, "a") + if err := second.SetCookie(webview.Cookie{Name: "session", Value: "b", Domain: "profiles.invalid", Path: "/", HTTPOnly: true}); err != nil { + t.Fatal(err) + } + a.close(t) + reopened := a.open(t) + assertProfileValue(t, reopened, `() => localStorage.getItem('owner')`, "a") + assertProfileValue(t, reopened, profileDatabaseScript(false), "saved") + assertProfileCookies(t, reopened, "a") + // Include a session cookie immediately before deletion to catch stale + // in-process CookieStore caches when the same profile is recreated. + if err := reopened.SetCookie(webview.Cookie{Name: "session", Value: "remove-me", Domain: "profiles.invalid", Path: "/", HTTPOnly: true}); err != nil { + t.Fatal(err) + } + a.close(t) + a.remove(t) + a.remove(t) // Retrying a successful uninstall is harmless. + reinstalled := a.open(t) + assertProfileValue(t, reinstalled, `() => localStorage.getItem('owner')`, nil) + assertProfileValue(t, reinstalled, profileDatabaseScript(false), nil) + assertProfileCookies(t, reinstalled, "") + assertProfileValue(t, second, `() => localStorage.getItem('owner')`, "b") + assertProfileCookies(t, second, "b") + // Refuse the HTTPS tunnel deliberately. Seeing it at our proxy proves HTTPS + // routing without adding a certificate-validation bypass to the test host. + if err := reinstalled.Goto("https://profiles.invalid/", webview.GotoOptions{TimeoutMS: 5000}); err == nil { + t.Fatal("HTTPS unexpectedly succeeded through a rejecting proxy") + } + if secureHits.Load() == 0 { + t.Fatal("HTTPS bypassed the host proxy") + } +} + +type contractProfile struct { + provider webview.Provider + remover webview.ProfileRemover + options webview.OpenOptions + pages []webview.Page +} + +func newContractProfile(t *testing.T, provider webview.Provider, remover webview.ProfileRemover, proxyURL string) *contractProfile { + t.Helper() + path := filepath.Join(t.TempDir(), "profile") + hash := sha256.Sum256([]byte(path)) + id := fmt.Sprintf("%x-%x-%x-%x-%x", hash[:4], hash[4:6], hash[6:8], hash[8:10], hash[10:16]) + p := &contractProfile{provider: provider, remover: remover, options: webview.OpenOptions{ProfileID: id, DataPath: path, ProxyURL: proxyURL, Headless: true, Title: "Gopeed profile contract"}} + t.Cleanup(func() { p.close(t); p.remove(t) }) + return p +} +func (p *contractProfile) open(t *testing.T) webview.Page { + t.Helper() + page, err := p.provider.Open(p.options) + if err != nil { + t.Fatal(err) + } + p.pages = append(p.pages, page) + if err := page.Goto("http://profiles.invalid/", webview.GotoOptions{TimeoutMS: 10000}); err != nil { + t.Fatal(err) + } + return page +} +func (p *contractProfile) close(t *testing.T) { + t.Helper() + for _, page := range p.pages { + if err := page.Close(); err != nil { + t.Errorf("close profile page: %v", err) + } + } + p.pages = nil +} +func (p *contractProfile) remove(t *testing.T) { + t.Helper() + if err := p.remover.RemoveProfile(p.options.ProfileID, p.options.DataPath); err != nil { + t.Errorf("remove profile: %v", err) + } +} +func assertProfileValue(t *testing.T, page webview.Page, script string, want any) { + t.Helper() + got, err := page.Execute(script) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("script %s: got %#v, want %#v", script, got, want) + } +} +func assertProfileCookies(t *testing.T, page webview.Page, want string) { + t.Helper() + cookies, err := page.GetCookies() + if err != nil { + t.Fatal(err) + } + if want == "" { + if len(cookies) != 0 { + t.Fatalf("profile cookies survived removal: %#v", cookies) + } + return + } + if len(cookies) != 1 || cookies[0].Name != "session" || cookies[0].Value != want || !cookies[0].HTTPOnly { + t.Fatalf("unexpected profile cookie store: %#v", cookies) + } +} +func profileDatabaseScript(write bool) string { + operation := "store.get('owner')" + if write { + operation = "store.put('saved','owner')" + } + return fmt.Sprintf(`async () => await new Promise((resolve,reject)=>{ + const request=indexedDB.open('profile-contract',1); + request.onupgradeneeded=()=>request.result.createObjectStore('state'); + request.onerror=()=>reject(String(request.error)); + request.onsuccess=()=>{ + const db=request.result, tx=db.transaction('state','readwrite'), store=tx.objectStore('state'); + const op=%s; let value; + op.onsuccess=()=>value=%s; + tx.oncomplete=()=>{db.close();resolve(value===undefined?null:value)}; + tx.onerror=()=>{db.close();reject(String(tx.error))}; + }; +})`, operation, map[bool]string{true: "'saved'", false: "op.result"}[write]) +} + +func profileProxy(t *testing.T) (string, *atomic.Int32) { + t.Helper() + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Header().Set("Cache-Control", "no-store") + fmt.Fprint(w, "Profile contract") + })) + t.Cleanup(origin.Close) + secureHits := &atomic.Int32{} + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodConnect { + origin.Config.Handler.ServeHTTP(w, r) + return + } + if r.Host == "profiles.invalid:443" { + secureHits.Add(1) + http.Error(w, "HTTPS routing fixture rejects tunnels", http.StatusBadGateway) + return + } + if r.Host != "profiles.invalid:80" { + http.Error(w, "unexpected target", http.StatusBadGateway) + return + } + remote, err := net.DialTimeout("tcp", origin.Listener.Addr().String(), 5*time.Second) + if err != nil { + http.Error(w, "origin unavailable", http.StatusBadGateway) + return + } + defer remote.Close() + client, buffer, err := w.(http.Hijacker).Hijack() + if err != nil { + return + } + defer client.Close() + if _, err := buffer.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil { + return + } + if err := buffer.Flush(); err != nil { + return + } + go func() { io.Copy(remote, buffer); remote.Close() }() + io.Copy(client, remote) + })) + t.Cleanup(upstream.Close) + upstreamURL, err := url.Parse(upstream.URL) + if err != nil { + t.Fatal(err) + } + proxy, err := webviewproxy.New(func(*http.Request) (*url.URL, error) { return upstreamURL, nil }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { proxy.Close() }) + if runtime.GOOS == "darwin" { + return proxy.SOCKSURL(), secureHits + } + return proxy.URL(), secureHits +} diff --git a/internal/webview/rpcprovider/provider_integration_test.go b/internal/webview/rpcprovider/provider_integration_test.go index 6ade9458..5f26ca56 100644 --- a/internal/webview/rpcprovider/provider_integration_test.go +++ b/internal/webview/rpcprovider/provider_integration_test.go @@ -37,3 +37,8 @@ func defaultWebViewRPCAddress() string { } return filepath.Join(homeDir, "Library", "Application Support", "com.gopeed.gopeed", "gopeed_webview.sock") } + +func TestProviderProfiles(t *testing.T) { + provider := New(enginewebview.RPCConfig{Network: *webViewRPCNetwork, Address: *webViewRPCAddress, Token: *webViewRPCToken}) + integrationtest.RunProfileContract(t, provider) +} diff --git a/ui/flutter/third_party/README.md b/ui/flutter/third_party/README.md index af35170b..9a0d8cc6 100644 --- a/ui/flutter/third_party/README.md +++ b/ui/flutter/third_party/README.md @@ -27,7 +27,8 @@ Android requires `MULTI_PROFILE` and `PROXY_OVERRIDE`; unsupported runtimes retu an explicit unavailable error rather than opening a shared, unproxied browser. When updating upstream, refresh these three packages, reapply the above changes, -and run `tool/webview_profile_check.dart` on a native Apple host. Do not patch the +and run the shared Go provider integration tests against the normal Flutter app +on a native Apple host (see `internal/webview/rpcprovider/README.md`). Do not patch the user's pub cache: dependency_overrides selects these tracked sources reproducibly. Android uses AndroidX WebKit 1.13.0 for profile-scoped full browsing-data deletion. diff --git a/ui/flutter/tool/webview_profile_check.dart b/ui/flutter/tool/webview_profile_check.dart deleted file mode 100644 index 2a84af54..00000000 --- a/ui/flutter/tool/webview_profile_check.dart +++ /dev/null @@ -1,271 +0,0 @@ -// Real native smoke test: flutter run -d macos -t tool/webview_profile_check.dart -import 'dart:async'; -import 'dart:io'; -import 'dart:convert'; -import 'package:flutter/material.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:gopeed/app/rpc/webview_profile.dart'; - -Future main() async { - WidgetsFlutterBinding.ensureInitialized(); - runApp(const MaterialApp(home: SizedBox())); - try { - await checkProfiles(); - stdout.writeln( - 'PASS: native proxy, concurrent profiles, cookies, LocalStorage, IndexedDB, reopening and profile deletion', - ); - exit(0); - } catch (error, stack) { - stderr.writeln('FAIL: $error\n$stack'); - exit(1); - } -} - -void expectValue(dynamic actual, dynamic expected) { - if (actual != expected) throw StateError('Expected $expected, got $actual'); -} - -Future checkProfiles() async { - final context = SecurityContext() - ..useCertificateChainBytes(utf8.encode(fixtureCertificate)) - ..usePrivateKeyBytes(utf8.encode(fixtureKey)); - final origin = await HttpServer.bindSecure(InternetAddress.loopbackIPv4, 0, context); - origin.listen((request) async { - request.response.headers.contentType = ContentType.html; - request.response.write('profile fixture'); - await request.response.close(); - }); - final plainOrigin = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - plainOrigin.listen((request) async { - request.response.headers.contentType = ContentType.html; - request.response.write('profile fixture'); - await request.response.close(); - }); - var proxyHits = 0; - final proxy = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); - proxy.listen((socket) async { - final input = StreamIterator(socket); - final buffer = []; - Future> read(int count) async { - while (buffer.length < count) { - if (!await input.moveNext()) throw StateError('Incomplete SOCKS handshake'); - buffer.addAll(input.current); - } - final value = buffer.sublist(0, count); - buffer.removeRange(0, count); - return value; - } - - try { - final hello = await read(2); - await read(hello[1]); - socket.add([5, 0]); - await socket.flush(); - final header = await read(4); - if (header[3] == 1) { - await read(4); - } else if (header[3] == 4) { - await read(16); - } else { - final size = await read(1); - await read(size[0]); - } - final port = await read(2); - final targetPort = port[0] * 256 + port[1]; - proxyHits++; - final remote = await Socket.connect( - InternetAddress.loopbackIPv4, - targetPort == 443 ? origin.port : plainOrigin.port, - ); - socket.add([5, 0, 0, 1, 127, 0, 0, 1, 0, 0]); - await socket.flush(); - remote.listen(socket.add, onDone: socket.destroy, onError: (_) => socket.destroy()); - if (buffer.isNotEmpty) remote.add(buffer); - while (await input.moveNext()) { - remote.add(input.current); - } - remote.destroy(); - } finally { - await input.cancel(); - socket.destroy(); - } - }); - final suffix = DateTime.now().microsecondsSinceEpoch.toRadixString(16).padLeft(12, '0'); - final a = WebViewProfile('11111111-2222-3333-4444-${suffix.substring(suffix.length - 12)}'); - final b = WebViewProfile('22222222-2222-3333-4444-${suffix.substring(suffix.length - 12)}'); - final proxyUrl = 'socks5://127.0.0.1:${proxy.port}'; - await a.prepare(proxyUrl); - await b.prepare(proxyUrl); - final target = WebUri('https://profiles.invalid/'); - final pages = []; - Future open(WebViewProfile profile) async { - await profile.prepare(proxyUrl); - final ready = Completer(); - final page = HeadlessInAppWebView( - onReceivedServerTrustAuthRequest: (_, _) async => - ServerTrustAuthResponse(action: ServerTrustAuthResponseAction.PROCEED), - initialSettings: WebViewProfileSettings(profileId: profile.id, debug: true), - onWebViewCreated: (controller) { - controller.loadUrl(urlRequest: URLRequest(url: target)); - }, - onLoadStop: (controller, url) { - if (url?.host == target.host && !ready.isCompleted) ready.complete(controller); - }, - onReceivedError: (_, _, error) { - if (!ready.isCompleted) ready.completeError(StateError(error.description)); - }, - ); - pages.add(page); - await page.run(); - return ready.future.timeout(const Duration(seconds: 20)); - } - - Future js(InAppWebViewController c, String source) => c.evaluateJavascript(source: source); - Future database(InAppWebViewController c, bool write) async { - final result = await c.callAsyncJavaScript( - functionBody: - ''' - return await new Promise((resolve,reject)=>{ - const request=indexedDB.open('profile-check',1); - request.onupgradeneeded=()=>request.result.createObjectStore('state'); - request.onerror=()=>reject(String(request.error)); - request.onsuccess=()=>{ - const db=request.result; - const tx=db.transaction('state','readwrite'); - const store=tx.objectStore('state'); - const op=${write ? "store.put('saved','owner')" : "store.get('owner')"}; - let value; - op.onsuccess=()=>value=op.result; - tx.oncomplete=()=>{db.close();resolve(value===undefined?null:value);}; - tx.onerror=()=>{db.close();reject(String(tx.error));}; - }; - }); - ''', - ); - if (result?.error != null) throw StateError(result!.error.toString()); - return result?.value; - } - - try { - final plainReady = Completer(); - final plain = HeadlessInAppWebView( - initialSettings: WebViewProfileSettings(profileId: a.id, debug: true), - initialUrlRequest: URLRequest(url: WebUri('http://profiles.invalid/')), - onLoadStop: (_, url) { - if (url?.host == 'profiles.invalid' && !plainReady.isCompleted) plainReady.complete(); - }, - onReceivedError: (_, _, error) { - if (!plainReady.isCompleted) plainReady.completeError(StateError(error.description)); - }, - ); - pages.add(plain); - await plain.run(); - await plainReady.future.timeout(const Duration(seconds: 20)); - if (proxyHits == 0) throw StateError('HTTP bypassed proxy'); - final first = await open(a); - await js(first, "localStorage.setItem('owner','saved')"); - await database(first, true); - await a.setCookie( - url: target, - name: 'session', - value: 'a', - domain: 'profiles.invalid', - isHttpOnly: true, - expiresDate: DateTime.now().add(const Duration(days: 1)).millisecondsSinceEpoch, - ); - final second = await open(b); - expectValue(await js(second, "localStorage.getItem('owner')"), null); - expectValue(await database(second, false), null); - expectValue((await b.getCookies(url: target)).length, 0); - final shared = await open(a); - expectValue(await js(shared, "localStorage.getItem('owner')"), 'saved'); - expectValue(await database(shared, false), 'saved'); - expectValue((await a.getCookies(url: target)).single.value, 'a'); - await b.deleteAllCookies(); - expectValue((await a.getCookies(url: target)).single.value, 'a'); - for (final page in pages) { - await page.dispose(); - } - pages.clear(); - final reopened = await open(a); - expectValue(await js(reopened, "localStorage.getItem('owner')"), 'saved'); - expectValue((await a.getCookies(url: target)).single.isHttpOnly, true); - await a.deleteCookie(url: target, name: 'session', domain: 'profiles.invalid'); - expectValue((await a.getCookies(url: target)).length, 0); - for (final page in pages) { - await page.dispose(); - } - pages.clear(); - final untouched = await open(b); - await js(untouched, "localStorage.setItem('owner','other')"); - await a.setCookie(url: target, name: 'delete-me', value: 'secret', isHttpOnly: true); - await a.remove(); - await a.remove(); // Uninstall retries must be safe. - final reinstalled = await open(a); - expectValue(await js(reinstalled, "localStorage.getItem('owner')"), null); - expectValue(await database(reinstalled, false), null); - expectValue((await a.getCookies(url: target)).length, 0); - expectValue(await js(untouched, "localStorage.getItem('owner')"), 'other'); - if (proxyHits == 0) throw StateError('Browser bypassed proxy'); - } finally { - for (final page in pages) { - await page.dispose(); - } - await a.remove(); - await b.remove(); - await proxy.close(); - await plainOrigin.close(force: true); - await origin.close(force: true); - } -} - -// Self-signed test fixture; trusted only by the smoke-test callback. -const fixtureCertificate = '''-----BEGIN CERTIFICATE----- -MIIDFzCCAf+gAwIBAgIUTVTmWtXNVthZEKsowr8XXq+WPf0wDQYJKoZIhvcNAQEL -BQAwGzEZMBcGA1UEAwwQcHJvZmlsZXMuaW52YWxpZDAeFw0yNjA5MTMxNjE4NTFa -Fw0yNjA5MTYxNjE4NTFaMBsxGTAXBgNVBAMMEHByb2ZpbGVzLmludmFsaWQwggEi -MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDM8AJ8i1fy++T4VjH1pyRo96Hw -7yQnjAKhbh4kD4w145bc1RgmWgJLqSSZaegXKsn5Cw2FKxCjA0TyZCTGq+fkDp45 -ERWN2TACMWGyz/7ROuAOVfIo92AnDMpdinn+wn9DdWo8z9UEnzCjs613CLCXmGOQ -9R/rTO2VlTlAM5VJygqOpBrtPzrx1ojJhL3fY9e5yYP0yxJqCf7ukCG/lrhHj5fK -7twD+tJMkkGkEMHqavmmTPlD8TsUYKB2Qf9orRPQIxNZkbtG+DEzckRhUJDBprI/ -pKVwG680d/40AZ3NvAjbz8WjmWGj2gBkSC1NdQ1k6a6MFJOPGnKfDepMk4S7AgMB -AAGjUzBRMB0GA1UdDgQWBBTwL0eACgwkw0isYAWpZwp1uqULWjAfBgNVHSMEGDAW -gBTwL0eACgwkw0isYAWpZwp1uqULWjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 -DQEBCwUAA4IBAQCplXaffZy1mYexyq744NFnIDBlnb3FLbBSG7tJt4SAY1dDysr9 -lNd8C5udrPxlmezUmlihzv8BqJcUNA0T9iYX6rOlNmD6xaaHs7akAGxvd8upIuM6 -iuKt3lXrE+xhVKGLs1XNYVOC+03H+sI1RxSOIpVPY43zSZXRXd8ADQ/+74KLsjIP -uQ1qUPG59C36Tfmp8UBA4pb3inPyRxcpGAREbXeX1zMuyoOs4c80CsV26Jh+sQFZ -VdSqtQrwzCLxb51D1MzeOakD69CWgM1hMIG12wqKvOz0d7uHLCXgIYLIYbaqgsF3 -FdpVXgGYktDl/GfVa2i9RLCZKUGOl2OuPFie ------END CERTIFICATE----- -'''; -const fixtureKey = '''-----BEGIN PRIVATE KEY----- -MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDM8AJ8i1fy++T4 -VjH1pyRo96Hw7yQnjAKhbh4kD4w145bc1RgmWgJLqSSZaegXKsn5Cw2FKxCjA0Ty -ZCTGq+fkDp45ERWN2TACMWGyz/7ROuAOVfIo92AnDMpdinn+wn9DdWo8z9UEnzCj -s613CLCXmGOQ9R/rTO2VlTlAM5VJygqOpBrtPzrx1ojJhL3fY9e5yYP0yxJqCf7u -kCG/lrhHj5fK7twD+tJMkkGkEMHqavmmTPlD8TsUYKB2Qf9orRPQIxNZkbtG+DEz -ckRhUJDBprI/pKVwG680d/40AZ3NvAjbz8WjmWGj2gBkSC1NdQ1k6a6MFJOPGnKf -DepMk4S7AgMBAAECggEACWGTO5BFXmIZgSSw9MXJ7OQpDZZk2UqXdDxONOhhSrY4 -WbOHf3nvUPdVLZCjhyv2Qug2njnycQPdTBs5c766xr3EkGgvzGZ8xBzuk/jheUJw -rzdP9oAcbq0vNkj4fKj6mmPtkQsSfTusRlIbRq+nJclcBW6zH1pOJeLG1CGIW/BG -wskEc9/86434Kmmot14f/nIGHPBPEL7MgsoXtMoM7lm/bOPekxd481SOA9j2h8Wl -wLVyFrtzTqDOeNBCZNBPpQeaWgBg4nSeX8TRUMAxDYhlMu0ENmYKp2LDfw6XXEii -yJsO07JNfcHQNyFPkW/E840KAvj+RzEUkQ4VhczLgQKBgQDncicAlvRWhCD//Jd7 -1FFMx8zaz6hKRrbdI0zzpzIle3Ebe7zXL84KMzLSP3pwD/ABwktoEhQjTjvo/VCh -pkvnqOgH8k9QUEyuzVhlhwC5RZOVfz3vnKTJMuCjsQ66OY1ljNVDWWLv5MkbVPlu -wmziIWqdnitN7X0F9oHnnshuiQKBgQDireplA0+aZfQFsiWm/YSaQa/QJUVBkA0N -La+C12O+dCr2n3gJsjVPteqVloJRymwpLtOW+3XWD4U4W7JJh3B+TNdtkgjT2jYF -GK/29B/mspB9aCXuMimq1XAyFC27vSR6PhiYPdQJ+dWtACigRXTZvjTdC7EvxSsZ -MNqjdO8oIwKBgHsXbATVQ/frZ72dcldqUR7buGi7Pk7akxDDYH7JclMa9sneIk3z -38nu9t144z5MS6Iz2nTsAIfSaOx+JO8ECaSYYWcwiNw3CMGC9rtwdIMUrOw6cw9H -qSBUjcKhPSrvxvK2VwnttT/O1uJRbCNrBguyKAjSAUf9wZt1QhuUiBa5AoGAc18p -Sbhhr6fsh2mgmFm4P/rmzP3rPe9fdTGnfuS5s5nUtCl+IxE86REiEHjGY3KvklZv -Aw2BcC3+FC03lQ0GSII7s1z8eTc6/2UNSpf+FKEVwX5cr9uAMZ7ot9RlYoRmKIGq -avItFda+1oqaIti3HIwew9LLoBCuWufun/tILhkCgYACBuavVB5q0JW520hb5KeA -ScD4+OLyxfKWYPvBNaIZLBP/lWuXQmxoXHvPRbxNjGQTldj1SbGS6yD5yvHs17vb -cFF+y2NS4hTD6VMMnl5sZT9OQjEEgj5/nnq+TlcHljpQ3q+OQ7rtJJFt832ZAXLd -ts2luZQYoJ+6aosodHGjCA== ------END PRIVATE KEY----- -''';