示例#1
0
  @Test
  public void testGenerateNewMetaGlobalNonePersisted() throws Exception {
    final MockGlobalSessionCallback callback = new MockGlobalSessionCallback();
    final GlobalSession session =
        MockPrefsGlobalSession.getSession(
            TEST_USERNAME,
            TEST_PASSWORD,
            new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY),
            callback,
            null,
            null);

    // Verify we fill in all of our known engines when none are persisted.
    session.config.enabledEngineNames = null;
    MetaGlobal mg = session.generateNewMetaGlobal();
    assertEquals(Long.valueOf(GlobalSession.STORAGE_VERSION), mg.getStorageVersion());
    assertEquals(
        VersionConstants.BOOKMARKS_ENGINE_VERSION,
        mg.getEngines().getObject("bookmarks").getIntegerSafely("version").intValue());
    assertEquals(
        VersionConstants.CLIENTS_ENGINE_VERSION,
        mg.getEngines().getObject("clients").getIntegerSafely("version").intValue());

    List<String> namesList = new ArrayList<String>(mg.getEnabledEngineNames());
    Collections.sort(namesList);
    String[] names = namesList.toArray(new String[namesList.size()]);
    String[] expected =
        new String[] {"bookmarks", "clients", "forms", "history", "passwords", "tabs"};
    assertArrayEquals(expected, names);
  }
示例#2
0
  @Test
  public void testUploadUpdatedMetaGlobal() throws Exception {
    // Set up session with meta/global.
    final MockGlobalSessionCallback callback = new MockGlobalSessionCallback();
    final GlobalSession session =
        MockPrefsGlobalSession.getSession(
            TEST_USERNAME,
            TEST_PASSWORD,
            new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY),
            callback,
            null,
            null);
    session.config.metaGlobal = session.generateNewMetaGlobal();
    session.enginesToUpdate.clear();

    // Set enabledEngines in meta/global, including a "new engine."
    String[] origEngines =
        new String[] {"bookmarks", "clients", "forms", "history", "tabs", "new-engine"};

    ExtendedJSONObject origEnginesJSONObject = new ExtendedJSONObject();
    for (String engineName : origEngines) {
      EngineSettings mockEngineSettings =
          new EngineSettings(Utils.generateGuid(), Integer.valueOf(0));
      origEnginesJSONObject.put(engineName, mockEngineSettings);
    }
    session.config.metaGlobal.setEngines(origEnginesJSONObject);

    // Engines to remove.
    String[] toRemove = new String[] {"bookmarks", "tabs"};
    for (String name : toRemove) {
      session.removeEngineFromMetaGlobal(name);
    }

    // Engines to add.
    String[] toAdd = new String[] {"passwords"};
    for (String name : toAdd) {
      String syncId = Utils.generateGuid();
      session.recordForMetaGlobalUpdate(name, new EngineSettings(syncId, Integer.valueOf(1)));
    }

    // Update engines.
    session.uploadUpdatedMetaGlobal();

    // Check resulting enabledEngines.
    Set<String> expected = new HashSet<String>();
    for (String name : origEngines) {
      expected.add(name);
    }
    for (String name : toRemove) {
      expected.remove(name);
    }
    for (String name : toAdd) {
      expected.add(name);
    }
    assertEquals(expected, session.config.metaGlobal.getEnabledEngineNames());
  }
示例#3
0
  /**
   * Override these in your subclasses.
   *
   * @return true if this stage should be executed.
   * @throws MetaGlobalException
   */
  protected boolean isEnabled() throws MetaGlobalException {
    EngineSettings engineSettings = null;
    try {
      engineSettings = getEngineSettings();
    } catch (Exception e) {
      Logger.warn(
          LOG_TAG, "Unable to get engine settings for " + this + ": fetching config failed.", e);
      // Fall through; null engineSettings will pass below.
    }

    // We can be disabled by the server's meta/global record, or malformed in the server's
    // meta/global record.
    // We catch the subclasses of MetaGlobalException to trigger various resets and wipes in
    // execute().
    boolean enabledInMetaGlobal = session.engineIsEnabled(this.getEngineName(), engineSettings);
    if (!enabledInMetaGlobal) {
      Logger.debug(LOG_TAG, "Stage " + this.getEngineName() + " disabled by server meta/global.");
      return false;
    }

    // We can also be disabled just for this sync.
    if (session.config.stagesToSync == null) {
      return true;
    }
    boolean enabledThisSync =
        session.config.stagesToSync.contains(
            this.getEngineName()); // For ServerSyncStage, stage name == engine name.
    if (!enabledThisSync) {
      Logger.debug(LOG_TAG, "Stage " + this.getEngineName() + " disabled just for this sync.");
    }
    return enabledThisSync;
  }
