BareGit

Strengthen internal game visibility handling

Propagate invalid persisted visibility errors correctly. Expand persistence,
service, and HTTP coverage for internal game workflows and access boundaries.
Author: MetroWind <chris.corsair@gmail.com>
Date: Fri Sep 11 09:16:33 2026 -0700
Commit: ebfecc1036a95d8468f52cf12fe6a94cb7ed3d73

Changes

diff --git a/src/data_sqlite.cpp b/src/data_sqlite.cpp
index e065ec9..9740476 100644
--- a/src/data_sqlite.cpp
+++ b/src/data_sqlite.cpp
@@ -546,10 +546,14 @@ mw::E<std::optional<GameDefinitionSnapshot>> loadGameDefinition(
     auto& [stored_short_name, display_name, description, visibility_value,
            revision] = game_rows->front();
     auto visibility = gameVisibilityFromInteger(visibility_value);
-    if(!visibility || revision < 1)
+    if(!visibility)
+    {
+        return std::unexpected(std::move(visibility.error()));
+    }
+    if(revision < 1)
     {
         return std::unexpected(mw::runtimeError(
-            "Database contains invalid game metadata"));
+            "Database contains an invalid game revision"));
     }
     if(scope == GameContentScope::PUBLIC_ONLY &&
        *visibility == GameVisibility::INTERNAL)
