mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-21 11:01:16 +00:00
getObjectStream now attaches an 'error' listener to every body it returns. A body that fails before its consumer attaches (socket timeout, reset, or a checksum mismatch on a truncated read) used to emit 'error' with no listener and exit the process, failing every in-flight request on the task. It now logs a warning; consumers that attach later still receive the error.
662 lines
24 KiB
TypeScript
662 lines
24 KiB
TypeScript
/*
|
|
* Copyright (C) 2024-present Puter Technologies Inc.
|
|
*
|
|
* This file is part of Puter.
|
|
*
|
|
* Puter is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published
|
|
* by the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
|
import { Readable } from 'node:stream';
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
|
|
|
/**
|
|
* Route-level coverage for `GET /fs/readdir`. The unit tests call the handler
|
|
* directly, which cannot catch a route that was never registered or a gate that
|
|
* rejects the request — so this suite drives real HTTP over a listening server,
|
|
* and authenticates purely through `?auth_token=` (no request headers), which is
|
|
* the whole point of offering the read as a GET.
|
|
*/
|
|
describe('GET /fs/readdir over HTTP', () => {
|
|
let env: PuterTestEnv;
|
|
|
|
beforeAll(async () => {
|
|
env = await setupPuterTestEnv();
|
|
}, 120_000);
|
|
|
|
afterAll(async () => {
|
|
await env?.shutdown();
|
|
});
|
|
|
|
const readdirUrl = (params: Record<string, string>) => {
|
|
const url = new URL('/fs/readdir', env.apiOrigin);
|
|
for (const [key, value] of Object.entries(params)) {
|
|
url.searchParams.set(key, value);
|
|
}
|
|
return url;
|
|
};
|
|
|
|
it('lists a directory authenticated only by a query token', async () => {
|
|
const { username, token } = env.users.user;
|
|
const response = await fetch(
|
|
readdirUrl({ path: `/${username}`, auth_token: token }),
|
|
);
|
|
expect(response.status).toBe(200);
|
|
const body = (await response.json()) as Array<{
|
|
name: string;
|
|
uuid: string;
|
|
}>;
|
|
expect(Array.isArray(body)).toBe(true);
|
|
// Default provisioned home directories.
|
|
expect(body.map((e) => e.name)).toContain('Documents');
|
|
for (const entry of body) {
|
|
expect(entry.uuid).toEqual(expect.any(String));
|
|
}
|
|
});
|
|
|
|
it('paginates via query params', async () => {
|
|
const { username, token } = env.users.user;
|
|
const response = await fetch(
|
|
readdirUrl({
|
|
path: `/${username}`,
|
|
auth_token: token,
|
|
limit: '2',
|
|
cursor: '',
|
|
includeTotal: 'true',
|
|
}),
|
|
);
|
|
expect(response.status).toBe(200);
|
|
const page = (await response.json()) as {
|
|
items: unknown[];
|
|
cursor?: string;
|
|
total?: number;
|
|
};
|
|
expect(page.items.length).toBe(2);
|
|
expect(typeof page.total).toBe('number');
|
|
expect(page.cursor).toEqual(expect.any(String));
|
|
});
|
|
|
|
it('rejects an unauthenticated request', async () => {
|
|
const { username } = env.users.user;
|
|
const response = await fetch(readdirUrl({ path: `/${username}` }));
|
|
expect(response.status).toBeGreaterThanOrEqual(400);
|
|
expect(response.status).toBeLessThan(500);
|
|
});
|
|
|
|
it('does not leak internal ids or storage columns over the wire', async () => {
|
|
const { username, token } = env.users.user;
|
|
const response = await fetch(
|
|
readdirUrl({ path: `/${username}`, auth_token: token }),
|
|
);
|
|
const raw = await response.text();
|
|
const body = JSON.parse(raw) as Array<Record<string, unknown>>;
|
|
expect(body.length).toBeGreaterThan(0);
|
|
// Checked per-entry rather than by scanning the raw body: the nested
|
|
// `associatedApp` payload legitimately carries its own `id` (v1 has
|
|
// always exposed it), so a substring scan would false-alarm.
|
|
for (const entry of body) {
|
|
for (const field of [
|
|
'id',
|
|
'parentId',
|
|
'userId',
|
|
'associatedAppId',
|
|
'bucket',
|
|
'bucketRegion',
|
|
'publicToken',
|
|
'fileRequestToken',
|
|
]) {
|
|
expect(entry).not.toHaveProperty(field);
|
|
}
|
|
}
|
|
// No user-identifying data (emails, owner records) anywhere.
|
|
expect(raw).not.toContain('@');
|
|
expect(raw).not.toMatch(/"(email|owner|user_id|userId)"/);
|
|
});
|
|
|
|
it('lists a nested subtree recursively over GET', async () => {
|
|
const { username, token } = env.users.user;
|
|
const base = `/${username}/Documents/http-recursive-${Date.now()}`;
|
|
for (const path of [base, `${base}/a`, `${base}/a/b`]) {
|
|
const created = await fetch(new URL('/fs/mkdir', env.apiOrigin), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ path, auth_token: token }),
|
|
});
|
|
expect(created.status).toBe(200);
|
|
}
|
|
|
|
const response = await fetch(
|
|
readdirUrl({
|
|
path: base,
|
|
auth_token: token,
|
|
recursive: 'true',
|
|
depth: '5',
|
|
}),
|
|
);
|
|
expect(response.status).toBe(200);
|
|
const page = (await response.json()) as {
|
|
items: Array<{ path: string }>;
|
|
};
|
|
expect(
|
|
page.items.map((e) => e.path.slice(base.length + 1)).sort(),
|
|
).toEqual(['a', 'a/b']);
|
|
});
|
|
|
|
it('mkdir does not return internal ids, storage columns or tokens', async () => {
|
|
const { username, token } = env.users.user;
|
|
const response = await fetch(new URL('/fs/mkdir', env.apiOrigin), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
path: `/${username}/Documents/http-mkdir-${Date.now()}`,
|
|
auth_token: token,
|
|
}),
|
|
});
|
|
expect(response.status).toBe(200);
|
|
const entry = (await response.json()) as Record<string, unknown>;
|
|
expect(entry.uuid).toEqual(expect.any(String));
|
|
expect(entry.isDir).toBe(true);
|
|
for (const field of [
|
|
'id',
|
|
'parentId',
|
|
'userId',
|
|
'associatedAppId',
|
|
'bucket',
|
|
'bucketRegion',
|
|
'publicToken',
|
|
'fileRequestToken',
|
|
]) {
|
|
expect(entry).not.toHaveProperty(field);
|
|
}
|
|
});
|
|
|
|
it('startBatchWrite does not expose storage internals', async () => {
|
|
const { username, token } = env.users.user;
|
|
const response = await fetch(
|
|
new URL('/fs/startBatchWrite', env.apiOrigin),
|
|
{
|
|
method: 'POST',
|
|
// Array body, so there is no `auth_token` field for the auth
|
|
// probe to read — authenticate via the header instead.
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify([
|
|
{
|
|
fileMetadata: {
|
|
path: `/${username}/Documents/http-signed-${Date.now()}.bin`,
|
|
size: 4,
|
|
},
|
|
},
|
|
]),
|
|
},
|
|
);
|
|
expect(response.status).toBe(200);
|
|
const [target] = (await response.json()) as Array<
|
|
Record<string, unknown>
|
|
>;
|
|
// What a client actually needs to upload.
|
|
expect(target!.sessionId).toEqual(expect.any(String));
|
|
expect(typeof target!.url).toBe('string');
|
|
// What it does not: where the bytes physically live.
|
|
for (const field of ['bucket', 'bucketRegion', 'objectKey']) {
|
|
expect(target).not.toHaveProperty(field);
|
|
}
|
|
});
|
|
|
|
it('still serves the POST form for existing callers', async () => {
|
|
const { username, token } = env.users.user;
|
|
const response = await fetch(new URL('/fs/readdir', env.apiOrigin), {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ path: `/${username}`, auth_token: token }),
|
|
});
|
|
expect(response.status).toBe(200);
|
|
const body = (await response.json()) as Array<{ name: string }>;
|
|
expect(body.map((e) => e.name)).toContain('Documents');
|
|
});
|
|
|
|
describe('the share flag', () => {
|
|
const post = async (path: string, token: string, body: unknown) => {
|
|
const response = await fetch(new URL(path, env.apiOrigin), {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
expect(response.status).toBe(200);
|
|
return response.json() as Promise<Record<string, unknown>>;
|
|
};
|
|
|
|
const mkdir = (path: string, token: string) =>
|
|
post('/fs/mkdir', token, { path });
|
|
|
|
const readdir = async (path: string, token: string) => {
|
|
const response = await fetch(readdirUrl({ path, auth_token: token }));
|
|
expect(response.status).toBe(200);
|
|
const entries = (await response.json()) as Array<{
|
|
name: string;
|
|
isShared: boolean | null;
|
|
}>;
|
|
return new Map(entries.map((e) => [e.name, e.isShared]));
|
|
};
|
|
|
|
const base = () =>
|
|
`/${env.users.user.username}/Documents/flag-${crypto.randomUUID().slice(0, 8)}`;
|
|
|
|
it('marks a shared child in a listing, and clears it on revoke', async () => {
|
|
const owner = env.users.user;
|
|
const recipient = env.users.other;
|
|
const parent = base();
|
|
await mkdir(parent, owner.token);
|
|
const shared = await mkdir(`${parent}/shared`, owner.token);
|
|
await mkdir(`${parent}/private`, owner.token);
|
|
|
|
// A write response says nothing about sharing.
|
|
expect(shared).not.toHaveProperty('isShared');
|
|
|
|
await post('/share', owner.token, {
|
|
recipients: [recipient.username],
|
|
items: [{ uid: shared.uuid }],
|
|
mode: 'read',
|
|
});
|
|
|
|
expect(await readdir(parent, owner.token)).toEqual(
|
|
new Map([
|
|
['shared', true],
|
|
['private', false],
|
|
]),
|
|
);
|
|
|
|
await post('/share/revoke', owner.token, {
|
|
recipients: [recipient.username],
|
|
items: [{ uid: shared.uuid }],
|
|
});
|
|
|
|
expect(await readdir(parent, owner.token)).toEqual(
|
|
new Map([
|
|
['shared', false],
|
|
['private', false],
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('names the recipients in stat only when asked', async () => {
|
|
const owner = env.users.user;
|
|
const recipient = env.users.other;
|
|
const parent = base();
|
|
await mkdir(parent, owner.token);
|
|
const shared = await mkdir(`${parent}/shared`, owner.token);
|
|
|
|
await post('/share', owner.token, {
|
|
recipients: [recipient.username],
|
|
items: [{ uid: shared.uuid }],
|
|
mode: 'read',
|
|
});
|
|
|
|
const plain = await post('/fs/stat', owner.token, {
|
|
uid: shared.uuid,
|
|
});
|
|
expect(plain.isShared).toBe(true);
|
|
expect(plain).not.toHaveProperty('shares');
|
|
|
|
|
|
const withShares = await post('/fs/stat', owner.token, {
|
|
uid: shared.uuid,
|
|
return_shares: true,
|
|
});
|
|
const shares = withShares.shares as Array<Record<string, unknown>>;
|
|
expect(shares).toHaveLength(1);
|
|
expect(shares[0]).toMatchObject({
|
|
holder: recipient.username,
|
|
issuer: owner.username,
|
|
mode: 'read',
|
|
inherited_from: null,
|
|
});
|
|
for (const key of ['holder_user_id', 'issuer_user_id', 'fsentry_id']) {
|
|
expect(shares[0]).not.toHaveProperty(key);
|
|
}
|
|
});
|
|
|
|
it('tells a recipient nothing about who else can reach the item', async () => {
|
|
const owner = env.users.user;
|
|
const recipient = env.users.other;
|
|
const parent = base();
|
|
await mkdir(parent, owner.token);
|
|
const shared = await mkdir(`${parent}/shared`, owner.token);
|
|
|
|
await post('/share', owner.token, {
|
|
recipients: [recipient.username],
|
|
items: [{ uid: shared.uuid }],
|
|
mode: 'read',
|
|
});
|
|
|
|
const stat = await post('/fs/stat', recipient.token, {
|
|
uid: shared.uuid,
|
|
return_shares: true,
|
|
});
|
|
expect(stat.isShared).toBeNull();
|
|
// Asked for, so the key is there — but it names nobody.
|
|
expect(stat.shares).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('the root listing', () => {
|
|
const mint = async (permissions: string[]) => {
|
|
const response = await fetch(
|
|
new URL('/auth/create-access-token', env.apiOrigin),
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
authorization: `Bearer ${env.users.user.token}`,
|
|
},
|
|
body: JSON.stringify({ permissions }),
|
|
},
|
|
);
|
|
expect(response.status).toBe(200);
|
|
return ((await response.json()) as { token: string }).token;
|
|
};
|
|
|
|
const listRoot = async (token: string) => {
|
|
const response = await fetch(
|
|
readdirUrl({ path: '/', auth_token: token }),
|
|
);
|
|
expect(response.status).toBe(200);
|
|
return response.text();
|
|
};
|
|
|
|
const rootPaths = async (token: string) => {
|
|
const entries = JSON.parse(await listRoot(token)) as Array<{
|
|
path: string;
|
|
}>;
|
|
return entries.map((entry) => entry.path);
|
|
};
|
|
|
|
const entryAt = async (path: string) =>
|
|
(await env.server.stores.fsEntry.getEntryByPath(path))!;
|
|
|
|
it('lists the home for a session and a full-access token', async () => {
|
|
const { username, token, apiToken } = env.users.user;
|
|
for (const credential of [token, apiToken]) {
|
|
expect(await rootPaths(credential)).toEqual([`/${username}`]);
|
|
}
|
|
});
|
|
|
|
it('leaves the home out for a token scoped below it', async () => {
|
|
const { username } = env.users.user;
|
|
const home = await entryAt(`/${username}`);
|
|
const documents = await entryAt(`/${username}/Documents`);
|
|
const scoped = await mint([`fs:${documents.uuid}:read`]);
|
|
|
|
const raw = await listRoot(scoped);
|
|
|
|
expect(JSON.parse(raw)).toEqual([]);
|
|
expect(raw).not.toContain(home.uuid);
|
|
expect(raw).not.toContain(username);
|
|
});
|
|
|
|
it('lists the home for a token that can list it', async () => {
|
|
const { username } = env.users.user;
|
|
const home = await entryAt(`/${username}`);
|
|
const scoped = await mint([`fs:${home.uuid}:list`]);
|
|
|
|
expect(await rootPaths(scoped)).toEqual([`/${username}`]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('POST /fs/completeWrite over HTTP', () => {
|
|
let env: PuterTestEnv;
|
|
|
|
beforeAll(async () => {
|
|
env = await setupPuterTestEnv();
|
|
}, 120_000);
|
|
|
|
afterAll(async () => {
|
|
await env?.shutdown();
|
|
});
|
|
|
|
it('refuses completion of a session whose object never arrived', async () => {
|
|
const { username, token } = env.users.user;
|
|
const started = await fetch(new URL('/fs/startWrite', env.apiOrigin), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({
|
|
fileMetadata: {
|
|
path: `/${username}/Documents/http-nobytes-${Date.now()}.bin`,
|
|
size: 4,
|
|
},
|
|
}),
|
|
});
|
|
expect(started.status).toBe(200);
|
|
const { sessionId } = (await started.json()) as { sessionId: string };
|
|
|
|
// No PUT to the presigned URL.
|
|
const response = await fetch(
|
|
new URL('/fs/completeWrite', env.apiOrigin),
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({ uploadId: sessionId }),
|
|
},
|
|
);
|
|
|
|
expect(response.status).toBe(400);
|
|
const body = (await response.json()) as {
|
|
code?: string;
|
|
message?: string;
|
|
};
|
|
expect(body.code).toBe('bad_request');
|
|
expect(body.message).toBe('Upload content was not received');
|
|
});
|
|
});
|
|
|
|
describe('POST /fs/startWrite storage quota over HTTP', () => {
|
|
let env: PuterTestEnv;
|
|
|
|
beforeAll(async () => {
|
|
env = await setupPuterTestEnv({
|
|
is_storage_limited: true,
|
|
storage_capacity: 10 * 1024,
|
|
} as never);
|
|
}, 120_000);
|
|
|
|
afterAll(async () => {
|
|
await env?.shutdown();
|
|
});
|
|
|
|
it('rejects the excess of a parallel burst of startWrite calls', async () => {
|
|
const { username, token } = env.users.user;
|
|
const user = await env.server.stores.user.getByUsername(username);
|
|
const fs = env.server.services.fs as unknown as {
|
|
getUsersStorageAllowance: (
|
|
userId: number,
|
|
) => Promise<{ curr: number; max: number }>;
|
|
};
|
|
const { curr, max } = await fs.getUsersStorageAllowance(user!.id);
|
|
const size = Math.floor((max - curr) / 3) + 1;
|
|
|
|
const responses = await Promise.all(
|
|
Array.from({ length: 8 }, (_, i) =>
|
|
fetch(new URL('/fs/startWrite', env.apiOrigin), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({
|
|
fileMetadata: {
|
|
path: `/${username}/Documents/http-quota-burst-${i}.bin`,
|
|
size,
|
|
},
|
|
}),
|
|
}),
|
|
),
|
|
);
|
|
|
|
const ok = responses.filter((response) => response.status === 200);
|
|
const rejected = responses.filter(
|
|
(response) => response.status === 413,
|
|
);
|
|
expect(ok).toHaveLength(2);
|
|
expect(rejected).toHaveLength(6);
|
|
|
|
for (const response of rejected) {
|
|
const body = (await response.json()) as { code?: string };
|
|
expect(body.code).toBe('storage_limit_reached');
|
|
}
|
|
});
|
|
|
|
// A raw JSON body carries `1e400` as a numeral, not a computed value, so
|
|
// `JSON.stringify` can't produce it (it turns `Infinity` into `null`) —
|
|
// the wire text is built by hand to exercise what the server's own
|
|
// `JSON.parse` does with it.
|
|
it('rejects a declared size of 1e400 (parses to Infinity) with 400', async () => {
|
|
const { username, token } = env.users.user;
|
|
const body = `{"fileMetadata":{"path":"/${username}/Documents/http-huge.bin","size":1e400}}`;
|
|
|
|
const response = await fetch(new URL('/fs/startWrite', env.apiOrigin), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
const responseBody = (await response.json()) as { code?: string };
|
|
expect(responseBody.code).toBe('bad_request');
|
|
});
|
|
});
|
|
|
|
describe('file reads over HTTP when storage fails mid-download', () => {
|
|
let env: PuterTestEnv;
|
|
|
|
beforeAll(async () => {
|
|
env = await setupPuterTestEnv();
|
|
}, 120_000);
|
|
|
|
afterAll(async () => {
|
|
await env?.shutdown();
|
|
});
|
|
|
|
const writeFile = async (name: string, content: string) => {
|
|
const { username } = env.users.user;
|
|
const user = await env.server.stores.user.getByUsername(username);
|
|
const body = Buffer.from(content);
|
|
const { fsEntry } = await env.server.services.fs.write(user!.id, {
|
|
fileMetadata: {
|
|
path: `/${username}/Documents/${name}`,
|
|
size: body.byteLength,
|
|
contentType: 'text/plain',
|
|
},
|
|
fileContent: body,
|
|
});
|
|
return fsEntry;
|
|
};
|
|
|
|
// Storage hands over one chunk and then fails, the way a dropped
|
|
// connection or a checksum mismatch surfaces on a read in flight.
|
|
const breakReadsOf = (objectKey: string) => {
|
|
const s3 = env.server.stores.s3Object;
|
|
const client = env.server.clients.s3.get(s3.resolveRegion(null));
|
|
const send = client.send.bind(client);
|
|
return vi.spyOn(client, 'send').mockImplementation(((
|
|
command: unknown,
|
|
) => {
|
|
if (
|
|
!(command instanceof GetObjectCommand) ||
|
|
command.input.Key !== objectKey
|
|
) {
|
|
return send(command as never);
|
|
}
|
|
let reads = 0;
|
|
const body = new Readable({
|
|
read() {
|
|
reads += 1;
|
|
if (reads === 1) this.push('partial');
|
|
// Late enough for the headers and first chunk to go out.
|
|
else if (reads === 2)
|
|
setTimeout(
|
|
() => this.destroy(new Error('Checksum mismatch')),
|
|
25,
|
|
);
|
|
},
|
|
});
|
|
return Promise.resolve({
|
|
Body: body,
|
|
ContentLength: 64,
|
|
ContentType: 'text/plain',
|
|
});
|
|
}) as never);
|
|
};
|
|
|
|
it.each([
|
|
['/fs/read', 'path'],
|
|
['/read', 'file'],
|
|
])(
|
|
'GET %s fails that response and keeps serving others',
|
|
async (route, param) => {
|
|
const { username, token } = env.users.user;
|
|
const urlFor = (name: string) => {
|
|
const url = new URL(route, env.apiOrigin);
|
|
url.searchParams.set(param, `/${username}/Documents/${name}`);
|
|
return url;
|
|
};
|
|
const suffix = route.replace(/\W/g, '');
|
|
const broken = await writeFile(
|
|
`broken-${suffix}.txt`,
|
|
'x'.repeat(64),
|
|
);
|
|
await writeFile(`healthy-${suffix}.txt`, 'still fine');
|
|
|
|
const send = breakReadsOf(broken.uuid);
|
|
let status = 0;
|
|
let completed = false;
|
|
try {
|
|
const response = await fetch(urlFor(`broken-${suffix}.txt`), {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
status = response.status;
|
|
await response.arrayBuffer();
|
|
completed = true;
|
|
} catch {
|
|
// Expected: the transfer is cut short.
|
|
} finally {
|
|
send.mockRestore();
|
|
}
|
|
expect(status).toBe(200);
|
|
expect(completed).toBe(false);
|
|
|
|
const healthy = await fetch(urlFor(`healthy-${suffix}.txt`), {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
expect(healthy.status).toBe(200);
|
|
expect(await healthy.text()).toBe('still fine');
|
|
},
|
|
);
|
|
});
|