mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-22 03:21:27 +00:00
fix: misc bugs (#4251)
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
This commit is contained in:
1 parent
2a15afc570
commit
2b254b8892
14 files changed
+223
-120
No files matched your search
@@ -1523,6 +1523,86 @@ describe('createPuterSiteMiddleware — __workers/ is never served', () => {
|
||||
expect(Buffer.isBuffer(piped)).toBe(true);
|
||||
expect(piped!.equals(shell)).toBe(true);
|
||||
});
|
||||
|
||||
// SQLite compares paths bytewise; MySQL's utf8mb4_unicode_ci folds
|
||||
// accents, fullwidth forms and zero-width chars. Emulate that fold.
|
||||
const withCollationAlias = async (
|
||||
alias: string,
|
||||
real: string,
|
||||
fn: () => Promise<void>,
|
||||
) => {
|
||||
const store = server.stores.fsEntry;
|
||||
const original = store.getEntryByPath.bind(store);
|
||||
const spy = vi
|
||||
.spyOn(store, 'getEntryByPath')
|
||||
.mockImplementation((path, options) =>
|
||||
original(
|
||||
path.startsWith(alias)
|
||||
? real + path.slice(alias.length)
|
||||
: path,
|
||||
options,
|
||||
),
|
||||
);
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
};
|
||||
|
||||
it('404s a collation alias of __workers/ that the database resolves to the real file', async () => {
|
||||
const { homePath, sub } = await setupSiteWithWorker();
|
||||
await withCollationAlias(
|
||||
`${homePath}/__wörkers/matchmaking.worker.js`,
|
||||
`${homePath}/__workers/matchmaking.worker.js`,
|
||||
async () => {
|
||||
const out = await request(
|
||||
sub,
|
||||
'/__w%C3%B6rkers/matchmaking.worker.js',
|
||||
);
|
||||
expect(out.statusCode).toBe(404);
|
||||
expect(String(out.body)).not.toContain('secret');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('404s a collation alias of a folder whose index.html sits in __workers/', async () => {
|
||||
const { owner, homePath, sub } = await setupSiteWithWorker();
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`${homePath}/__workers/index.html`,
|
||||
Buffer.from('<html>listing</html>'),
|
||||
'text/html',
|
||||
);
|
||||
await withCollationAlias(
|
||||
`${homePath}/__wörkers`,
|
||||
`${homePath}/__workers`,
|
||||
async () => {
|
||||
const out = await request(sub, '/__w%C3%B6rkers');
|
||||
expect(out.statusCode).toBe(404);
|
||||
expect(String(out.body)).not.toContain('listing');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('404s a collation alias of .puter_site_config', async () => {
|
||||
const { owner, homePath, sub } = await setupSiteWithWorker();
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`${homePath}/.puter_site_config`,
|
||||
Buffer.from(JSON.stringify({ errors: {}, marker: 'cfg-body' })),
|
||||
'application/json',
|
||||
);
|
||||
await withCollationAlias(
|
||||
`${homePath}/.püter_site_config`,
|
||||
`${homePath}/.puter_site_config`,
|
||||
async () => {
|
||||
const out = await request(sub, '/.p%C3%BCter_site_config');
|
||||
expect(out.statusCode).toBe(404);
|
||||
expect(String(out.body)).not.toContain('cfg-body');
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPuterSiteMiddleware — hosting CSP', () => {
|
||||
|
||||
@@ -94,6 +94,20 @@ const isWorkersSourcePath = (urlPath: string): boolean =>
|
||||
.split('/')
|
||||
.some((segment) => segment.toLowerCase() === WORKERS_FOLDER);
|
||||
|
||||
/**
|
||||
* Checks the stored path of a looked-up entry. The URL-level guards are not
|
||||
* enough on MySQL: `fsentries.path` is utf8mb4_unicode_ci, so `__wörkers`,
|
||||
* fullwidth or zero-width spellings resolve to the real `__workers` row.
|
||||
*/
|
||||
const isServableEntryPath = (
|
||||
entryPath: string | null | undefined,
|
||||
rootPath: string,
|
||||
): boolean => {
|
||||
if (!entryPath?.startsWith(`${rootPath}/`)) return false;
|
||||
const relPath = entryPath.slice(rootPath.length);
|
||||
return !isWorkersSourcePath(relPath) && !isSiteConfigPath(relPath);
|
||||
};
|
||||
|
||||
/** Inside some owner's top-level Trash, which is where Delete puts things. */
|
||||
const isTrashedPath = (path: string): boolean =>
|
||||
/^\/[^/]+\/Trash(\/|$)/u.test(path);
|
||||
@@ -620,6 +634,9 @@ export const createPuterSiteMiddleware = (
|
||||
pathPosix.join(filePath, 'index.html'),
|
||||
);
|
||||
}
|
||||
if (entry && !isServableEntryPath(entry.path, rootPath)) {
|
||||
entry = null;
|
||||
}
|
||||
|
||||
// Custom error page fallback. On a 404 we consult the site config
|
||||
// for a rule and, if one resolves to an existing file, serve that
|
||||
@@ -640,7 +657,11 @@ export const createPuterSiteMiddleware = (
|
||||
const candidate = await layers.stores.fsEntry.getEntryByPath(
|
||||
errorTarget.absPath,
|
||||
);
|
||||
if (candidate && !candidate.isDir) {
|
||||
if (
|
||||
candidate &&
|
||||
!candidate.isDir &&
|
||||
isServableEntryPath(candidate.path, rootPath)
|
||||
) {
|
||||
entry = candidate;
|
||||
statusOverride = errorTarget.status;
|
||||
}
|
||||
|
||||
@@ -1867,12 +1867,16 @@ export class AuthService extends PuterService {
|
||||
|
||||
/**
|
||||
* Materialize the `kind='asset'` session row that the cookie's
|
||||
* `session_uuid` claim points at. Parented to the web session so a logout
|
||||
* cascade kills every asset cookie minted under it. Both fields are `null`
|
||||
* only when the caller didn't supply a web session at all — the cookie
|
||||
* still mints unparented and without an `auth_id` claim (matches v1
|
||||
* behavior for access-token-minted cookies that aren't tied to an
|
||||
* interactive session).
|
||||
* `session_uuid` claim points at. Parented to the minting session so
|
||||
* revoking it kills the cookie. The cascade is one level deep, so a cookie
|
||||
* minted from a godmode token (itself under a web session) outlives logout
|
||||
* of that web session; accepted, as app tokens outlive it too and the
|
||||
* cookie only serves one app's files after an entitlement check.
|
||||
*
|
||||
* Both fields are `null` only when the caller didn't supply a session at
|
||||
* all — the cookie still mints unparented and without an `auth_id` claim
|
||||
* (matches v1 behavior for access-token-minted cookies that aren't tied to
|
||||
* an interactive session).
|
||||
*
|
||||
* If the caller DID supply a `webSessionUuid` but the lookup misses (row
|
||||
* revoked / expired between mint request and this lookup), throw —
|
||||
@@ -1907,9 +1911,10 @@ export class AuthService extends PuterService {
|
||||
/**
|
||||
* Verify a hosted-asset cookie of `kind` against what the request expects,
|
||||
* and that the session it was minted under still exists and isn't revoked:
|
||||
* `getByUuid` filters on `revoked_at IS NULL`, so a logout cascade
|
||||
* invalidates every cookie minted under that web session. A cookie minted
|
||||
* without a session (from an access-token actor) has nothing to bind.
|
||||
* `getByUuid` filters on `revoked_at IS NULL`, so revoking the minting
|
||||
* session invalidates the cookie. Only the leaf row is checked; see
|
||||
* `#mintAssetSessionContext` for the godmode case. A cookie minted without
|
||||
* a session (from an access-token actor) has nothing to bind.
|
||||
*/
|
||||
async verifyHostedAssetToken(
|
||||
kind: HostedAssetKind,
|
||||
|
||||
@@ -4533,6 +4533,14 @@ describe('FSService move', () => {
|
||||
}),
|
||||
);
|
||||
expect(intoItself.legacyCode).toBe('cannot_move_directory_into_itself');
|
||||
|
||||
const ontoItself = await caught(() =>
|
||||
fs.move(user.userId, {
|
||||
source: dir,
|
||||
destinationParent: dir,
|
||||
}),
|
||||
);
|
||||
expect(ontoItself.legacyCode).toBe('cannot_move_directory_into_itself');
|
||||
});
|
||||
|
||||
it('handles a destination collision by conflict, overwrite or dedupe', async () => {
|
||||
|
||||
@@ -4901,7 +4901,8 @@ export class FSService extends PuterService {
|
||||
}
|
||||
if (
|
||||
source.isDir &&
|
||||
destinationParent.path.startsWith(`${source.path}/`)
|
||||
(destinationParent.uuid === source.uuid ||
|
||||
destinationParent.path.startsWith(`${source.path}/`))
|
||||
) {
|
||||
throw new HttpError(400, 'Cannot move a directory into itself', {
|
||||
legacyCode: 'cannot_move_directory_into_itself',
|
||||
|
||||
@@ -399,11 +399,7 @@ export class SocketService extends PuterService {
|
||||
const base = this.#authOptions();
|
||||
const id = actor?.user?.id;
|
||||
if (typeof id !== 'number' || actor?.user?.otp_enabled) return base;
|
||||
const seat = await (
|
||||
this.stores.team as unknown as {
|
||||
getOrgSeat?: (n: number) => Promise<{ require_2fa?: number }>;
|
||||
}
|
||||
)
|
||||
const seat = await this.stores.team
|
||||
?.getOrgSeat?.(id)
|
||||
.catch((): null => null);
|
||||
return { ...base, teamRequires2fa: Number(seat?.require_2fa) === 1 };
|
||||
@@ -423,7 +419,8 @@ export class SocketService extends PuterService {
|
||||
// `{ auth: { ... } }`, not the query string. puter-js uses
|
||||
// `io(url, { auth: { auth_token } })`.
|
||||
const handshakeAuth = socket.handshake.auth as
|
||||
Record<string, unknown> | undefined;
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const tokenRaw =
|
||||
typeof handshakeAuth?.auth_token === 'string'
|
||||
? handshakeAuth.auth_token
|
||||
@@ -932,7 +929,8 @@ export class SocketService extends PuterService {
|
||||
// their next poll of /cache/last-change-timestamp.
|
||||
const originalSocketId = (
|
||||
data.response as
|
||||
{ original_client_socket_id?: string } | undefined
|
||||
| { original_client_socket_id?: string }
|
||||
| undefined
|
||||
)?.original_client_socket_id;
|
||||
await this.send({ room: userId }, 'cache.updated', {
|
||||
timestamp,
|
||||
@@ -946,7 +944,9 @@ export class SocketService extends PuterService {
|
||||
#handleUploadProgress(data: UploadProgressPayload): void {
|
||||
const meta = data.meta ?? {};
|
||||
const userId = (meta.user_id ?? meta.userId) as
|
||||
number | string | undefined;
|
||||
| number
|
||||
| string
|
||||
| undefined;
|
||||
if (!userId) {
|
||||
console.warn('[socket] upload-progress missing user_id', { meta });
|
||||
return;
|
||||
|
||||
@@ -152,7 +152,10 @@ const RESERVED_HANDLES = new Set([
|
||||
]);
|
||||
|
||||
export type HandleRejection =
|
||||
'too_short' | 'too_long' | 'malformed' | 'reserved';
|
||||
| 'too_short'
|
||||
| 'too_long'
|
||||
| 'malformed'
|
||||
| 'reserved';
|
||||
|
||||
/** Trimmed and capped, so the same name is accepted on every engine. */
|
||||
export const normalizeTeamName = (name: string): string => {
|
||||
|
||||
+48
-89
@@ -505,49 +505,64 @@ const ipc_listener = async (event, handled) => {
|
||||
// app-side promise only settles when a message with its id comes back
|
||||
// — so a rejected call is answered anyway rather than left hanging
|
||||
// forever. Logged here because that is the only place it is visible.
|
||||
puter.kv.set({
|
||||
key: event.data.key,
|
||||
value: event.data.value,
|
||||
app_uid: app_uuid,
|
||||
}).catch(err => console.warn('kv.setItem failed for app:', err)).then(() => {
|
||||
// send confirmation to requester window
|
||||
target_iframe.contentWindow.postMessage({
|
||||
original_msg_id: msg_id,
|
||||
}, '*');
|
||||
});
|
||||
// These run on the user's session, so without an explicit app namespace
|
||||
// they would land in the desktop's own account-wide settings.
|
||||
if ( app_uuid ) {
|
||||
try {
|
||||
await puter.kv.set({
|
||||
key: event.data.key,
|
||||
value: event.data.value,
|
||||
optConfig: { appUuid: app_uuid },
|
||||
});
|
||||
} catch ( err ) {
|
||||
console.warn('kv.setItem failed for app:', err);
|
||||
}
|
||||
}
|
||||
// send confirmation to requester window
|
||||
target_iframe.contentWindow.postMessage({
|
||||
original_msg_id: msg_id,
|
||||
}, '*');
|
||||
}
|
||||
//--------------------------------------------------------
|
||||
// getItem
|
||||
//--------------------------------------------------------
|
||||
else if ( event.data.msg === 'getItem' && event.data.key ) {
|
||||
puter.kv.get({
|
||||
key: event.data.key,
|
||||
app_uid: app_uuid,
|
||||
}).catch(err => {
|
||||
console.warn('kv.getItem failed for app:', err);
|
||||
return null;
|
||||
}).then((result) => {
|
||||
// send confirmation to requester window
|
||||
target_iframe.contentWindow.postMessage({
|
||||
original_msg_id: msg_id,
|
||||
msg: 'getItemSucceeded',
|
||||
value: result ?? null,
|
||||
}, '*');
|
||||
});
|
||||
let result = null;
|
||||
if ( app_uuid ) {
|
||||
try {
|
||||
result = await puter.kv.get({
|
||||
key: event.data.key,
|
||||
optConfig: { appUuid: app_uuid },
|
||||
});
|
||||
} catch ( err ) {
|
||||
console.warn('kv.getItem failed for app:', err);
|
||||
}
|
||||
}
|
||||
// send confirmation to requester window
|
||||
target_iframe.contentWindow.postMessage({
|
||||
original_msg_id: msg_id,
|
||||
msg: 'getItemSucceeded',
|
||||
value: result ?? null,
|
||||
}, '*');
|
||||
}
|
||||
//--------------------------------------------------------
|
||||
// removeItem
|
||||
//--------------------------------------------------------
|
||||
else if ( event.data.msg === 'removeItem' && event.data.key ) {
|
||||
puter.kv.del({
|
||||
key: event.data.key,
|
||||
app_uid: app_uuid,
|
||||
}).catch(err => console.warn('kv.removeItem failed for app:', err)).then(() => {
|
||||
// send confirmation to requester window
|
||||
target_iframe.contentWindow.postMessage({
|
||||
original_msg_id: msg_id,
|
||||
}, '*');
|
||||
});
|
||||
if ( app_uuid ) {
|
||||
try {
|
||||
await puter.kv.del({
|
||||
key: event.data.key,
|
||||
optConfig: { appUuid: app_uuid },
|
||||
});
|
||||
} catch ( err ) {
|
||||
console.warn('kv.removeItem failed for app:', err);
|
||||
}
|
||||
}
|
||||
// send confirmation to requester window
|
||||
target_iframe.contentWindow.postMessage({
|
||||
original_msg_id: msg_id,
|
||||
}, '*');
|
||||
}
|
||||
//--------------------------------------------------------
|
||||
// showOpenFilePicker
|
||||
@@ -1522,62 +1537,6 @@ const ipc_listener = async (event, handled) => {
|
||||
}, '*');
|
||||
$(target_iframe).get(0).focus({ preventScroll: true });
|
||||
}
|
||||
//--------------------------------------------------------
|
||||
// setWallpaper
|
||||
//--------------------------------------------------------
|
||||
else if ( event.data.msg === 'setWallpaper' ) {
|
||||
// Auth
|
||||
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// No options?
|
||||
if ( ! event.data.options )
|
||||
{
|
||||
event.data.options = {};
|
||||
}
|
||||
|
||||
// /set-desktop-bg
|
||||
try {
|
||||
await $.ajax({
|
||||
url: `${window.api_origin }/set-desktop-bg`,
|
||||
type: 'POST',
|
||||
data: JSON.stringify({
|
||||
url: event.data.readURL,
|
||||
fit: event.data.options.fit ?? 'cover',
|
||||
color: event.data.options.color,
|
||||
}),
|
||||
async: true,
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${window.auth_token}`,
|
||||
},
|
||||
statusCode: {
|
||||
401: function (xhr) {
|
||||
window.handle401(xhr);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Set wallpaper
|
||||
window.set_desktop_background({
|
||||
url: event.data.readURL,
|
||||
fit: event.data.options.fit ?? 'cover',
|
||||
color: event.data.options.color,
|
||||
});
|
||||
|
||||
// Send success to app
|
||||
target_iframe.contentWindow.postMessage({
|
||||
msg: 'wallpaperSet',
|
||||
original_msg_id: msg_id,
|
||||
}, '*');
|
||||
$(target_iframe).get(0).focus({ preventScroll: true });
|
||||
} catch ( err ) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
//--------------------------------------------------------
|
||||
// showSaveFilePicker
|
||||
//--------------------------------------------------------
|
||||
|
||||
@@ -293,11 +293,12 @@ const TabFiles = {
|
||||
puter.kv.get('sort_direction').catch(() => null),
|
||||
puter.kv.get('column_widths').catch(() => null),
|
||||
]);
|
||||
this.currentView = savedView || 'list';
|
||||
// Unknown stored values fall back to the defaults; sortColumn ends up in a selector.
|
||||
this.currentView = ['list', 'grid', 'grid-sm'].includes(savedView) ? savedView : 'list';
|
||||
|
||||
// Sorting state
|
||||
this.sortColumn = savedSortColumn || 'name';
|
||||
this.sortDirection = savedSortDirection || 'asc';
|
||||
this.sortColumn = ['name', 'size', 'modified'].includes(savedSortColumn) ? savedSortColumn : 'name';
|
||||
this.sortDirection = savedSortDirection === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
// Column widths state (for resizing). A corrupt saved value falls back
|
||||
// to the defaults instead of taking the whole tab down with it.
|
||||
|
||||
@@ -722,7 +722,15 @@ async function UIDesktop (options) {
|
||||
|
||||
// load window sidebar items from KV
|
||||
puter.kv.get('sidebar_items').then(async (val) => {
|
||||
window.sidebar_items = val;
|
||||
// UIWindow parses this and reads `item.path` while building every
|
||||
// explorer window, so a malformed value is dropped for the defaults.
|
||||
let items = null;
|
||||
try {
|
||||
items = JSON.parse(val);
|
||||
} catch { /* keep the defaults */ }
|
||||
if ( Array.isArray(items) && items.every(item => typeof item?.path === 'string') ) {
|
||||
window.sidebar_items = val;
|
||||
}
|
||||
// Catch on the chain rather than the read, so a failure leaves whatever is
|
||||
// already there instead of overwriting it with a fallback.
|
||||
}).catch(err => console.warn('Could not load sidebar_items:', err));
|
||||
|
||||
@@ -24,6 +24,8 @@ import launch_app from '../helpers/launchApp.js';
|
||||
import UIContextMenu from './UIContextMenu.js';
|
||||
import { appIconAttrs, appIconSrc } from '../helpers/appIcon.js';
|
||||
|
||||
const TASKBAR_POSITIONS = ['left', 'bottom', 'right'];
|
||||
|
||||
async function UITaskbar (options) {
|
||||
window.global_element_id++;
|
||||
|
||||
@@ -40,7 +42,8 @@ async function UITaskbar (options) {
|
||||
taskbar_position = await puter.kv.get('taskbar_position').catch(() => null);
|
||||
// if this is not first visit, set taskbar position to bottom since it's from a user that
|
||||
// used puter before customizing taskbar position was added and the taskbar position was set to bottom
|
||||
if ( ! taskbar_position ) {
|
||||
// Unknown values are discarded too: this ends up in markup and stored values may be poisoned.
|
||||
if ( ! TASKBAR_POSITIONS.includes(taskbar_position) ) {
|
||||
taskbar_position = 'bottom'; // default position
|
||||
puter.kv.set('taskbar_position', taskbar_position).catch(err => console.warn('Could not save taskbar_position:', err));
|
||||
}
|
||||
@@ -501,10 +504,7 @@ window.update_taskbar_position = async function (new_position) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Valid positions
|
||||
const valid_positions = ['left', 'bottom', 'right'];
|
||||
|
||||
if ( ! valid_positions.includes(new_position) ) {
|
||||
if ( ! TASKBAR_POSITIONS.includes(new_position) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -708,7 +708,10 @@ window.mutate_user_preferences = function (user_preferences_delta) {
|
||||
window.update_user_preferences = function (user_preferences) {
|
||||
window.user_preferences = user_preferences;
|
||||
localStorage.setItem('user_preferences', JSON.stringify(user_preferences));
|
||||
const language = user_preferences.language ?? 'en';
|
||||
// The stored value can be anything, and i18n/Intl throw on some bad locales.
|
||||
const language = window.listSupportedLanguages().some(lang => lang.code === user_preferences.language)
|
||||
? user_preferences.language
|
||||
: 'en';
|
||||
window.locale = language;
|
||||
|
||||
// Broadcast locale change to apps
|
||||
|
||||
@@ -60,7 +60,9 @@ export const sanitize_recents = (value) => {
|
||||
out.push({
|
||||
kind: entry.kind,
|
||||
id,
|
||||
name: typeof entry.name === 'string' && entry.name.trim() !== ''
|
||||
// A person is shown by the id the share goes to, so a stored name
|
||||
// can't present one recipient as another.
|
||||
name: entry.kind === 'team' && typeof entry.name === 'string' && entry.name.trim() !== ''
|
||||
? entry.name
|
||||
: id,
|
||||
});
|
||||
|
||||
@@ -37,6 +37,18 @@ describe('sanitize_recents', () => {
|
||||
.toEqual([{ kind: 'user', id: 'bob', name: 'bob' }]);
|
||||
});
|
||||
|
||||
it('shows a person or invite by their id, whatever name was stored', () => {
|
||||
expect(sanitize_recents([
|
||||
{ kind: 'user', id: 'bob', name: 'alice' },
|
||||
{ kind: 'invite', id: 'ann@example.com', name: 'carol@example.com' },
|
||||
{ kind: 'team', id: 'team-1', name: 'Design' },
|
||||
])).toEqual([
|
||||
BOB,
|
||||
ANN,
|
||||
{ kind: 'team', id: 'team-1', name: 'Design' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps one entry per recipient, the first', () => {
|
||||
expect(sanitize_recents([
|
||||
{ kind: 'user', id: 'bob', name: 'bob' },
|
||||
|
||||
Reference in new issue
Block a user