示例#4
0
 /**
  * Return a Crypto5Middleware-wrapped Server11Repository.
  *
  * @throws NoCollectionKeysSetException
  * @throws URISyntaxException
  */
 protected Repository wrappedServerRepo() throws NoCollectionKeysSetException, URISyntaxException {
   String collection = this.getCollection();
   KeyBundle collectionKey = session.keyBundleForCollection(collection);
   Crypto5MiddlewareRepository cryptoRepo =
       new Crypto5MiddlewareRepository(getRemoteRepository(), collectionKey);
   cryptoRepo.recordFactory = getRecordFactory();
   return cryptoRepo;
 }
示例#5
0
  /**
   * We failed to sync this engine! Do not persist timestamps (which means that the next sync will
   * include this sync's data), but do advance the session (if we didn't get a Retry-After header).
   *
   * @param synchronizer the <code>Synchronizer</code> that failed.
   */
  @Override
  public void onSynchronizeFailed(
      Synchronizer synchronizer, Exception lastException, String reason) {
    Logger.warn(LOG_TAG, "Synchronize failed: " + reason, lastException);

    // This failure could be due to a 503 or a 401 and it could have headers.
    // Interrogate the headers but only abort the global session if Retry-After header is set.
    if (lastException instanceof HTTPFailureException) {
      SyncStorageResponse response = ((HTTPFailureException) lastException).response;
      if (response.retryAfterInSeconds() > 0) {
        session.handleHTTPError(response, reason); // Calls session.abort().
        return;
      } else {
        session.interpretHTTPFailure(response.httpResponse()); // Does not call session.abort().
      }
    }

    Logger.info(LOG_TAG, "Advancing session even though stage failed. Timestamps not persisted.");
    session.advance();
  }
示例#6
0
  /**
   * We synced this engine! Persist timestamps and advance the session.
   *
   * @param synchronizer the <code>Synchronizer</code> that succeeded.
   */
  @Override
  public void onSynchronized(Synchronizer synchronizer) {
    Logger.debug(LOG_TAG, "onSynchronized.");

    SynchronizerConfiguration newConfig = synchronizer.save();
    if (newConfig != null) {
      persistConfig(newConfig);
    } else {
      Logger.warn(LOG_TAG, "Didn't get configuration from synchronizer after success.");
    }

    Logger.info(LOG_TAG, "Advancing session.");
    session.advance();
  }
  /**
   * Override these in your subclasses.
   *
   * @return true if this stage should be executed.
   * @throws MetaGlobalException
   */
  protected boolean isEnabled() throws MetaGlobalException {
    EngineSettings engineSettings = null;
    try {
      engineSettings = getEngineSettings();
    } catch (Exception e) {
      Logger.warn(
          LOG_TAG, "Unable to get engine settings for " + this + ": fetching config failed.", e);
      // Fall through; null engineSettings will pass below.
    }

    // We catch the subclasses of MetaGlobalException to trigger various resets and wipes in
    // execute().
    return session.engineIsEnabled(this.getEngineName(), engineSettings);
  }