@@ -2597,7 +2601,11 @@ mw::E<std::vector<Game>> DataSourceSQLite::getGames(
                revision] : *rows)
     {
         auto visibility = gameVisibilityFromInteger(visibility_value);
-        if(!visibility || revision < 1)
+        if(!visibility)
+        {
+            return std::unexpected(std::move(visibility.error()));
+        }
+        if(revision < 1)
         {
             return std::unexpected(mw::runtimeError(
                 "Database contains an invalid game revision"));
diff --git a/tests/app_integration_test.cpp b/tests/app_integration_test.cpp
index c0f80e3..42c8b66 100644
--- a/tests/app_integration_test.cpp
+++ b/tests/app_integration_test.cpp
@@ -377,7 +377,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
         {"short_name", "Invalid-Game"},
         {"display_name", "Invalid Game"},
         {"description", ""},
-        {"visibility", "PUBLIC"},
+        {"visibility", "INTERNAL"},
     };
     auto invalid_game = client.Post(
         "/collection/admin/games",
@@ -392,6 +392,25 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
         std::string::npos);
     EXPECT_NE(invalid_game->body.find("Invalid-Game"),
               std::string::npos);
+    const std::size_t retained_internal_choice = invalid_game->body.find(
+        "value=\"INTERNAL\"");
+    ASSERT_NE(retained_internal_choice, std::string::npos);
+    EXPECT_LT(
+        invalid_game->body.find("checked", retained_internal_choice),
+        invalid_game->body.find('>', retained_internal_choice));
+
+    const httplib::Params missing_visibility_fields = {
+        {"csrf_token", csrf_token},
+        {"short_name", "missingvisibility"},
+        {"display_name", "Missing visibility"},
+        {"description", ""},
+    };
+    auto missing_visibility = client.Post(
+        "/collection/admin/games",
+        session_headers,
+        missing_visibility_fields);
+    ASSERT_NE(missing_visibility, nullptr);
+    EXPECT_EQ(missing_visibility->status, 400);
 
     const httplib::Params invalid_visibility_fields = {
         {"csrf_token", csrf_token},
@@ -418,6 +437,34 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
     ASSERT_NE(duplicate_visibility, nullptr);
     EXPECT_EQ(duplicate_visibility->status, 400);
 
+    const httplib::Params internal_create_fields = {
+        {"csrf_token", csrf_token},
+        {"short_name", "internalonly"},
+        {"display_name", "Internal only"},
+        {"description", ""},
+        {"visibility", "INTERNAL"},
+    };
+    auto internal_created = client.Post(
+        "/collection/admin/games",
+        session_headers,
+        internal_create_fields);
+    ASSERT_NE(internal_created, nullptr);
+    ASSERT_EQ(internal_created->status, 303) << internal_created->body;
+    auto internal_created_page = client.Get(
+        "/collection/admin/games/internalonly/edit", session_headers);
+    ASSERT_NE(internal_created_page, nullptr);
+    ASSERT_EQ(internal_created_page->status, 200);
+    EXPECT_NE(internal_created_page->body.find("Internal"),
+              std::string::npos);
+    const httplib::Params delete_internal_fields = {
+        {"csrf_token", csrf_token}, {"game_revision", "1"}};
+    auto internal_deleted = client.Post(
+        "/collection/admin/games/internalonly/delete",
+        session_headers,
+        delete_internal_fields);
+    ASSERT_NE(internal_deleted, nullptr);
+    ASSERT_EQ(internal_deleted->status, 303) << internal_deleted->body;
+
     auto dynamic_game = client.Post(
         "/collection/admin/games",
         session_headers,
@@ -859,13 +906,6 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
         "/collection/admin/series/2", session_headers, edit_series_fields);
     ASSERT_NE(edited_series, nullptr);
     EXPECT_EQ(edited_series->status, 303) << edited_series->body;
-    auto deleted_series = client.Post(
-        "/collection/admin/series/2/delete",
-        session_headers,
-        csrf_fields);
-    ASSERT_NE(deleted_series, nullptr);
-    EXPECT_EQ(deleted_series->status, 303) << deleted_series->body;
-
     auto player_authentication = client.Get("/collection/authentication");
     ASSERT_NE(player_authentication, nullptr);
     const std::string player_auth_cookie = responseCookie(
@@ -958,6 +998,48 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
         player_game_fields);
     ASSERT_NE(player_game_create, nullptr);
     EXPECT_EQ(player_game_create->status, 403);
+    auto promoted = client.Post(
+        "/collection/admin/users/2/promote",
+        session_headers,
+        csrf_fields);
+    ASSERT_NE(promoted, nullptr);
+    ASSERT_EQ(promoted->status, 303) << promoted->body;
+    const httplib::UploadFormDataItems creator_game_card_fields = {
+        {"csrf_token", player_csrf, "", ""},
+        {"game", "gh", "", ""},
+        {"game_revision", "1", "", ""},
+        {"name", "Creator Going Home", "", ""},
+        {"source_mode", "files", "", ""},
+        {"front", upload_bytes, "card.png", "image/png"},
+    };
+    auto creator_game_card = client.Post(
+        "/collection/cards", player_headers, creator_game_card_fields);
+    ASSERT_NE(creator_game_card, nullptr);
+    ASSERT_EQ(creator_game_card->status, 303) << creator_game_card->body;
+    auto creator_public_cards = client.Get(
+        "/collection/creator/cards", player_headers);
+    ASSERT_NE(creator_public_cards, nullptr);
+    ASSERT_EQ(creator_public_cards->status, 200);
+    EXPECT_NE(creator_public_cards->body.find("Creator Going Home"),
+              std::string::npos);
+    const httplib::Params player_pull_fields = {
+        {"csrf_token", player_csrf}};
+    auto initial_player_pull = client.Post(
+        "/collection/collection/pull",
+        player_headers,
+        player_pull_fields);
+    ASSERT_NE(initial_player_pull, nullptr);
+    ASSERT_EQ(initial_player_pull->status, 303)
+        << initial_player_pull->body;
+    EXPECT_EQ(
+        urlRequestPath(initial_player_pull->get_header_value("Location")),
+        created_path);
+    player_collection = client.Get(
+        "/collection/collection", player_headers);
+    ASSERT_NE(player_collection, nullptr);
+    ASSERT_EQ(player_collection->status, 200) << player_collection->body;
+    EXPECT_NE(player_collection->body.find("Edited upload"),
+              std::string::npos);
 
     const httplib::Params internal_game_fields = {
         {"csrf_token", csrf_token},
@@ -972,12 +1054,46 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
         internal_game_fields);
     ASSERT_NE(internal_game, nullptr);
     ASSERT_EQ(internal_game->status, 303) << internal_game->body;
+    auto administrator_games = client.Get(
+        "/collection/admin/games", session_headers);
+    ASSERT_NE(administrator_games, nullptr);
+    ASSERT_EQ(administrator_games->status, 200);
+    EXPECT_NE(administrator_games->body.find("Internal"),
+              std::string::npos);
+    auto internal_game_form = client.Get(
+        "/collection/admin/games/gh/edit", session_headers);
+    ASSERT_NE(internal_game_form, nullptr);
+    ASSERT_EQ(internal_game_form->status, 200);
+    const std::size_t internal_choice = internal_game_form->body.find(
+        "value=\"INTERNAL\"");
+    ASSERT_NE(internal_choice, std::string::npos);
+    EXPECT_NE(internal_game_form->body.find("checked", internal_choice),
+              std::string::npos);
     auto concealed_card = client.Get(created_path, player_headers);
     ASSERT_NE(concealed_card, nullptr);
     EXPECT_EQ(concealed_card->status, 404);
     EXPECT_EQ(
         concealed_card->get_header_value("Cache-Control"),
         "private, no-store");
+    player_collection = client.Get(
+        "/collection/collection", player_headers);
+    ASSERT_NE(player_collection, nullptr);
+    ASSERT_EQ(player_collection->status, 200) << player_collection->body;
+    EXPECT_EQ(player_collection->body.find("Edited upload"),
+              std::string::npos);
+    auto concealed_edit = client.Get(
+        created_path + "/edit", player_headers);
+    ASSERT_NE(concealed_edit, nullptr);
+    EXPECT_EQ(concealed_edit->status, 404);
+    EXPECT_EQ(concealed_edit->body, concealed_card->body);
+    EXPECT_EQ(
+        concealed_edit->get_header_value("Cache-Control"),
+        "private, no-store");
+    auto nonexistent_card = client.Get(
+        "/collection/cards/gh-999999", player_headers);
+    ASSERT_NE(nonexistent_card, nullptr);
+    EXPECT_EQ(nonexistent_card->status, 404);
+    EXPECT_EQ(nonexistent_card->body, concealed_card->body);
     auto administrator_cards = client.Get(
         "/collection/admin/cards", session_headers);
     ASSERT_NE(administrator_cards, nullptr);
@@ -989,13 +1105,34 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
     EXPECT_EQ(administrator_card->status, 200);
     EXPECT_NE(administrator_card->body.find(
         "Not currently in the pull pool"), std::string::npos);
-
-    auto promoted = client.Post(
-        "/collection/admin/users/2/promote",
-        session_headers,
-        csrf_fields);
-    ASSERT_NE(promoted, nullptr);
-    ASSERT_EQ(promoted->status, 303) << promoted->body;
+    const httplib::UploadFormDataItems internal_edit_fields = {
+        {"csrf_token", csrf_token, "", ""},
+        {"name", "Internal edit", "", ""},
+        {"rarity", "4", "", ""},
+        {"revision", "2", "", ""},
+        {"game_revision", "2", "", ""},
+        {"source_mode", "files", "", ""},
+        {"front_action", "keep", "", ""},
+        {"foil_action", "keep", "", ""},
+    };
+    auto internal_edit = client.Post(
+        created_path + "/edit", session_headers, internal_edit_fields);
+    ASSERT_NE(internal_edit, nullptr);
+    ASSERT_EQ(internal_edit->status, 303) << internal_edit->body;
+    auto administrator_series = client.Get(
+        "/collection/admin/series", session_headers);
+    ASSERT_NE(administrator_series, nullptr);
+    ASSERT_EQ(administrator_series->status, 200);
+    EXPECT_NE(administrator_series->body.find("Edited series"),
+              std::string::npos);
+    EXPECT_NE(administrator_series->body.find("Internal"),
+              std::string::npos);
+    auto creator_internal_cards = client.Get(
+        "/collection/creator/cards", player_headers);
+    ASSERT_NE(creator_internal_cards, nullptr);
+    ASSERT_EQ(creator_internal_cards->status, 200);
+    EXPECT_EQ(creator_internal_cards->body.find("Creator Going Home"),
+              std::string::npos);
     auto creator_game_create = client.Post(
         "/collection/admin/games",
         player_headers,
@@ -1007,6 +1144,13 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
     ASSERT_EQ(creator_new->status, 200) << creator_new->body;
     EXPECT_EQ(creator_new->body.find("name=\"rarity\""), std::string::npos);
     EXPECT_EQ(creator_new->body.find("Going Home"), std::string::npos);
+    EXPECT_EQ(creator_new->body.find("Edited series"), std::string::npos);
+    auto deleted_series = client.Post(
+        "/collection/admin/series/2/delete",
+        session_headers,
+        csrf_fields);
+    ASSERT_NE(deleted_series, nullptr);
+    EXPECT_EQ(deleted_series->status, 303) << deleted_series->body;
     const httplib::UploadFormDataItems forged_internal_fields = {
         {"csrf_token", player_csrf, "", ""},
         {"game", "gh", "", ""},
@@ -1050,19 +1194,70 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
         creator_card_path + "/edit", session_headers, pool_edit_fields);
     ASSERT_NE(pooled, nullptr);
     ASSERT_EQ(pooled->status, 303) << pooled->body;
-    const httplib::Params player_pull_fields = {
-        {"csrf_token", player_csrf}};
-    auto player_pull = client.Post(
-        "/collection/collection/pull", player_headers, player_pull_fields);
-    ASSERT_NE(player_pull, nullptr);
-    ASSERT_EQ(player_pull->status, 303) << player_pull->body;
+    const httplib::Params administrator_pull_fields = {
+        {"csrf_token", csrf_token}};
+    auto administrator_pull = client.Post(
+        "/collection/collection/pull",
+        session_headers,
+        administrator_pull_fields);
+    ASSERT_NE(administrator_pull, nullptr);
+    ASSERT_EQ(administrator_pull->status, 303)
+        << administrator_pull->body;
     EXPECT_EQ(
-        urlRequestPath(player_pull->get_header_value("Location")),
+        urlRequestPath(administrator_pull->get_header_value("Location")),
         creator_card_path);
-    auto owned_card = client.Get(creator_card_path, player_headers);
+    auto owned_card = client.Get(creator_card_path, session_headers);
     ASSERT_NE(owned_card, nullptr);
     EXPECT_EQ(owned_card->status, 200) << owned_card->body;
     EXPECT_NE(owned_card->body.find("100%"), std::string::npos);
+    const httplib::Params unprotected_visibility_fields = {
+        {"game_revision", "2"},
+        {"display_name", "Going Home"},
+        {"description", ""},
+        {"visibility", "PUBLIC"},
+    };
+    auto unprotected_visibility = client.Post(
+        "/collection/admin/games/gh",
+        session_headers,
+        unprotected_visibility_fields);
+    ASSERT_NE(unprotected_visibility, nullptr);
+    EXPECT_EQ(unprotected_visibility->status, 403);
+    const httplib::Params revisionless_visibility_fields = {
+        {"csrf_token", csrf_token},
+        {"display_name", "Going Home"},
+        {"description", ""},
+        {"visibility", "PUBLIC"},
+    };
+    auto revisionless_visibility = client.Post(
+        "/collection/admin/games/gh",
+        session_headers,
+        revisionless_visibility_fields);
+    ASSERT_NE(revisionless_visibility, nullptr);
+    EXPECT_EQ(revisionless_visibility->status, 400);
+    const httplib::Params public_game_fields = {
+        {"csrf_token", csrf_token},
+        {"game_revision", "2"},
+        {"display_name", "Going Home"},
+        {"description", ""},
+        {"visibility", "PUBLIC"},
+    };
+    auto public_game = client.Post(
+        "/collection/admin/games/gh",
+        session_headers,
+        public_game_fields);
+    ASSERT_NE(public_game, nullptr);
+    ASSERT_EQ(public_game->status, 303) << public_game->body;
+    auto revealed_card = client.Get(created_path, player_headers);
+    ASSERT_NE(revealed_card, nullptr);
+    ASSERT_EQ(revealed_card->status, 200) << revealed_card->body;
+    EXPECT_NE(revealed_card->body.find("Internal edit"),
+              std::string::npos);
+    player_collection = client.Get(
+        "/collection/collection", player_headers);
+    ASSERT_NE(player_collection, nullptr);
+    ASSERT_EQ(player_collection->status, 200) << player_collection->body;
+    EXPECT_NE(player_collection->body.find("Internal edit"),
+              std::string::npos);
     auto deleted = client.Post(
         created_path + "/delete",
         session_headers,
diff --git a/tests/card_service_test.cpp b/tests/card_service_test.cpp
index 0150822..8aa6796 100644
--- a/tests/card_service_test.cpp
+++ b/tests/card_service_test.cpp
@@ -928,6 +928,52 @@ TEST(CardServiceTest, RejectsCreatorMutationsForInternalGames)
     EXPECT_EQ(create_error->code, 422);
     EXPECT_EQ(create_error->msg, "Unknown game");
 
+    const std::filesystem::path administrator_staging =
+        temporary.path() / ".staging/administrator";
+    std::filesystem::create_directories(administrator_staging);
+    const std::filesystem::path administrator_front =
+        administrator_staging / "upload_front";
+    writePng(administrator_front);
+    auto administrator_created = service.createGameCard(
+        1,
+        {"Administrator card", std::nullopt, std::nullopt, 0,
+         administrator_staging, administrator_front, std::nullopt,
+         std::nullopt, false},
+        "internal",
+        1,
+        {},
+        {});
+    ASSERT_TRUE(administrator_created) << administrator_created.error().msg();
+    auto administrator_card = (*data_source)->getCard(
+        {"internal", 1}, GameContentScope::INCLUDE_INTERNAL);
+    ASSERT_TRUE(administrator_card);
+    ASSERT_TRUE(*administrator_card);
+    const std::filesystem::path administrator_edit_staging =
+        temporary.path() / ".staging/administrator-edit";
+    std::filesystem::create_directories(administrator_edit_staging);
+    auto administrator_updated = service.updateGameCard(
+        1,
+        {**administrator_card, 1, "Administrator edit", std::nullopt,
+         std::nullopt, 0, administrator_edit_staging,
+         FrontAssetAction::KEEP, FoilAssetAction::KEEP, std::nullopt,
+         std::nullopt, std::nullopt, false},
+        1,
+        {},
+        {});
+    ASSERT_TRUE(administrator_updated) << administrator_updated.error().msg();
+    administrator_card = (*data_source)->getCard(
+        {"internal", 1}, GameContentScope::INCLUDE_INTERNAL);
+    ASSERT_TRUE(administrator_card);
+    ASSERT_TRUE(*administrator_card);
+    EXPECT_EQ((**administrator_card).name, "Administrator edit");
+    auto administrator_deleted = service.deleteCard(
+        1, **administrator_card);
+    ASSERT_TRUE(administrator_deleted) << administrator_deleted.error().msg();
+    administrator_card = (*data_source)->getCard(
+        {"internal", 1}, GameContentScope::INCLUDE_INTERNAL);
+    ASSERT_TRUE(administrator_card);
+    EXPECT_FALSE(*administrator_card);
+
     const std::filesystem::path public_staging =
         temporary.path() / ".staging/public";
     std::filesystem::create_directories(public_staging);
diff --git a/tests/data_sqlite_test.cpp b/tests/data_sqlite_test.cpp
index 6a4c6af..9bf0a64 100644
--- a/tests/data_sqlite_test.cpp
+++ b/tests/data_sqlite_test.cpp
@@ -382,17 +382,30 @@ TEST(DataSourceSQLiteTest, ScopesInternalGameContentAndPulls)
         *internal_id, GameContentScope::PUBLIC_ONLY);
     ASSERT_TRUE(hidden_fields);
     EXPECT_FALSE(*hidden_fields);
+    auto creator_cards = (*data_source)->getCardsByCreator(
+        1, GameContentScope::PUBLIC_ONLY);
+    ASSERT_TRUE(creator_cards);
+    ASSERT_EQ(creator_cards->size(), 1);
+    EXPECT_EQ(creator_cards->front().name, "Public card");
 
     auto public_series_list = (*data_source)->getSeries(
         GameContentScope::PUBLIC_ONLY);
+    auto hidden_series = (*data_source)->getSeries(
+        *internal_series, GameContentScope::PUBLIC_ONLY);
     auto public_collection = (*data_source)->getCollection(
         1, GameContentScope::PUBLIC_ONLY);
+    auto administrator_collection = (*data_source)->getCollection(
+        1, GameContentScope::INCLUDE_INTERNAL);
     auto pool = (*data_source)->getPoolCards();
     ASSERT_TRUE(public_series_list);
+    ASSERT_TRUE(hidden_series);
     ASSERT_TRUE(public_collection);
+    ASSERT_TRUE(administrator_collection);
     ASSERT_TRUE(pool);
     EXPECT_EQ(public_series_list->size(), 1);
+    EXPECT_FALSE(*hidden_series);
     EXPECT_EQ(public_collection->size(), 1);
+    EXPECT_EQ(administrator_collection->size(), 2);
     EXPECT_EQ(pool->size(), 1);
 
     transaction = (*data_source)->beginTransaction();
@@ -407,13 +420,17 @@ TEST(DataSourceSQLiteTest, ScopesInternalGameContentAndPulls)
     ASSERT_TRUE((*transaction)->commit());
 
     public_cards = (*data_source)->getCards(GameContentScope::PUBLIC_ONLY);
+    public_series_list = (*data_source)->getSeries(
+        GameContentScope::PUBLIC_ONLY);
     public_collection = (*data_source)->getCollection(
         1, GameContentScope::PUBLIC_ONLY);
     pool = (*data_source)->getPoolCards();
     ASSERT_TRUE(public_cards);
+    ASSERT_TRUE(public_series_list);
     ASSERT_TRUE(public_collection);
     ASSERT_TRUE(pool);
     EXPECT_EQ(public_cards->size(), 2);
+    EXPECT_EQ(public_series_list->size(), 2);
     EXPECT_EQ(public_collection->size(), 2);
     EXPECT_EQ(pool->size(), 2);
 }
@@ -796,6 +813,67 @@ TEST(DataSourceSQLiteTest, RejectsInvalidGameVisibility)
         static_cast<GameVisibility>(2), 1}));
 }
 
