mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 13:33:00 +08:00
* feat: initial commit (mise, nest, restic-api) * ci: should use new nested tasks * chore: use absolute imports * chore: format * fix: absolute imports for jest * feat: REST API impl. with in-memory storage * feat: s3 storage backing (wip) * fix: use Range in S3 get object * feat: validate path parameters * chore: clean up checks * chore: remove TODO * docs: add descriptions to mise commands * feat: JWT auth * feat: load secrets from env (validate w. zod) * chore: set default compose variables * chore: add docker commands to mise * fix: don't eat JWT errors * fix: obfuscate s3 errors * chore: validate auth schema * feat: commit errors.ts * fix: return partial status if Range provided * chore: split jwt/payload validation errors * fix: check name is a sha256 hash * feat: streaming download * feat: streaming upload * feat: WORM / write once support * fix: don't obscure other S3 errors for checkBucket * refactor: more concise error types * test: service unit tests for app/auth; e2e for storage * chore: prettier ignore .dev folder * feat: interceptor for Restic JSON routes * test: app e2e tests (wip) * test: partial content response * chore: lint * refactor: use class-validator for auth dto * refactor: clean up constants through project * refactor: also update binary content type * fix: incorrect Basic parsing * fix: actually handle validation errors in auth dto * chore: delete .gitignore * fix: should use 403 not 405 in worm * chore: expose minio console for debug * feat: delete config route * fix: error consistency with restic reference * feat: validate sha256 on blob upload * fix: use bad request exception for mismatch * feat: initial work on full e2e test * fix: respond with correct headers for Range refactor: unify respond logic * test: backup & restore e2e test * feat: setup empty yucca-api project * feat: setup kysely for yucca-api & db repository * refactor: `utils.ts` -> `utils/s3.ts` * fix: shut down database after e2e test * chore: remove stray log * test: refactor getObject test * chore: add built wrapper for now * chore: add e2e/ to mise task * chore: format * fix: esm jest * feat: setup database migrator * feat: minimal template for frontend project * chore: add prettier-plugin-tailwindcss to workspace * chore: configure @immich/ui * feat: sdk for web, hook up sample * chore: add @oazapfts/runtime dependency * chore: ignore `yucca-sdk/src/fetch-client.ts` from eslint * chore: format * chore: move all CI check stages into mise config * test: mock $env/dynamic/publish for ui library * chore: format code * fix: correct restic API port in e2e test * chore: move tasks around * chore: use restic-wrapper from npm * chore: string * test: comprehensive e2e tests for restic API * test: cover new commands in e2e tests * test: worm-case for prune * feat: lingui for svelte * chore: ensure check runs lingui * chore: ignore locales from checks * chore: format/lint * chore: configure nix-ld for mise * chore: accept any no. of arguments for docker tasks/e2e * chore: different strategy for e2e clean up * chore: configure Git Town Signed-off-by: izzy <me@insrt.uk> * chore: add default.nix for mise Signed-off-by: izzy <me@insrt.uk> * chore: lost changes from parent Signed-off-by: izzy <me@insrt.uk> * fix: use valid language code --------- Signed-off-by: izzy <me@insrt.uk>
392 lines
13 KiB
TypeScript
392 lines
13 KiB
TypeScript
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { createHash, randomUUID } from 'node:crypto';
|
|
import request from 'supertest';
|
|
import { App } from 'supertest/types';
|
|
import { AppModule } from './../src/app.module';
|
|
|
|
const makeAuthHeader = (token: string) => 'Basic ' + Buffer.from(`_:${token}`).toString('base64');
|
|
|
|
describe('AppController (e2e)', () => {
|
|
let app: INestApplication<App>;
|
|
|
|
let repository: string;
|
|
let authHeader: string;
|
|
let wormAuthHeader: string;
|
|
|
|
beforeEach(async () => {
|
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
|
imports: [AppModule],
|
|
}).compile();
|
|
|
|
app = moduleFixture.createNestApplication();
|
|
app.useGlobalPipes(new ValidationPipe());
|
|
await app.init();
|
|
|
|
const jwtService = app.get(JwtService);
|
|
repository = randomUUID();
|
|
authHeader = makeAuthHeader(jwtService.sign({ user: randomUUID(), repository, writeOnce: false }));
|
|
wormAuthHeader = makeAuthHeader(jwtService.sign({ user: randomUUID(), repository, writeOnce: true }));
|
|
});
|
|
|
|
describe('POST /:path', () => {
|
|
it('fails if create is not true', async () => {
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}?create=false`)
|
|
.set('Authorization', authHeader)
|
|
.expect(400);
|
|
});
|
|
|
|
it('creates the repository', async () => {
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}?create=true`)
|
|
.set('Authorization', authHeader)
|
|
.expect(200);
|
|
});
|
|
|
|
it('fails if repository already exists', async () => {
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}?create=true`)
|
|
.set('Authorization', authHeader)
|
|
.expect(200);
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}?create=true`)
|
|
.set('Authorization', authHeader)
|
|
.expect(409);
|
|
});
|
|
});
|
|
|
|
describe('DELETE /:path', () => {
|
|
it('returns not implemented', async () => {
|
|
await request(app.getHttpServer()).delete(`/${repository}`).set('Authorization', authHeader).expect(501);
|
|
});
|
|
});
|
|
|
|
describe('HEAD /:path/config', () => {
|
|
it('returns 404 if config does not exist', async () => {
|
|
await request(app.getHttpServer()).head(`/${repository}/config`).set('Authorization', authHeader).expect(404);
|
|
});
|
|
|
|
it('returns content-length if config exists', async () => {
|
|
const config = 'test-config-data';
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}?create=true`)
|
|
.set('Authorization', authHeader)
|
|
.expect(200);
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}/config`)
|
|
.set('Authorization', authHeader)
|
|
.set('Content-Type', 'application/octet-stream')
|
|
.send(Buffer.from(config))
|
|
.expect(200);
|
|
|
|
await request(app.getHttpServer())
|
|
.head(`/${repository}/config`)
|
|
.set('Authorization', authHeader)
|
|
.expect(200)
|
|
.expect('Content-Length', String(config.length));
|
|
});
|
|
});
|
|
|
|
describe('GET, POST /:path/config', () => {
|
|
it('saves and returns config data', async () => {
|
|
const config = 'test-config-data';
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}?create=true`)
|
|
.set('Authorization', authHeader)
|
|
.expect(200);
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}/config`)
|
|
.set('Authorization', authHeader)
|
|
.set('Content-Type', 'application/octet-stream')
|
|
.send(Buffer.from(config))
|
|
.expect(200);
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.get(`/${repository}/config`)
|
|
.set('Authorization', authHeader)
|
|
.expect(200)
|
|
.expect('Content-Type', 'application/octet-stream');
|
|
|
|
expect(response.body.toString()).toBe(config);
|
|
});
|
|
|
|
it('fails to overwrite config with worm', async () => {
|
|
const config = 'test-config-data';
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}?create=true`)
|
|
.set('Authorization', wormAuthHeader)
|
|
.expect(200);
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}/config`)
|
|
.set('Authorization', wormAuthHeader)
|
|
.set('Content-Type', 'application/octet-stream')
|
|
.send(Buffer.from(config))
|
|
.expect(200);
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}/config`)
|
|
.set('Authorization', wormAuthHeader)
|
|
.set('Content-Type', 'application/octet-stream')
|
|
.send(Buffer.from(config))
|
|
.expect(403);
|
|
});
|
|
});
|
|
|
|
describe('GET /:path/:type', () => {
|
|
it('returns 501 without restic v2 accept header', async () => {
|
|
await request(app.getHttpServer()).get(`/${repository}/data`).set('Authorization', authHeader).expect(501);
|
|
});
|
|
|
|
it('returns empty list when no blobs exist', async () => {
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}?create=true`)
|
|
.set('Authorization', authHeader)
|
|
.expect(200);
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.get(`/${repository}/data`)
|
|
.set('Authorization', authHeader)
|
|
.set('Accept', 'application/vnd.x.restic.rest.v2')
|
|
.expect(200)
|
|
.expect('Content-Type', 'application/vnd.x.restic.rest.v2');
|
|
|
|
expect(JSON.parse(response.text)).toEqual([]);
|
|
});
|
|
|
|
it('returns list of blobs when they exist', async () => {
|
|
const blobData = 'test-blob-data';
|
|
const blobName = createHash('sha256').update(blobData).digest('hex');
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}?create=true`)
|
|
.set('Authorization', authHeader)
|
|
.expect(200);
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}/data/${blobName}`)
|
|
.set('Authorization', authHeader)
|
|
.set('Content-Type', 'application/octet-stream')
|
|
.send(Buffer.from(blobData));
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}/data/${blobName}`)
|
|
.set('Authorization', authHeader)
|
|
.set('Content-Type', 'application/octet-stream')
|
|
.send(Buffer.from(blobData))
|
|
.expect(200);
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.get(`/${repository}/data`)
|
|
.set('Authorization', authHeader)
|
|
.set('Accept', 'application/vnd.x.restic.rest.v2')
|
|
.expect(200)
|
|
.expect('Content-Type', 'application/vnd.x.restic.rest.v2');
|
|
|
|
expect(JSON.parse(response.text)).toEqual([{ name: blobName, size: blobData.length }]);
|
|
});
|
|
});
|
|
|
|
describe('HEAD /:path/:type/:name', () => {
|
|
it('returns 404 if blob does not exist', async () => {
|
|
await request(app.getHttpServer())
|
|
.head(`/${repository}/data/${'a'.repeat(64)}`)
|
|
.set('Authorization', authHeader)
|
|
.expect(404);
|
|
});
|
|
|
|
it('returns content-length if blob exists', async () => {
|
|
const blobData = 'test-blob-data';
|
|
const blobName = createHash('sha256').update(blobData).digest('hex');
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}?create=true`)
|
|
.set('Authorization', authHeader)
|
|
.expect(200);
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}/data/${blobName}`)
|
|
.set('Authorization', authHeader)
|
|
.set('Content-Type', 'application/octet-stream')
|
|
.send(Buffer.from(blobData))
|
|
.expect(200);
|
|
|
|
await request(app.getHttpServer())
|
|
.head(`/${repository}/data/${blobName}`)
|
|
.set('Authorization', authHeader)
|
|
.expect(200)
|
|
.expect('Content-Length', String(blobData.length));
|
|
});
|
|
});
|
|
|
|
describe('GET, POST /:path/:type/:name', () => {
|
|
it('saves and returns blob data', async () => {
|
|
const blobData = 'test-blob-data';
|
|
const blobName = createHash('sha256').update(blobData).digest('hex');
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}?create=true`)
|
|
.set('Authorization', authHeader)
|
|
.expect(200);
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}/data/${blobName}`)
|
|
.set('Authorization', authHeader)
|
|
.set('Content-Type', 'application/octet-stream')
|
|
.send(Buffer.from(blobData))
|
|
.expect(200);
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.get(`/${repository}/data/${blobName}`)
|
|
.set('Authorization', authHeader)
|
|
.expect(200)
|
|
.expect('Content-Type', 'application/octet-stream');
|
|
|
|
expect(response.body.toString()).toBe(blobData);
|
|
});
|
|
|
|
it('returns partial content with range header', async () => {
|
|
const blobData = 'test-blob-data';
|
|
const blobName = createHash('sha256').update(blobData).digest('hex');
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}?create=true`)
|
|
.set('Authorization', authHeader)
|
|
.expect(200);
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}/data/${blobName}`)
|
|
.set('Authorization', authHeader)
|
|
.set('Content-Type', 'application/octet-stream')
|
|
.send(Buffer.from(blobData))
|
|
.expect(200);
|
|
|
|
const response = await request(app.getHttpServer())
|
|
.get(`/${repository}/data/${blobName}`)
|
|
.set('Authorization', authHeader)
|
|
.set('Range', 'bytes=0-3')
|
|
.expect(206)
|
|
.expect('Content-Type', 'application/octet-stream');
|
|
|
|
expect(response.body.toString()).toBe('test');
|
|
});
|
|
|
|
it('fails to overwrite blob with worm', async () => {
|
|
const blobData = 'test-blob-data';
|
|
const blobName = createHash('sha256').update(blobData).digest('hex');
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}?create=true`)
|
|
.set('Authorization', wormAuthHeader)
|
|
.expect(200);
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}/data/${blobName}`)
|
|
.set('Authorization', wormAuthHeader)
|
|
.set('Content-Type', 'application/octet-stream')
|
|
.send(Buffer.from(blobData))
|
|
.expect(200);
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}/data/${blobName}`)
|
|
.set('Authorization', wormAuthHeader)
|
|
.set('Content-Type', 'application/octet-stream')
|
|
.send(Buffer.from(blobData))
|
|
.expect(403);
|
|
});
|
|
|
|
it('fails to write blob with non-hash for name', async () => {
|
|
const blobData = 'test-blob-data';
|
|
const blobName = 'invalid-hash';
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}?create=true`)
|
|
.set('Authorization', authHeader)
|
|
.expect(200);
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}/data/${blobName}`)
|
|
.set('Authorization', authHeader)
|
|
.set('Content-Type', 'application/octet-stream')
|
|
.send(Buffer.from(blobData))
|
|
.expect(400);
|
|
});
|
|
|
|
it('fails to write blob with non-matching hash for name', async () => {
|
|
const blobData = 'test-blob-data';
|
|
const blobName = 'a'.repeat(64);
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}?create=true`)
|
|
.set('Authorization', authHeader)
|
|
.expect(200);
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}/data/${blobName}`)
|
|
.set('Authorization', authHeader)
|
|
.set('Content-Type', 'application/octet-stream')
|
|
.send(Buffer.from(blobData))
|
|
.expect(400);
|
|
});
|
|
});
|
|
|
|
describe('DELETE /:path/:type/:name', () => {
|
|
it('deletes a blob', async () => {
|
|
const blobData = 'test-blob-data';
|
|
const blobName = createHash('sha256').update(blobData).digest('hex');
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}?create=true`)
|
|
.set('Authorization', authHeader)
|
|
.expect(200);
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}/data/${blobName}`)
|
|
.set('Authorization', authHeader)
|
|
.set('Content-Type', 'application/octet-stream')
|
|
.send(Buffer.from(blobData))
|
|
.expect(200);
|
|
|
|
await request(app.getHttpServer())
|
|
.delete(`/${repository}/data/${blobName}`)
|
|
.set('Authorization', authHeader)
|
|
.expect(200);
|
|
|
|
await request(app.getHttpServer())
|
|
.head(`/${repository}/data/${blobName}`)
|
|
.set('Authorization', authHeader)
|
|
.expect(404);
|
|
});
|
|
|
|
it('fails to delete blob with worm', async () => {
|
|
const blobData = 'test-blob-data';
|
|
const blobName = createHash('sha256').update(blobData).digest('hex');
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}?create=true`)
|
|
.set('Authorization', wormAuthHeader)
|
|
.expect(200);
|
|
|
|
await request(app.getHttpServer())
|
|
.post(`/${repository}/data/${blobName}`)
|
|
.set('Authorization', wormAuthHeader)
|
|
.set('Content-Type', 'application/octet-stream')
|
|
.send(Buffer.from(blobData))
|
|
.expect(200);
|
|
|
|
await request(app.getHttpServer())
|
|
.delete(`/${repository}/data/${blobName}`)
|
|
.set('Authorization', wormAuthHeader)
|
|
.expect(403);
|
|
});
|
|
});
|
|
});
|