示例#8
0
  @Test
  public void testGetSyncStagesBy()
      throws SyncConfigurationException, IllegalArgumentException, NonObjectJSONException,
          IOException, ParseException, CryptoException, NoSuchStageException {

    final MockGlobalSessionCallback callback = new MockGlobalSessionCallback();
    GlobalSession s =
        MockPrefsGlobalSession.getSession(
            TEST_USERNAME,
            TEST_PASSWORD,
            new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY),
            callback, /* context */
            null,
            null);

    assertTrue(
        s.getSyncStageByName(Stage.syncBookmarks)
            instanceof AndroidBrowserBookmarksServerSyncStage);

    final Set<String> empty = new HashSet<String>();

    final Set<String> bookmarksAndTabsNames = new HashSet<String>();
    bookmarksAndTabsNames.add("bookmarks");
    bookmarksAndTabsNames.add("tabs");

    final Set<GlobalSyncStage> bookmarksAndTabsSyncStages = new HashSet<GlobalSyncStage>();
    GlobalSyncStage bookmarksStage = s.getSyncStageByName("bookmarks");
    GlobalSyncStage tabsStage = s.getSyncStageByName(Stage.syncTabs);
    bookmarksAndTabsSyncStages.add(bookmarksStage);
    bookmarksAndTabsSyncStages.add(tabsStage);

    final Set<Stage> bookmarksAndTabsEnums = new HashSet<Stage>();
    bookmarksAndTabsEnums.add(Stage.syncBookmarks);
    bookmarksAndTabsEnums.add(Stage.syncTabs);

    assertTrue(s.getSyncStagesByName(empty).isEmpty());
    assertEquals(
        bookmarksAndTabsSyncStages,
        new HashSet<GlobalSyncStage>(s.getSyncStagesByName(bookmarksAndTabsNames)));
    assertEquals(
        bookmarksAndTabsSyncStages,
        new HashSet<GlobalSyncStage>(s.getSyncStagesByEnum(bookmarksAndTabsEnums)));
  }
示例#9
0
  @Override
  public void execute() throws NoSuchStageException {
    final String name = getEngineName();
    Logger.debug(LOG_TAG, "Starting execute for " + name);

    try {
      if (!this.isEnabled()) {
        Logger.info(LOG_TAG, "Skipping stage " + name + ".");
        session.advance();
        return;
      }
    } catch (MetaGlobalException.MetaGlobalMalformedSyncIDException e) {
      // Bad engine syncID. This should never happen. Wipe the server.
      try {
        session.updateMetaGlobalWith(
            name, new EngineSettings(Utils.generateGuid(), this.getStorageVersion()));
        Logger.info(
            LOG_TAG, "Wiping server because malformed engine sync ID was found in meta/global.");
        wipeServer();
        Logger.info(LOG_TAG, "Wiped server after malformed engine sync ID found in meta/global.");
      } catch (Exception ex) {
        session.abort(
            ex, "Failed to wipe server after malformed engine sync ID found in meta/global.");
      }
    } catch (MetaGlobalException.MetaGlobalMalformedVersionException e) {
      // Bad engine version. This should never happen. Wipe the server.
      try {
        session.updateMetaGlobalWith(
            name, new EngineSettings(Utils.generateGuid(), this.getStorageVersion()));
        Logger.info(
            LOG_TAG, "Wiping server because malformed engine version was found in meta/global.");
        wipeServer();
        Logger.info(LOG_TAG, "Wiped server after malformed engine version found in meta/global.");
      } catch (Exception ex) {
        session.abort(
            ex, "Failed to wipe server after malformed engine version found in meta/global.");
      }
    } catch (MetaGlobalException.MetaGlobalStaleClientSyncIDException e) {
      // Our syncID is wrong. Reset client and take the server syncID.
      Logger.warn(
          LOG_TAG,
          "Remote engine syncID different from local engine syncID:"
              + " resetting local engine and assuming remote engine syncID.");
      this.resetLocal(e.serverSyncID);
    } catch (MetaGlobalException e) {
      session.abort(e, "Inappropriate meta/global; refusing to execute " + name + " stage.");
      return;
    }

    Synchronizer synchronizer;
    try {
      synchronizer = this.getConfiguredSynchronizer(session);
    } catch (NoCollectionKeysSetException e) {
      session.abort(e, "No CollectionKeys.");
      return;
    } catch (URISyntaxException e) {
      session.abort(e, "Invalid URI syntax for server repository.");
      return;
    } catch (NonObjectJSONException e) {
      session.abort(e, "Invalid persisted JSON for config.");
      return;
    } catch (IOException e) {
      session.abort(e, "Invalid persisted JSON for config.");
      return;
    } catch (ParseException e) {
      session.abort(e, "Invalid persisted JSON for config.");
      return;
    }

    Logger.debug(LOG_TAG, "Invoking synchronizer.");
    synchronizer.synchronize(session.getContext(), this);
    Logger.debug(LOG_TAG, "Reached end of execute.");
  }
