diff --git a/packages/realm-server/tests/queries-test.ts b/packages/realm-server/tests/queries-test.ts index dbb816ac72..0611deffad 100644 --- a/packages/realm-server/tests/queries-test.ts +++ b/packages/realm-server/tests/queries-test.ts @@ -141,6 +141,149 @@ module(basename(import.meta.filename), function () { 'filters out published realms when fetching all permissions', ); }); + + test('excludes archived realms by default; includeArchived re-includes them', async function (assert) { + const ownerUserId = '@owner:localhost'; + const activeOwned = 'http://example.com/active-owned/'; + const archivedOwned = 'http://example.com/archived-owned/'; + const activePublic = 'http://example.com/active-public/'; + const archivedPublic = 'http://example.com/archived-public/'; + + await insertPermissions(dbAdapter, new URL(activeOwned), { + [ownerUserId]: ['read', 'write', 'realm-owner'], + }); + await insertPermissions(dbAdapter, new URL(archivedOwned), { + [ownerUserId]: ['read', 'write', 'realm-owner'], + }); + // Archiving via the endpoint is forbidden for public realms, but the + // helper itself doesn't enforce that — directly seed an archived + // public realm to exercise the public-read arm of the UNION too. + await insertPermissions(dbAdapter, new URL(activePublic), { + '*': ['read'], + }); + await insertPermissions(dbAdapter, new URL(archivedPublic), { + '*': ['read'], + }); + await archiveRealm(dbAdapter, new URL(archivedOwned)); + await archiveRealm(dbAdapter, new URL(archivedPublic)); + + // fetchUserPermissions' public arm returns every public realm in the + // database, which includes the bootstrap realms carried by the seed + // template. Assert on the four realms this test seeds rather than the + // full key set so the result stays stable regardless of what else the + // seed template carries. + let active = await fetchUserPermissions(dbAdapter, { + userId: ownerUserId, + }); + assert.true( + activeOwned in active, + 'default: active owned realm is enumerated', + ); + assert.true( + activePublic in active, + 'default: active public realm is enumerated', + ); + assert.false( + archivedOwned in active, + 'default: archived owned realm is excluded (owner arm)', + ); + assert.false( + archivedPublic in active, + 'default: archived public realm is excluded (public arm)', + ); + + let withArchived = await fetchUserPermissions(dbAdapter, { + userId: ownerUserId, + includeArchived: true, + }); + assert.true( + activeOwned in withArchived, + 'includeArchived: active owned realm is enumerated', + ); + assert.true( + activePublic in withArchived, + 'includeArchived: active public realm is enumerated', + ); + assert.true( + archivedOwned in withArchived, + 'includeArchived: archived owned realm is re-included (owner arm)', + ); + assert.true( + archivedPublic in withArchived, + 'includeArchived: archived public realm is re-included (public arm)', + ); + + let activeOwnersOnly = await fetchUserPermissions(dbAdapter, { + userId: ownerUserId, + onlyOwnRealms: true, + }); + assert.true( + activeOwned in activeOwnersOnly, + 'onlyOwnRealms: active owned realm is enumerated', + ); + assert.false( + archivedOwned in activeOwnersOnly, + 'onlyOwnRealms: archived owned realm is excluded by default', + ); + assert.false( + activePublic in activeOwnersOnly, + 'onlyOwnRealms: public realm is not an owned realm', + ); + assert.false( + archivedPublic in activeOwnersOnly, + 'onlyOwnRealms: archived public realm is excluded', + ); + + let ownersWithArchived = await fetchUserPermissions(dbAdapter, { + userId: ownerUserId, + onlyOwnRealms: true, + includeArchived: true, + }); + assert.true( + activeOwned in ownersWithArchived, + 'onlyOwnRealms + includeArchived: active owned realm is enumerated', + ); + assert.true( + archivedOwned in ownersWithArchived, + 'onlyOwnRealms + includeArchived: archived owned realm is included', + ); + assert.false( + activePublic in ownersWithArchived, + 'onlyOwnRealms + includeArchived: public realm is not an owned realm', + ); + assert.false( + archivedPublic in ownersWithArchived, + 'onlyOwnRealms + includeArchived: archived public realm is excluded', + ); + }); + + test('an unarchived realm is enumerated again', async function (assert) { + const ownerUserId = '@owner:localhost'; + const realmURL = 'http://example.com/restored/'; + + await insertPermissions(dbAdapter, new URL(realmURL), { + [ownerUserId]: ['read', 'write', 'realm-owner'], + }); + await archiveRealm(dbAdapter, new URL(realmURL)); + + let archivedView = await fetchUserPermissions(dbAdapter, { + userId: ownerUserId, + }); + assert.false( + realmURL in archivedView, + 'archived realm is not enumerated', + ); + + await unarchiveRealm(dbAdapter, new URL(realmURL)); + let restoredView = await fetchUserPermissions(dbAdapter, { + userId: ownerUserId, + }); + assert.deepEqual( + restoredView[realmURL], + ['read', 'write', 'realm-owner'], + 'unarchived realm is enumerated again', + ); + }); }); module('fetchAllRealmsWithOwners', function (hooks) { diff --git a/packages/realm-server/tests/realm-auth-test.ts b/packages/realm-server/tests/realm-auth-test.ts index e063050dab..ddbc1b6a5a 100644 --- a/packages/realm-server/tests/realm-auth-test.ts +++ b/packages/realm-server/tests/realm-auth-test.ts @@ -5,6 +5,7 @@ import sinon from 'sinon'; import { basename } from 'path'; import type { PgAdapter } from '@cardstack/postgres'; +import { archiveRealm, unarchiveRealm } from '@cardstack/runtime-common'; import { MatrixClient } from '@cardstack/runtime-common/matrix-client'; import { fetchSessionRoom } from '@cardstack/runtime-common/db-queries/session-room-queries'; @@ -179,5 +180,53 @@ module(basename(import.meta.filename), function () { 'the handler did NOT cold-mount the realm — mount remains deferred to the next per-realm request', ); }); + + test('POST /_realm-auth omits archived realms; unarchive restores them', async function (assert) { + sinon + .stub(MatrixClient.prototype, 'createDM') + .resolves('!archived-test-session-room:localhost'); + sinon.stub(MatrixClient.prototype, 'sendEvent').resolves(); + sinon.stub(MatrixClient.prototype, 'getJoinedRooms').resolves({ + joined_rooms: [], + }); + sinon.stub(MatrixClient.prototype, 'joinRoom').resolves(); + + let realmAuthRequest = () => + request + .post('/_realm-auth') + .set('Accept', 'application/json') + .set('Content-Type', 'application/json') + .set( + 'Authorization', + `Bearer ${createRealmServerJWT( + { user: matrixUserId, sessionRoom: 'server-session-room' }, + realmSecretSeed, + )}`, + ) + .send('{}'); + + let before = await realmAuthRequest(); + assert.strictEqual(before.status, 200); + assert.ok( + before.body[testRealmHref], + 'active realm appears in the response', + ); + + await archiveRealm(dbAdapter, new URL(testRealmHref)); + let archived = await realmAuthRequest(); + assert.strictEqual(archived.status, 200); + assert.notOk( + archived.body[testRealmHref], + 'archived realm is omitted from the response', + ); + + await unarchiveRealm(dbAdapter, new URL(testRealmHref)); + let restored = await realmAuthRequest(); + assert.strictEqual(restored.status, 200); + assert.ok( + restored.body[testRealmHref], + 'unarchived realm reappears in the response', + ); + }); }); }); diff --git a/packages/runtime-common/db-queries/realm-permission-queries.ts b/packages/runtime-common/db-queries/realm-permission-queries.ts index a1558793f0..2bbe7e5a65 100644 --- a/packages/runtime-common/db-queries/realm-permission-queries.ts +++ b/packages/runtime-common/db-queries/realm-permission-queries.ts @@ -138,11 +138,24 @@ export async function fetchUserPermissions( args: { userId: string; onlyOwnRealms?: boolean; + // Archived realms are sealed: by default they're omitted from this + // enumeration so _realm-auth, multi-realm authorization, the indexer + // sweep, etc. see only active realms. Set true to include them. + // The archive-management endpoints reach archived realms by URL via + // fetchRealmPermissions / fetchArchivedRealmsForOwner instead, so + // they bypass this filter and don't need the opt-in. + includeArchived?: boolean; }, ): Promise<{ [realm: string]: RealmAction[]; }> { - const { userId, onlyOwnRealms = false } = args; + const { userId, onlyOwnRealms = false, includeArchived = false } = args; + // Render the archive predicate inline rather than appending a fragment: + // the `false` branch's UNION applies the filter to both arms, so a + // single composable expression keeps the two arms in sync. + const excludeArchivedSql = includeArchived + ? '' + : `AND realm_url NOT IN (SELECT url FROM realm_metadata WHERE archived_at IS NOT NULL)`; let permissions: { realm_url: string; read: boolean; @@ -156,7 +169,8 @@ export async function fetchUserPermissions( `SELECT realm_url, read, write, realm_owner FROM realm_user_permissions WHERE username =`, param(userId), `AND realm_owner = true - AND realm_url NOT IN (SELECT url FROM realm_registry WHERE kind = 'published')`, + AND realm_url NOT IN (SELECT url FROM realm_registry WHERE kind = 'published') + ${excludeArchivedSql}`, ])) as { realm_url: string; read: boolean; @@ -170,12 +184,14 @@ export async function fetchUserPermissions( `SELECT realm_url, read, write, realm_owner FROM realm_user_permissions WHERE username =`, param(userId), `AND realm_url NOT IN (SELECT url FROM realm_registry WHERE kind = 'published') + ${excludeArchivedSql} UNION SELECT realm_url, true as read, false as write, false as realm_owner FROM realm_user_permissions WHERE username = '*' AND read = true AND realm_url NOT IN (SELECT realm_url FROM realm_user_permissions WHERE username =`, param(userId), `) - AND realm_url NOT IN (SELECT url FROM realm_registry WHERE kind = 'published')`, + AND realm_url NOT IN (SELECT url FROM realm_registry WHERE kind = 'published') + ${excludeArchivedSql}`, ])) as { realm_url: string; read: boolean;