open-design/apps/daemon/tests/storage.test.ts
Cursor Agent 9b2ce2bbc4
feat(daemon): S3ProjectStorage live impl via AWS SigV4 (Phase 5)
Plan U1 / spec §15.6.

apps/daemon/src/storage/aws-sigv4.ts ships a minimal AWS Signature
V4 signer using only node:crypto. No `@aws-sdk/*` dep is pulled
in; the OD daemon ships without an extra ~60 MB of SDK code on
disk.

  signSigV4({ method, path, query, headers, body, region, service,
              credentials, now? }) \u2192 { authorization, amzDate,
                                          contentSha256 }

The signer mutates the supplied headers map by adding the four
required amz fields ('x-amz-date', 'x-amz-content-sha256',
optional 'x-amz-security-token', and the final 'authorization').
The 'now' override pins signatures for tests.

apps/daemon/src/storage/project-storage.ts S3ProjectStorage now
implements the five operations the ProjectStorage interface
declares — all five round-trip through pluggable fetch:

  readFile    \u2192 GET    /<key>
  writeFile   \u2192 PUT    /<key>          (with x-amz-content-sha256)
  deleteFile  \u2192 DELETE /<key>          (idempotent on 404)
  statFile    \u2192 HEAD   /<key>          (returns null on 404)
  listFiles   \u2192 GET    /?list-type=2&prefix=<projectId>/
                walks NextContinuationToken for paginated buckets.

Style:
  - Default to virtual-host-style endpoints (`<bucket>.s3.<region>.amazonaws.com`).
  - When OD_S3_ENDPOINT is set, switch to path-style (`<endpoint>/<bucket>/<key>`) for
    Aliyun OSS / Tencent COS / Huawei OBS / MinIO.

Credentials are read in this order:
  OD_S3_ACCESS_KEY_ID / OD_S3_SECRET_ACCESS_KEY / OD_S3_SESSION_TOKEN
  AWS_ACCESS_KEY_ID  / AWS_SECRET_ACCESS_KEY  / AWS_SESSION_TOKEN
\u2026so existing AWS toolchain setups (`aws configure` exporters,
IAM-role pods) drop in without renaming.

Daemon tests: 1650 \u2192 1661 (+11).

  +4 plugins-aws-sigv4 cases:
    - AWS-published reference vector (GetObject) hashes byte-equal.
    - List-bucket query stays canonical-sorted.
    - x-amz-security-token forwards through SignedHeaders.
    - encodeS3PathSegment handles unreserved / reserved chars.

  +7 storage S3 cases (replacing the prior throw-only stub):
    - PUT signs + reports ProjectFileMeta back.
    - GET returns body bytes; 404 \u2192 NOT_FOUND.
    - HEAD returns null on 404 + parses Content-Length / Last-Modified.
    - DELETE swallows 404, rejects 500.
    - LIST parses ListBucketV2 XML + walks NextContinuationToken.
    - endpoint override switches to path-style.
    - resolveProjectStorage falls back to AWS_* env vars when OD_*
      knobs are unset.

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 16:10:05 +00:00

303 lines
12 KiB
TypeScript