示例#10
0
  /**
   * Synchronously wipe this stage by instantiating a local repository session and wiping that.
   *
   * <p>Logs and re-throws an exception on failure.
   */
  @Override
  public void wipeLocal() throws Exception {
    // Reset, then clear data.
    this.resetLocal();

    final WipeWaiter monitor = new WipeWaiter();
    final Context context = session.getContext();
    final Repository r = this.getLocalRepository();

    final Runnable doWipe =
        new Runnable() {
          @Override
          public void run() {
            r.createSession(
                new RepositorySessionCreationDelegate() {

                  @Override
                  public void onSessionCreated(final RepositorySession session) {
                    try {
                      session.begin(
                          new RepositorySessionBeginDelegate() {

                            @Override
                            public void onBeginSucceeded(final RepositorySession session) {
                              session.wipe(
                                  new RepositorySessionWipeDelegate() {
                                    @Override
                                    public void onWipeSucceeded() {
                                      try {
                                        session.finish(
                                            new RepositorySessionFinishDelegate() {

                                              @Override
                                              public void onFinishSucceeded(
                                                  RepositorySession session,
                                                  RepositorySessionBundle bundle) {
                                                // Hurrah.
                                                synchronized (monitor) {
                                                  monitor.notify();
                                                }
                                              }

                                              @Override
                                              public void onFinishFailed(Exception ex) {
                                                // Assume that no finish => no wipe.
                                                synchronized (monitor) {
                                                  monitor.notify(ex, true);
                                                }
                                              }

                                              @Override
                                              public RepositorySessionFinishDelegate
                                                  deferredFinishDelegate(ExecutorService executor) {
                                                return this;
                                              }
                                            });
                                      } catch (InactiveSessionException e) {
                                        // Cannot happen. Call for safety.
                                        synchronized (monitor) {
                                          monitor.notify(e, true);
                                        }
                                      }
                                    }

                                    @Override
                                    public void onWipeFailed(Exception ex) {
                                      session.abort();
                                      synchronized (monitor) {
                                        monitor.notify(ex, true);
                                      }
                                    }

                                    @Override
                                    public RepositorySessionWipeDelegate deferredWipeDelegate(
                                        ExecutorService executor) {
                                      return this;
                                    }
                                  });
                            }

                            @Override
                            public void onBeginFailed(Exception ex) {
                              session.abort();
                              synchronized (monitor) {
                                monitor.notify(ex, true);
                              }
                            }

                            @Override
                            public RepositorySessionBeginDelegate deferredBeginDelegate(
                                ExecutorService executor) {
                              return this;
                            }
                          });
                    } catch (InvalidSessionTransitionException e) {
                      session.abort();
                      synchronized (monitor) {
                        monitor.notify(e, true);
                      }
                    }
                  }

                  @Override
                  public void onSessionCreateFailed(Exception ex) {
                    synchronized (monitor) {
                      monitor.notify(ex, false);
                    }
                  }

                  @Override
                  public RepositorySessionCreationDelegate deferredCreationDelegate() {
                    return this;
                  }
                },
                context);
          }
        };

    final Thread wiping = new Thread(doWipe);
    synchronized (monitor) {
      wiping.start();
      try {
        monitor.wait();
      } catch (InterruptedException e) {
        Logger.error(LOG_TAG, "Wipe interrupted.");
      }
    }

    if (!monitor.sessionSucceeded) {
      Logger.error(LOG_TAG, "Failed to create session for wipe.");
      throw monitor.error;
    }

    if (!monitor.wipeSucceeded) {
      Logger.error(LOG_TAG, "Failed to wipe session.");
      throw monitor.error;
    }

    Logger.info(LOG_TAG, "Wiping stage complete.");
  }
