Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions backend/__tests__/unit/services/podListing.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* podListing — the join-policy gate, tested against the ABSENT field.
*
* `joinPolicy` has a mongoose default of 'open', but a default applies on
* write: documents created before the field existed carry no `joinPolicy`
* at all. Every read here must therefore fail OPEN — `!== 'invite-only'`, never `=== 'open'` — so a
* legacy pod stays joinable instead of silently disappearing from Discover.
*
* The predicate and the query are two encodings of the same rule, and they
* are consumed by different callers (podController's join path vs. the
* Mongo listing queries). They can drift apart, so both are asserted on the
* absent-field case, not just the predicate.
*/
const {
isCommunityListed,
isDirectlyJoinable,
DIRECTLY_JOINABLE_QUERY,
communityDiscoverQuery,
NON_LISTABLE_POD_TYPES,
} = require('../../../services/podListing');

const listedPod = (overrides = {}) => ({
type: 'chat',
publicRead: true,
communityListed: true,
...overrides,
});

describe('podListing join-policy gate', () => {
describe('absent joinPolicy (legacy rows) must fail OPEN', () => {
test('isDirectlyJoinable is true when the field is missing entirely', () => {
const pod = listedPod();
expect('joinPolicy' in pod).toBe(false);
expect(isDirectlyJoinable(pod)).toBe(true);
});

test('isDirectlyJoinable is true when the field is explicitly undefined', () => {
expect(isDirectlyJoinable(listedPod({ joinPolicy: undefined }))).toBe(true);
});

test('isDirectlyJoinable is true when the field is null', () => {
expect(isDirectlyJoinable(listedPod({ joinPolicy: null }))).toBe(true);
});

// The query half of the same rule. `{ $ne: 'invite-only' }` matches
// documents where the field is absent; `{ $eq: 'open' }` would not, so
// this asserts the encoding rather than just the value.
test('DIRECTLY_JOINABLE_QUERY negates invite-only rather than asserting open', () => {
expect(DIRECTLY_JOINABLE_QUERY.joinPolicy).toEqual({ $ne: 'invite-only' });
expect(DIRECTLY_JOINABLE_QUERY.joinPolicy).not.toEqual({ $eq: 'open' });
expect(DIRECTLY_JOINABLE_QUERY.joinPolicy).not.toBe('open');
});

test('communityDiscoverQuery inherits the same fail-open gate', () => {
const query = communityDiscoverQuery({ callerId: 'user-1' });
expect(query.joinPolicy).toEqual({ $ne: 'invite-only' });
});
});

describe('present joinPolicy still gates as before', () => {
test('open is joinable', () => {
expect(isDirectlyJoinable(listedPod({ joinPolicy: 'open' }))).toBe(true);
});

test('invite-only is not joinable', () => {
expect(isDirectlyJoinable(listedPod({ joinPolicy: 'invite-only' }))).toBe(false);
});
});

describe('the join gate does not override the listing gate', () => {
test('an unlisted pod is not joinable even with no joinPolicy', () => {
expect(isDirectlyJoinable(listedPod({ communityListed: false }))).toBe(false);
});

test('a non-public pod is not joinable even with no joinPolicy', () => {
expect(isDirectlyJoinable(listedPod({ publicRead: false }))).toBe(false);
});

test.each(NON_LISTABLE_POD_TYPES)('%s is never listed or joinable', (type) => {
expect(isCommunityListed(listedPod({ type }))).toBe(false);
expect(isDirectlyJoinable(listedPod({ type }))).toBe(false);
});
});
});
18 changes: 17 additions & 1 deletion backend/models/Pod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,23 @@ export interface IPod extends Document {
name: string;
description?: string;
type: PodType;
joinPolicy: PodJoinPolicy;
// OPTIONAL, and the `?` is load-bearing. The schema below defaults this
// to 'open', but a mongoose default applies on WRITE — documents created
// before the field existed have no `joinPolicy` at all. (No count here on
// purpose: a census in a permanent comment is stale the moment rows are
// written, and the type is justified by one such row existing — or by the
// possibility of one — not by how many there are today.) Declaring it
// required told a type-checking
// reader the field is always present, which licenses `=== 'open'`; that
// test is false for every legacy row and would fail CLOSED, silently
// hiding pods that are in fact joinable.
//
// Read it as `!== 'invite-only'` (fail open), never `=== 'open'`. That is
// what every production read already does, and what DIRECTLY_JOINABLE_QUERY
// encodes as `{ $ne: 'invite-only' }` — see services/podListing.ts, whose
// own CommunityListingPod already declared the field optional. The two
// declarations of one field disagreed; this is the one that was wrong.
joinPolicy?: PodJoinPolicy;
parentPod?: Types.ObjectId | null;
agentEnsemble: {
enabled: boolean;
Expand Down
Loading