// Phase 5 / spec §15.6 — ProjectStorage + DaemonDb adapter tests.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
LocalProjectStorage,
S3ProjectStorage,
StorageError,
resolveProjectStorage,
} from '../src/storage/project-storage.js';
import {
DaemonDbConfigError,
resolveDaemonDbConfig,
} from '../src/storage/daemon-db.js';
let tmp: string;
beforeEach(async () => {
tmp = await mkdtemp(path.join(os.tmpdir(), 'od-storage-'));
});
afterEach(async () => {
await rm(tmp, { recursive: true, force: true });
});
describe('LocalProjectStorage', () => {
it('writes, lists, reads, stats, and deletes a file', async () => {
const storage = new LocalProjectStorage(tmp);
const meta = await storage.writeFile('p1', 'hello.txt', Buffer.from('hi'));
expect(meta.path).toBe('hello.txt');
expect(meta.size).toBe(2);
const list = await storage.listFiles('p1');
expect(list.map((f) => f.path).sort()).toEqual(['hello.txt']);
const buf = await storage.readFile('p1', 'hello.txt');
expect(buf.toString('utf8')).toBe('hi');
const stat = await storage.statFile('p1', 'hello.txt');
expect(stat?.size).toBe(2);
await storage.deleteFile('p1', 'hello.txt');
expect(await storage.statFile('p1', 'hello.txt')).toBeNull();
});
it('walks nested directories on list', async () => {
const projectRoot = path.join(tmp, 'p2');
await mkdir(path.join(projectRoot, 'a', 'b'), { recursive: true });
await writeFile(path.join(projectRoot, 'a', 'b', 'deep.txt'), 'x');
const storage = new LocalProjectStorage(tmp);
const list = await storage.listFiles('p2');
expect(list.map((f) => f.path)).toEqual(['a/b/deep.txt']);
});
it('rejects path-traversal and unsafe ids', async () => {
const storage = new LocalProjectStorage(tmp);
await expect(storage.readFile('p1', '../escape')).rejects.toBeInstanceOf(StorageError);
await expect(storage.readFile('../bad', 'x.txt')).rejects.toBeInstanceOf(StorageError);
await expect(storage.readFile('p1', '')).rejects.toBeInstanceOf(StorageError);
});
it('returns NOT_FOUND on a missing file', async () => {
const storage = new LocalProjectStorage(tmp);
await expect(storage.readFile('p1', 'no-such.txt')).rejects.toMatchObject({
code: 'NOT_FOUND',
});
});
});
describe('S3ProjectStorage', () => {
const fixedNow = () => new Date('2026-05-09T12:00:00.000Z');
const credentials = { accessKeyId: 'AKIA-FIXTURE', secretAccessKey: 'shhh' };
it('builds a canonical key with the configured prefix', () => {
const storage = new S3ProjectStorage({
bucket: 'od-bucket',
region: 'us-east-1',
prefix: 'tenant-a/',
credentials,
fetchFn: (async () => new Response('')) as unknown as typeof fetch,
});
expect(storage.keyFor('p1', 'a/b/c.txt')).toBe('tenant-a/p1/a/b/c.txt');
});
it('refuses to instantiate without bucket / region / credentials', () => {
expect(() => new S3ProjectStorage({ bucket: '', region: 'r', credentials, fetchFn: globalThis.fetch })).toThrow(StorageError);
expect(() => new S3ProjectStorage({ bucket: 'b', region: '', credentials, fetchFn: globalThis.fetch })).toThrow(StorageError);
expect(() => new S3ProjectStorage({ bucket: 'b', region: 'r', credentials: { accessKeyId: '', secretAccessKey: 's' }, fetchFn: globalThis.fetch })).toThrow(StorageError);
expect(() => new S3ProjectStorage({ bucket: 'b', region: 'r', credentials: { accessKeyId: 'a', secretAccessKey: '' }, fetchFn: globalThis.fetch })).toThrow(StorageError);
});
it('PUT signs the request + reports back ProjectFileMeta on success', async () => {
const seen: Array<{ url: string; method: string; headers: Record<string, string> }> = [];
const fetchFn = (async (url: string, init: RequestInit) => {
seen.push({
url,
method: init.method ?? 'GET',
headers: Object.fromEntries(Object.entries(init.headers ?? {}) as Array<[string, string]>),
});
return new Response('', { status: 200 });
}) as unknown as typeof fetch;
const storage = new S3ProjectStorage({
bucket: 'od-bucket', region: 'us-east-1', credentials,
fetchFn, now: fixedNow,
});
const meta = await storage.writeFile('p1', 'hello.txt', Buffer.from('hi'));
expect(meta.path).toBe('hello.txt');
expect(meta.size).toBe(2);
expect(seen).toHaveLength(1);
expect(seen[0]?.method).toBe('PUT');
expect(seen[0]?.url).toBe('https://od-bucket.s3.us-east-1.amazonaws.com/p1/hello.txt');
expect(seen[0]?.headers.authorization).toMatch(/^AWS4-HMAC-SHA256 Credential=AKIA-FIXTURE\/20260509\/us-east-1\/s3\/aws4_request/);
expect(seen[0]?.headers['x-amz-date']).toBe('20260509T120000Z');
// The body sha256 lands on the header so an upstream proxy /
// bucket-policy can verify integrity.
expect(seen[0]?.headers['x-amz-content-sha256']).toMatch(/^[a-f0-9]{64}$/);
});
it('GET returns the body bytes as a Buffer', async () => {
const fetchFn = (async () => new Response('hello world', {
status: 200,
headers: { 'content-type': 'text/plain' },
})) as unknown as typeof fetch;
const storage = new S3ProjectStorage({
bucket: 'od-bucket', region: 'us-east-1', credentials, fetchFn, now: fixedNow,
});
const buf = await storage.readFile('p1', 'hello.txt');
expect(buf.toString('utf8')).toBe('hello world');
});
it('GET 404 surfaces StorageError with code=NOT_FOUND', async () => {
const fetchFn = (async () => new Response('', { status: 404 })) as unknown as typeof fetch;
const storage = new S3ProjectStorage({
bucket: 'od-bucket', region: 'us-east-1', credentials, fetchFn,
});
await expect(storage.readFile('p1', 'no.txt')).rejects.toMatchObject({ code: 'NOT_FOUND' });
});
it('HEAD returns null on 404 + parses content-length / last-modified on 200', async () => {
let respond: (() => Response) = () => new Response('', { status: 404 });
const fetchFn = (async () => respond()) as unknown as typeof fetch;
const storage = new S3ProjectStorage({
bucket: 'od-bucket', region: 'us-east-1', credentials, fetchFn,
});
expect(await storage.statFile('p1', 'x')).toBeNull();
respond = () => new Response('', {
status: 200,
headers: {
'content-length': '17',
'last-modified': 'Sat, 09 May 2026 11:59:00 GMT',
},
});
const stat = await storage.statFile('p1', 'x');
expect(stat?.size).toBe(17);
expect(stat?.mtimeMs).toBe(Date.parse('Sat, 09 May 2026 11:59:00 GMT'));
});
it('DELETE swallows 404 (idempotent) and rejects on 500', async () => {
let status = 204;
const fetchFn = (async () => {
// 204 forbids a body in the WHATWG fetch spec; use null.
return status === 204 ? new Response(null, { status }) : new Response('', { status });
}) as unknown as typeof fetch;
const storage = new S3ProjectStorage({
bucket: 'od-bucket', region: 'us-east-1', credentials, fetchFn,
});
await storage.deleteFile('p1', 'x');
status = 404;
await storage.deleteFile('p1', 'gone');
status = 500;
await expect(storage.deleteFile('p1', 'broken')).rejects.toBeInstanceOf(StorageError);
});
it('LIST parses ListBucketV2 XML and walks continuation tokens', async () => {
const pages = [
`<?xml version="1.0"?><ListBucketResult>
<Contents><Key>p1/a.txt</Key><Size>3</Size><LastModified>2026-05-09T11:00:00Z</LastModified></Contents>
<Contents><Key>p1/sub/b.txt</Key><Size>5</Size><LastModified>2026-05-09T11:01:00Z</LastModified></Contents>
<IsTruncated>true</IsTruncated>
<NextContinuationToken>tok-2</NextContinuationToken>
</ListBucketResult>`,
`<?xml version="1.0"?><ListBucketResult>
<Contents><Key>p1/sub/c.txt</Key><Size>7</Size><LastModified>2026-05-09T11:02:00Z</LastModified></Contents>
<IsTruncated>false</IsTruncated>
</ListBucketResult>`,
];
let page = 0;
const seenUrls: string[] = [];
const fetchFn = (async (url: string) => {
seenUrls.push(url);
const body = pages[page] ?? '<ListBucketResult/>';
page++;
return new Response(body, { status: 200 });
}) as unknown as typeof fetch;
const storage = new S3ProjectStorage({
bucket: 'od-bucket', region: 'us-east-1', credentials, fetchFn,
});
const list = await storage.listFiles('p1');
expect(list.map((f) => f.path).sort()).toEqual(['a.txt', 'sub/b.txt', 'sub/c.txt']);
expect(list[0]?.size).toBe(3);
// Second call carries the continuation-token query.
expect(seenUrls[1]).toContain('continuation-token=tok-2');
});
it('endpoint override switches to path-style + reuses the host', async () => {
const seen: string[] = [];
const fetchFn = (async (url: string) => {
seen.push(url);
return new Response('', { status: 200 });
}) as unknown as typeof fetch;
const storage = new S3ProjectStorage({
bucket: 'od-bucket', region: 'us-east-1',
endpoint: 'https://oss.aliyuncs.com',
credentials, fetchFn, now: fixedNow,
});
await storage.writeFile('p1', 'a.txt', Buffer.from('x'));
expect(seen[0]).toBe('https://oss.aliyuncs.com/od-bucket/p1/a.txt');
});
});
describe('resolveProjectStorage', () => {
it('defaults to LocalProjectStorage', () => {
const storage = resolveProjectStorage({ projectsRoot: tmp, env: {} });
expect(storage).toBeInstanceOf(LocalProjectStorage);
});
it('returns S3ProjectStorage when OD_PROJECT_STORAGE=s3', () => {
const storage = resolveProjectStorage({
projectsRoot: tmp,
env: {
OD_PROJECT_STORAGE: 's3',
OD_S3_BUCKET: 'my-bucket',
OD_S3_REGION: 'us-east-1',
OD_S3_PREFIX: 'tenant',
OD_S3_ENDPOINT: 'https://oss.aliyuncs.com',
OD_S3_ACCESS_KEY_ID: 'AKIA-FIXTURE',
OD_S3_SECRET_ACCESS_KEY: 'shhh',
},
});
expect(storage).toBeInstanceOf(S3ProjectStorage);
expect((storage as S3ProjectStorage).options).toMatchObject({
bucket: 'my-bucket',
region: 'us-east-1',
prefix: 'tenant',
endpoint: 'https://oss.aliyuncs.com',
});
});
it('falls back to AWS_ACCESS_KEY_ID / AWS_REGION when the OD_-specific knobs are unset', () => {
const storage = resolveProjectStorage({
projectsRoot: tmp,
env: {
OD_PROJECT_STORAGE: 's3',
OD_S3_BUCKET: 'my-bucket',
AWS_REGION: 'us-east-2',
AWS_ACCESS_KEY_ID: 'AKIA-AWS',
AWS_SECRET_ACCESS_KEY: 'aws-secret',
},
});
expect(storage).toBeInstanceOf(S3ProjectStorage);
expect((storage as S3ProjectStorage).options).toMatchObject({
region: 'us-east-2',
});
expect((storage as S3ProjectStorage).options.credentials.accessKeyId).toBe('AKIA-AWS');
});
});
describe('resolveDaemonDbConfig', () => {
it('defaults to sqlite', () => {
expect(resolveDaemonDbConfig({})).toEqual({ kind: 'sqlite' });
});
it('parses postgres env vars when OD_DAEMON_DB=postgres', () => {
const cfg = resolveDaemonDbConfig({
OD_DAEMON_DB: 'postgres',
OD_PG_HOST: 'pg.local',
OD_PG_PORT: '6543',
OD_PG_DATABASE: 'open_design',
OD_PG_USER: 'od',
OD_PG_SSL_MODE: 'disable',
});
expect(cfg.kind).toBe('postgres');
expect(cfg.postgres).toEqual({
host: 'pg.local',
port: 6543,
database: 'open_design',
user: 'od',
sslMode: 'disable',
});
});
it('throws when postgres env vars are incomplete', () => {
expect(() =>
resolveDaemonDbConfig({ OD_DAEMON_DB: 'postgres', OD_PG_HOST: 'pg.local' }),
).toThrow(DaemonDbConfigError);
});
it('throws on an unknown OD_DAEMON_DB value', () => {
expect(() => resolveDaemonDbConfig({ OD_DAEMON_DB: 'mongo' })).toThrow(DaemonDbConfigError);
});
});