示例#11
0
  /**
   * Clean the server, aborting the current sync.
   *
   * <p>
   *
   * <ol>
   *   <li>Wipe the server storage.
   *   <li>Reset all stages and purge cached state: (meta/global and crypto/keys records).
   *   <li>Upload fresh meta/global record.
   *   <li>Upload fresh crypto/keys record.
   *   <li>Restart the sync entirely in order to re-download meta/global and crypto/keys record.
   * </ol>
   *
   * @param session the current session.
   * @param freshStartDelegate delegate to notify on fresh start or failure.
   */
  protected static void freshStart(
      final GlobalSession session, final FreshStartDelegate freshStartDelegate) {
    Logger.debug(LOG_TAG, "Fresh starting.");

    final MetaGlobal mg = session.generateNewMetaGlobal();

    session.wipeServer(
        session.getAuthHeaderProvider(),
        new WipeServerDelegate() {

          @Override
          public void onWiped(long timestamp) {
            Logger.debug(
                LOG_TAG,
                "Successfully wiped server.  Resetting all stages and purging cached meta/global and crypto/keys records.");

            session.resetAllStages();
            session.config.purgeMetaGlobal();
            session.config.purgeCryptoKeys();
            session.config.persistToPrefs();

            Logger.info(LOG_TAG, "Uploading new meta/global with sync ID " + mg.syncID + ".");

            // It would be good to set the X-If-Unmodified-Since header to `timestamp`
            // for this PUT to ensure at least some level of transactionality.
            // Unfortunately, the servers don't support it after a wipe right now
            // (bug 693893), so we're going to defer this until bug 692700.
            mg.upload(
                new MetaGlobalDelegate() {
                  @Override
                  public void handleSuccess(
                      MetaGlobal uploadedGlobal, SyncStorageResponse uploadResponse) {
                    Logger.info(
                        LOG_TAG,
                        "Uploaded new meta/global with sync ID " + uploadedGlobal.syncID + ".");

                    // Generate new keys.
                    CollectionKeys keys = null;
                    try {
                      keys = session.generateNewCryptoKeys();
                    } catch (CryptoException e) {
                      Logger.warn(
                          LOG_TAG, "Got exception generating new keys; failing fresh start.", e);
                      freshStartDelegate.onFreshStartFailed(e);
                    }
                    if (keys == null) {
                      Logger.warn(
                          LOG_TAG, "Got null keys from generateNewKeys; failing fresh start.");
                      freshStartDelegate.onFreshStartFailed(null);
                    }

                    // Upload new keys.
                    Logger.info(LOG_TAG, "Uploading new crypto/keys.");
                    session.uploadKeys(
                        keys,
                        new KeyUploadDelegate() {
                          @Override
                          public void onKeysUploaded() {
                            Logger.info(LOG_TAG, "Uploaded new crypto/keys.");
                            freshStartDelegate.onFreshStart();
                          }

                          @Override
                          public void onKeyUploadFailed(Exception e) {
                            Logger.warn(LOG_TAG, "Got exception uploading new keys.", e);
                            freshStartDelegate.onFreshStartFailed(e);
                          }
                        });
                  }

                  @Override
                  public void handleMissing(MetaGlobal global, SyncStorageResponse response) {
                    // Shouldn't happen on upload.
                    Logger.warn(LOG_TAG, "Got 'missing' response uploading new meta/global.");
                    freshStartDelegate.onFreshStartFailed(
                        new Exception("meta/global missing while uploading."));
                  }

                  @Override
                  public void handleFailure(SyncStorageResponse response) {
                    Logger.warn(
                        LOG_TAG,
                        "Got failure " + response.getStatusCode() + " uploading new meta/global.");
                    session.interpretHTTPFailure(response.httpResponse());
                    freshStartDelegate.onFreshStartFailed(new HTTPFailureException(response));
                  }

                  @Override
                  public void handleError(Exception e) {
                    Logger.warn(LOG_TAG, "Got error uploading new meta/global.", e);
                    freshStartDelegate.onFreshStartFailed(e);
                  }
                });
          }

          @Override
          public void onWipeFailed(Exception e) {
            Logger.warn(LOG_TAG, "Wipe failed.");
            freshStartDelegate.onFreshStartFailed(e);
          }
        });
  }
  /**
   * Now that we have a sync key and password, go ahead and do the work.
   *
   * @throws NoSuchAlgorithmException
   * @throws IllegalArgumentException
   * @throws SyncConfigurationException
   * @throws AlreadySyncingException
   * @throws NonObjectJSONException
   * @throws ParseException
   * @throws IOException
   * @throws CryptoException
   */
  protected void performSync(
      final Account account,
      final Bundle extras,
      final String authority,
      final ContentProviderClient provider,
      final SyncResult syncResult,
      final String username,
      final String password,
      final String prefsPath,
      final String serverURL,
      final String syncKey)
      throws NoSuchAlgorithmException, SyncConfigurationException, IllegalArgumentException,
          AlreadySyncingException, IOException, ParseException, NonObjectJSONException,
          CryptoException {
    Logger.trace(LOG_TAG, "Performing sync.");
    syncStartTimestamp = System.currentTimeMillis();

    /**
     * Bug 769745: pickle Sync account parameters to JSON file. Un-pickle in <code>
     * SyncAccounts.syncAccountsExist</code>.
     */
    try {
      // Constructor can throw on nulls, which should not happen -- but let's be safe.
      final SyncAccountParameters params =
          new SyncAccountParameters(
              mContext,
              null,
              account.name, // Un-encoded, like "*****@*****.**".
              syncKey,
              password,
              serverURL,
              null, // We'll re-fetch cluster URL; not great, but not harmful.
              getClientName(),
              getAccountGUID());

      // Bug 772971: pickle Sync account parameters on background thread to
      // avoid strict mode warnings.
      ThreadPool.run(
          new Runnable() {
            @Override
            public void run() {
              final boolean syncAutomatically =
                  ContentResolver.getSyncAutomatically(account, authority);
              try {
                AccountPickler.pickle(
                    mContext, Constants.ACCOUNT_PICKLE_FILENAME, params, syncAutomatically);
              } catch (Exception e) {
                // Should never happen, but we really don't want to die in a background thread.
                Logger.warn(
                    LOG_TAG, "Got exception pickling current account details; ignoring.", e);
              }
            }
          });
    } catch (IllegalArgumentException e) {
      // Do nothing.
    }

    // TODO: default serverURL.
    final KeyBundle keyBundle = new KeyBundle(username, syncKey);
    GlobalSession globalSession =
        new GlobalSession(
            SyncConfiguration.DEFAULT_USER_API,
            serverURL,
            username,
            password,
            prefsPath,
            keyBundle,
            this,
            this.mContext,
            extras,
            this);

    globalSession.start();
  }