+TEST(DataSourceSQLiteTest, DefaultsGameVisibilityToPublicInteger)
+{
+    TemporaryDatabase database;
+    auto data_source = prepareDataSource(database.path());
+    ASSERT_TRUE(data_source) << data_source.error().msg();
+    data_source->reset();
+
+    auto connection = mw::SQLite::connectFile(database.path().string());
+    ASSERT_TRUE(connection);
+    ASSERT_TRUE((*connection)->execute(
+        "INSERT INTO games(short_name, display_name) "
+        "VALUES ('default', 'Default');"));
+    auto stored = (*connection)->evalToValue<std::int64_t>(
+        "SELECT visibility FROM games WHERE short_name = 'default';");
+    auto stored_type = (*connection)->evalToValue<std::string>(
+        "SELECT typeof(visibility) FROM games "
+        "WHERE short_name = 'default';");
+    ASSERT_TRUE(stored);
+    ASSERT_TRUE(stored_type);
+    EXPECT_EQ(*stored, 0);
+    EXPECT_EQ(*stored_type, "integer");
+    connection->reset();
+
+    auto reopened = DataSourceSQLite::fromFile(database.path());
+    ASSERT_TRUE(reopened);
+    auto games = (*reopened)->getGames(
+        GameContentScope::INCLUDE_INTERNAL);
+    ASSERT_TRUE(games);
+    ASSERT_EQ(games->size(), 1);
+    EXPECT_EQ(games->front().visibility, GameVisibility::PUBLIC);
+}
+
+TEST(DataSourceSQLiteTest, RejectsCorruptGameVisibilityOnRead)
+{
+    TemporaryDatabase database;
+    auto data_source = prepareDataSource(database.path());
+    ASSERT_TRUE(data_source) << data_source.error().msg();
+    auto transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    ASSERT_TRUE((*transaction)->insertGame(
+        {"corrupt", "Corrupt", "", GameVisibility::PUBLIC, 1}));
+    ASSERT_TRUE((*transaction)->commit());
+    data_source->reset();
+
+    auto connection = mw::SQLite::connectFile(database.path().string());
+    ASSERT_TRUE(connection);
+    ASSERT_TRUE((*connection)->execute(
+        "PRAGMA ignore_check_constraints = ON;"));
+    ASSERT_TRUE((*connection)->execute(
+        "UPDATE games SET visibility = 2 WHERE short_name = 'corrupt';"));
+    connection->reset();
+
+    auto reopened = DataSourceSQLite::fromFile(database.path());
+    ASSERT_TRUE(reopened);
+    auto games = (*reopened)->getGames(
+        GameContentScope::INCLUDE_INTERNAL);
+    ASSERT_FALSE(games);
+    EXPECT_NE(games.error().msg().find("invalid game visibility"),
+              std::string::npos) << games.error().msg();
+}
+
 /// Verify fresh initialization and reopening do not create any games.
 TEST(DataSourceSQLiteTest, StartsAndReopensWithoutGames)
 {
diff --git a/tests/game_service_test.cpp b/tests/game_service_test.cpp
index 8ae7618..8d6d9e9 100644
--- a/tests/game_service_test.cpp
+++ b/tests/game_service_test.cpp
@@ -119,6 +119,19 @@ TEST(GameServiceTest, MutatesDefinitionsAndRejectsStaleRevisions)
         GameVisibility::PUBLIC);
     ASSERT_TRUE(created) << created.error().msg();
     EXPECT_EQ(*created, "demo");