示例#13
0
  public void execute(final GlobalSession session) throws NoSuchStageException {

    if (session.config.getClusterURL() != null) {
      Log.i(LOG_TAG, "Cluster URL already set. Continuing with sync.");
      session.advance();
      return;
    }

    Log.i(LOG_TAG, "Fetching cluster URL.");
    final ClusterURLFetchDelegate delegate =
        new ClusterURLFetchDelegate() {

          @Override
          public void handleSuccess(final String url) {
            Log.i(LOG_TAG, "Node assignment pointed us to " + url);

            try {
              session.config.setClusterURL(url);
              ThreadPool.run(
                  new Runnable() {
                    @Override
                    public void run() {
                      session.advance();
                    }
                  });
              return;
            } catch (URISyntaxException e) {
              final URISyntaxException uriException = e;
              ThreadPool.run(
                  new Runnable() {
                    @Override
                    public void run() {
                      session.abort(uriException, "Invalid cluster URL.");
                    }
                  });
            }
          }

          @Override
          public void handleFailure(HttpResponse response) {
            int statusCode = response.getStatusLine().getStatusCode();
            Log.w(LOG_TAG, "Got HTTP failure fetching node assignment: " + statusCode);
            if (statusCode == 404) {
              URI serverURL = session.config.serverURL;
              if (serverURL != null) {
                Log.i(
                    LOG_TAG, "Using serverURL <" + serverURL.toASCIIString() + "> as clusterURL.");
                session.config.setClusterURL(serverURL);
                session.advance();
                return;
              }
              Log.w(LOG_TAG, "No serverURL set to use as fallback cluster URL. Aborting sync.");
              // Fallthrough to abort.
            } else {
              session.interpretHTTPFailure(response);
            }
            session.abort(new Exception("HTTP failure."), "Got failure fetching cluster URL.");
          }

          @Override
          public void handleError(Exception e) {
            session.abort(e, "Got exception fetching cluster URL.");
          }
        };

    ThreadPool.run(
        new Runnable() {
          @Override
          public void run() {
            try {
              fetchClusterURL(session, delegate);
            } catch (URISyntaxException e) {
              session.abort(e, "Invalid URL for node/weave.");
            }
          }
        });
  }
示例#14
0
 public void testStageAdvance() {
   assertEquals(GlobalSession.nextStage(Stage.idle), Stage.checkPreconditions);
   assertEquals(GlobalSession.nextStage(Stage.completed), Stage.idle);
 }