+    auto internal_created = service.createGame(
+        fixture->administrator_id,
+        "internal",
+        "Internal Game",
+        "",
+        GameVisibility::INTERNAL);
+    ASSERT_TRUE(internal_created) << internal_created.error().msg();
+    auto internal_definition = fixture->data_source->getGameDefinition(
+        "internal", GameContentScope::INCLUDE_INTERNAL);
+    ASSERT_TRUE(internal_definition);
+    ASSERT_TRUE(*internal_definition);
+    EXPECT_EQ((**internal_definition).game.visibility,
+              GameVisibility::INTERNAL);
 
     auto integer_id = service.createField(
         fixture->administrator_id,
@@ -186,6 +199,16 @@ TEST(GameServiceTest, MutatesDefinitionsAndRejectsStaleRevisions)
         "Renamed Game",
         "New description",
         GameVisibility::INTERNAL));
+    auto stale_visibility = service.updateGame(
+        fixture->administrator_id,
+        "demo",
+        6,
+        "Renamed Game",
+        "New description",
+        GameVisibility::PUBLIC);
+    ASSERT_FALSE(stale_visibility);
+    ASSERT_NE(httpError(stale_visibility.error()), nullptr);
+    EXPECT_EQ(httpError(stale_visibility.error())->code, 409);
 
     auto definition = fixture->data_source->getGameDefinition(
         "demo", GameContentScope::INCLUDE_INTERNAL);