feat(yucca-admin-api): send invite emails from the allowlist flow (#492)

This commit is contained in:
Antoine Lecompte
2026-08-24 06:44:59 -04:00
committed by GitHub
parent 1d92b4b0c1
commit 30166e0733
21 changed files with 495 additions and 8 deletions
+6
View File
@@ -20,3 +20,9 @@ OP_ACCOUNT="team-futo.1password.com"
# POLAR_WEBHOOK_SECRET=
# POLAR_PRODUCT_ID=d9bc9054-eb91-46c9-846e-dd5bbb8f0041
# POLAR_APPLY_DISCOUNT_ID=391d9f04-889a-44ff-95f1-98cee8a5b0db
# Postmark: defaults target the local mock (compose/Tilt). Set both to send
# real email from a dev machine.
# POSTMARK_API_URL="https://api.postmarkapp.com"
# POSTMARK_SERVER_TOKEN="op://yucca_tf_staging/POSTMARK_SERVER_TOKEN/password"
# EMAIL_FROM_ADDRESS="FUTO Backups <noreply@backups.futo.cloud>"
+2 -1
View File
@@ -78,7 +78,7 @@ jobs:
run: sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc || true
# Cluster bring-up and the workspace install touch none of the same files,
# so they overlap. Only @common/server is built: the suites run from
# so they overlap. Only the @common/* libs are built: the suites run from
# TypeScript source via ts-jest, so `mise prepare`'s other packages are
# never loaded here.
- name: Stand up k3d infra, install deps and build shared libs
@@ -87,6 +87,7 @@ jobs:
infra=$!
mise run install:frozen
mise run common:server:build
mise run common:emails:build
wait "$infra" || { echo "::error::k3d infra failed"; cat /tmp/k3d-infra.log; exit 1; }
echo "::group::k3d infra log"; cat /tmp/k3d-infra.log; echo "::endgroup::"
+6
View File
@@ -24,6 +24,12 @@ export TOPOLOGY_FILE=${TOPOLOGY_FILE:-./topology.dev.json}
export LEGACY_SITE_CODE=${LEGACY_SITE_CODE:-local}
export LEGACY_STORAGE_CLUSTER_CODE=${LEGACY_STORAGE_CLUSTER_CODE:-local-dev}
# Invite emails: the compose mock Postmark (delivering into Mailpit on :8025).
# Point POSTMARK_API_URL + a real token at Postmark to test actual delivery.
export POSTMARK_API_URL=${POSTMARK_API_URL:-http://localhost:8093}
export POSTMARK_SERVER_TOKEN=${POSTMARK_SERVER_TOKEN:-dev token}
export WEB_BASE_URL=${WEB_BASE_URL:-http://localhost:5173}
export OIDC_ADMIN_ISSUER=${OIDC_ISSUER:-http://localhost:8092}
export OIDC_ADMIN_CLIENT_ID=${OIDC_CLIENT_ID:-client ID}
export OIDC_ADMIN_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-client secret}
+4 -2
View File
@@ -79,7 +79,7 @@ followed by `tilt:ci-e2e` (the apps the e2e suites touch). Ceph converging outwe
in the integration job, so that job dropped it and its S3-backed suites moved to the e2e one.
Ports: `5173` web · `3020` yucca-api · `3030` yucca-admin-api · `3010` michael ·
`8092` mock-oidc · `9000` ceph rgw · `8428` victoria-metrics · `9428` victoria-logs.
`8092` mock-oidc · `8025` mailpit · `8093` mock-postmark · `9000` ceph rgw · `8428` victoria-metrics · `9428` victoria-logs.
### Infrastructure commands
@@ -110,7 +110,9 @@ Zod-validated `env.ts`, JWT auth guards via `@AuthRoute()`, OTel from `@common/s
| `yucca-metrics-worker` | NestJS | 5-min cron: RadosGW usage → meter tables → per-connection rollup (`connectionMetrics`, billing floor), OTel gauges. |
| `redis` (valkey) | | Shared platform cache (ephemeral; keys `yucca:<service>:<purpose>:*`). Primary-region only. |
| `mock-oidc-provider` | Node | Dev/test OIDC IdP (code + device flow). |
| `common` (`@common/server`) | TS lib | OTel init, pino repository, **feature-flag registry + connection-type registry**. |
| `mock-postmark-provider` | Node | Dev/test Postmark API mock; delivers into the Mailpit inbox. |
| `common` (`@common/server`) | TS lib | OTel init, pino repository, **feature-flag registry + connection-type registry**, Postmark `EmailRepository` (`@common/server/email`). |
| `emails` (`@common/emails`) | Svelte lib | Transactional email templates (better-svelte-email, web theme), prebuilt to JS for the NestJS apps. See `docs/email.md`. |
**Frontend** (`packages/web`): SvelteKit 5 + Tailwind 4, `@immich/ui`, lingui i18n
(`mise web:lingui:*`; compiled locales are generated, not edited), generated API client.
+3 -1
View File
@@ -216,9 +216,11 @@ docker_build(
live_update=[
sync('./packages/yucca-admin-api', '/app/packages/yucca-admin-api'),
sync('./packages/common', '/app/packages/common'),
sync('./packages/emails', '/app/packages/emails'),
# yucca-admin-api/src/schema is a symlink into yucca-api; keep it synced.
sync('./packages/yucca-api', '/app/packages/yucca-api'),
run('cd /app && pnpm --filter @common/server build', trigger=['./packages/common/src']),
run('cd /app && pnpm --filter @common/emails build', trigger=['./packages/emails/src']),
],
)
@@ -333,7 +335,7 @@ APP_WIRING = {
# calls it at boot. Tilt builds an image only when ready to deploy, so that
# edge parked this build (and web's) behind Rook-Ceph instead of alongside.
'yucca-api': {'build': 'yucca-api', 'deps': ['yucca-database', 'yucca-mock-oidc', 'yucca-topology'], 'dev_env': True, 'dev_keypair': True},
'yucca-admin-api': {'build': 'yucca-admin-api', 'deps': ['yucca-database', 'yucca-mock-oidc', 'yucca-topology'], 'dev_keypair': True},
'yucca-admin-api': {'build': 'yucca-admin-api', 'deps': ['yucca-database', 'yucca-mock-oidc', 'yucca-topology'], 'dev_env': True, 'dev_keypair': True},
'yucca-metrics-worker': {'build': 'yucca-metrics-worker', 'deps': ['yucca-database', 'yucca-metrics-object-user', 'yucca-topology'], 'dev_env': True},
# Likewise: the dev server reaches yucca-api per request, not at boot.
'yucca-web': {'build': 'web', 'deps': []},
+10
View File
@@ -43,6 +43,9 @@ oidcLogoutRedirectUri: http://localhost:3030
secretData:
OIDC_ADMIN_CLIENT_ID: "client ID"
OIDC_ADMIN_CLIENT_SECRET: "client secret"
# Any value satisfies the mock Postmark; prod gets the real token from the
# TF-provisioned Secret.
POSTMARK_SERVER_TOKEN: "dev token"
# OPT-IN dev signing key for CLI session JWTs. The project's well-known
# local-dev ES256 keypair (the same one committed in .mise/tasks/*/env and the
@@ -79,6 +82,13 @@ env:
value: http://victoria-metrics:8428/opentelemetry/v1/metrics
- name: OTEL_LOGGING
value: http://victoria-logs:9428/insert/opentelemetry/v1/logs
# Invite emails land in the dev mock (→ Mailpit); the prod env list in the
# base HelmRelease omits this so the real Postmark default applies.
- name: POSTMARK_API_URL
value: http://yucca-mock-postmark:8093
# Invite links resolve via the Tilt web port-forward.
- name: WEB_BASE_URL
value: http://localhost:5173
# Probes keep `tilt ci`/Flux honest: without them a crash-looping dev process
# still counts as Ready (this masked two real bugs). The startupProbe budgets
+69
View File
@@ -0,0 +1,69 @@
# Email
Yucca sends transactional email through [Postmark](https://postmarkapp.com/). The first (and so
far only) sender is the invite flow in yucca-admin-api; anything that later needs to email a user
builds on the same three pieces.
## Architecture
| Piece | Where | Role |
|---|---|---|
| `@common/emails` | `packages/emails` | Svelte email templates (better-svelte-email, Tailwind 4) styled after the web UI's `@immich/ui` theme, prebuilt to plain JS. Exposes `render*Email(props) → { subject, htmlBody, textBody }`. |
| `EmailRepository` | `@common/server/email` | Postmark HTTP client over global `fetch` (`POST /email`, `POST /email/batch`, ≤500 per batch). Stamps `From`, the `outbound` message stream, and an optional `Tag`. |
| Callers | e.g. `AllowlistService` | Render a template, hand the result to `EmailRepository`, record the outcome. |
Services wire `EmailRepository` into their `providers` array like any other repository; templates
are imported as functions. The renderer never runs in the request path of the web app — emails are
rendered inside the NestJS process from the prebuilt `@common/emails` dist.
**Degraded mode:** without `POSTMARK_SERVER_TOKEN` the repository logs and skips every send.
Environments that don't care about email keep working; nothing crashes at boot.
## Invite flow
`POST /allowlist/invite` and `POST /allowlist/invite-batch` (yucca-admin-api, driven by yuctl)
email every affected entry whose `userAllowlist.inviteEmailSentAt` is still null, then stamp it on
success. Re-running an invite is therefore a safe retry that only reaches the not-yet-emailed
entries; a rejected address stays null and is retried next time. The email carries the invite code
and links to `${WEB_BASE_URL}/login/invite`.
## Configuration
| Env var | Meaning | Default |
|---|---|---|
| `POSTMARK_SERVER_TOKEN` | Postmark server API token (secret) | unset → log-and-skip |
| `POSTMARK_API_URL` | Postmark endpoint; dev points it at the mock | `https://api.postmarkapp.com` |
| `EMAIL_FROM_ADDRESS` | `From` header | `FUTO Backups <noreply@backups.futo.cloud>` |
| `WEB_BASE_URL` | Base for links in emails (yucca-admin-api) | `http://localhost:5173` |
The boundary rule from [feature-flags.md](feature-flags.md) applies: these are deployment config
(env/Secret/cluster-settings), not feature flags. In staging/prod the token rides in the
TF-provisioned `yucca-admin-api` Secret; the from-address and `WEB_BASE_URL` come from the base
HelmRelease + cluster-settings.
## Local dev
Nothing leaves the machine. The app speaks the real Postmark wire protocol to an in-repo mock,
which delivers into a [Mailpit](https://mailpit.axllent.org/) inbox:
- **compose (`mise dev`)**: `mock-postmark-provider` on `localhost:8093`, Mailpit UI on
`http://localhost:8025`. The yucca-admin-api dev env defaults point at the mock.
- **k3d/Tilt**: `yucca-mock-postmark` + `yucca-mailpit` (dev-only HelmReleases under
`kubernetes/apps/dev/local`), same ports via the Tilt port-forwards.
- **e2e/tests**: assert through Mailpit's REST API (`GET /api/v1/messages`, search, message body);
unit/integration tests mock or override `EmailRepository` instead.
To send *real* email from a dev machine, set `POSTMARK_API_URL` + `POSTMARK_SERVER_TOKEN` in
`.env` (see `.env.example`).
## Adding a template
1. Add `packages/emails/src/emails/<name>.svelte` (compose with `src/lib/layout.svelte` and
`@better-svelte-email/components`; Tailwind classes only — the renderer inlines them).
2. Export a typed `render<Name>Email()` from `packages/emails/src/index.ts`.
3. Preview while iterating: `pnpm --filter @common/emails preview`.
4. Call it from a service and pass the result to `EmailRepository.send`/`sendBatch` with a `tag`.
Email HTML is its own dialect: no CSS variables, no `oklch()`, table-friendly markup — which is
why `src/theme.ts` mirrors the `@immich/ui` palette as literal hex and templates never import web
components directly.
+1 -1
View File
@@ -42,7 +42,7 @@ RUN pnpm install --frozen-lockfile
# 2. Copy sources, build workspace libs yucca-admin-api imports at runtime
COPY . ./
RUN pnpm --filter @common/server build
RUN pnpm --filter @common/server build && pnpm --filter @common/emails build
EXPOSE 3030
CMD ["pnpm", "--filter", "yucca-admin-api", "start:dev"]
+157
View File
@@ -838,6 +838,88 @@
]
}
},
"/api/settings": {
"get": {
"operationId": "listSettings",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SettingsListResponseDto"
}
}
}
}
},
"tags": [
"Settings"
]
}
},
"/api/settings/{scope}": {
"put": {
"operationId": "setSettings",
"parameters": [
{
"name": "scope",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SettingsValueDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SettingsEntryResponseDto"
}
}
}
}
},
"tags": [
"Settings"
]
},
"delete": {
"operationId": "deleteSettings",
"parameters": [
{
"name": "scope",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
}
},
"tags": [
"Settings"
]
}
},
"/api/features": {
"get": {
"operationId": "listFeatures",
@@ -1268,6 +1350,14 @@
"worm": {
"type": "boolean"
},
"siteCode": {
"type": "string",
"description": "Stable internal site code"
},
"storageClusterCode": {
"type": "string",
"description": "Stable, globally unique internal storage cluster code"
},
"connectionId": {
"type": "string"
},
@@ -1285,6 +1375,8 @@
"id",
"name",
"worm",
"siteCode",
"storageClusterCode",
"connectionId",
"connectionType",
"user",
@@ -1322,6 +1414,10 @@
"worm": {
"type": "boolean"
},
"site": {
"type": "string",
"description": "Internal site code to place the repository in; defaults to the topology default site"
},
"connectionType": {
"type": "string",
"enum": [
@@ -1412,6 +1508,10 @@
"type": "string",
"nullable": true
},
"inviteEmailSentAt": {
"type": "string",
"nullable": true
},
"createdAt": {
"type": "string"
}
@@ -1508,6 +1608,63 @@
"count"
]
},
"SettingsValueDto": {
"type": "object",
"properties": {
"restic_pack_size_mib": {
"type": "number"
},
"connections_math": {
"type": "string",
"description": "Client-evaluated expression: integers, cores, min, max, + - * /"
}
}
},
"SettingsEntryDto": {
"type": "object",
"properties": {
"scope": {
"type": "string",
"description": "'global', 'site:<code>' or 'cluster:<code>'"
},
"value": {
"$ref": "#/components/schemas/SettingsValueDto"
},
"updatedAt": {
"type": "string"
}
},
"required": [
"scope",
"value",
"updatedAt"
]
},
"SettingsListResponseDto": {
"type": "object",
"properties": {
"settings": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SettingsEntryDto"
}
}
},
"required": [
"settings"
]
},
"SettingsEntryResponseDto": {
"type": "object",
"properties": {
"entry": {
"$ref": "#/components/schemas/SettingsEntryDto"
}
},
"required": [
"entry"
]
},
"FeatureFlagDefDto": {
"type": "object",
"properties": {
+1
View File
@@ -20,6 +20,7 @@
"dist"
],
"dependencies": {
"@common/emails": "workspace:^",
"@common/server": "workspace:^",
"@immich/sql-tools": "catalog:",
"@nestjs/common": "catalog:",
@@ -1,3 +1,4 @@
import { EmailRepository } from '@common/server/email';
import { LoggerRepository, LoggingInterceptor, OtelModule, WideContextRepository } from '@common/server/otel';
import { Module } from '@nestjs/common';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
@@ -61,6 +62,7 @@ export const controllers = [
export const providers = [
WideContextRepository,
LoggerRepository,
EmailRepository,
DatabaseRepository,
DatabaseService,
OidcRepository,
@@ -21,6 +21,9 @@ export class AllowlistEntryDto {
@ApiProperty({ type: 'string', required: false, nullable: true })
inviteUsedAt!: Date | null;
@ApiProperty({ type: 'string', required: false, nullable: true })
inviteEmailSentAt!: Date | null;
@ApiProperty({ type: 'string' })
createdAt!: Date;
}
+2
View File
@@ -34,6 +34,8 @@ const schema = z.object({
LEGACY_SITE_CODE: z.string(),
LEGACY_STORAGE_CLUSTER_CODE: z.string(),
WEB_BASE_URL: z.url().default('http://localhost:5173'),
OIDC_ADMIN_ISSUER: z.url().transform((url) => new URL(url)),
OIDC_ADMIN_CLIENT_ID: z.string(),
OIDC_ADMIN_CLIENT_SECRET: z.string(),
@@ -0,0 +1,97 @@
import { AllowlistService } from 'src/services/allowlist.service';
import { Mocks, newMocks } from '../../test/mocks';
const row = (overrides: object = {}) => ({
id: 'entry-1',
email: 'user@example.com',
inviteCode: 'ABC123DEF4',
invited: true,
inviteUsed: false,
inviteUsedAt: null,
inviteEmailSentAt: null,
createdAt: new Date('2026-08-01'),
...overrides,
});
describe(AllowlistService.name, () => {
let mocks: Mocks;
let sut: AllowlistService;
beforeEach(() => {
mocks = newMocks();
sut = new AllowlistService(mocks.allowlist as never, mocks.email as never, mocks.logger);
});
describe('invite', () => {
it('emails newly invited entries and records the send', async () => {
const created = row();
const sent = row({ inviteEmailSentAt: new Date('2026-08-19') });
mocks.allowlist.create.mockResolvedValue(created as never);
mocks.allowlist.update.mockResolvedValue(sent as never);
mocks.email.sendBatch.mockResolvedValue([{ to: created.email, errorCode: 0, message: 'OK' }]);
const { items } = await sut.invite({ emails: ['User@Example.com'] });
expect(mocks.email.sendBatch).toHaveBeenCalledWith([
expect.objectContaining({ to: 'user@example.com', tag: 'invite', subject: "You're invited to FUTO Backups" }),
]);
const [messages] = mocks.email.sendBatch.mock.calls[0];
expect(messages[0].htmlBody).toContain('ABC123DEF4');
expect(messages[0].htmlBody).toContain('/login/invite');
expect(messages[0].textBody).toContain('ABC123DEF4');
expect(mocks.allowlist.update).toHaveBeenCalledWith('entry-1', { inviteEmailSentAt: expect.any(Date) });
expect(items).toEqual([sent]);
});
it('does not resend to entries that already got their invite email', async () => {
const delivered = row({ inviteEmailSentAt: new Date('2026-08-02') });
mocks.allowlist.getByEmail.mockResolvedValue(delivered as never);
const { items } = await sut.invite({ emails: ['user@example.com'] });
expect(mocks.email.sendBatch).not.toHaveBeenCalled();
expect(mocks.allowlist.update).not.toHaveBeenCalled();
expect(items).toEqual([delivered]);
});
it('keeps rejected sends retryable', async () => {
const created = row();
mocks.allowlist.create.mockResolvedValue(created as never);
mocks.email.sendBatch.mockResolvedValue([{ to: created.email, errorCode: 406, message: 'Inactive recipient' }]);
const { items } = await sut.invite({ emails: ['user@example.com'] });
expect(mocks.allowlist.update).not.toHaveBeenCalled();
expect(items).toEqual([created]);
expect(mocks.logger.warn).toHaveBeenCalled();
});
it('returns entries unchanged when the batch send fails outright', async () => {
const created = row();
mocks.allowlist.create.mockResolvedValue(created as never);
mocks.email.sendBatch.mockRejectedValue(new Error('postmark down'));
const { items } = await sut.invite({ emails: ['user@example.com'] });
expect(items).toEqual([created]);
expect(mocks.logger.error).toHaveBeenCalled();
});
});
describe('inviteBatch', () => {
it('invites the oldest staged entries and emails them', async () => {
const staged = row({ invited: false });
const invited = row();
const sent = row({ inviteEmailSentAt: new Date('2026-08-19') });
mocks.allowlist.oldestStaged.mockResolvedValue([staged] as never);
mocks.allowlist.update.mockResolvedValueOnce(invited as never).mockResolvedValueOnce(sent as never);
mocks.email.sendBatch.mockResolvedValue([{ to: staged.email, errorCode: 0, message: 'OK' }]);
const { items } = await sut.inviteBatch({ count: 1 });
expect(mocks.allowlist.update).toHaveBeenNthCalledWith(1, 'entry-1', { invited: true });
expect(mocks.allowlist.update).toHaveBeenNthCalledWith(2, 'entry-1', { inviteEmailSentAt: expect.any(Date) });
expect(items).toEqual([sent]);
});
});
});
@@ -1,3 +1,6 @@
import { renderInviteEmail } from '@common/emails';
import { EmailMessage, EmailRepository } from '@common/server/email';
import { LoggerRepository } from '@common/server/otel';
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import {
AllowlistAddRequestDto,
@@ -9,13 +12,18 @@ import {
AllowlistListQueryDto,
AllowlistListResponseDto,
} from 'src/dto/allowlist.dto';
import { env } from 'src/env';
import { UserAllowlistRepository } from 'src/repositories/userAllowlist.repository';
import { generateInviteCode } from 'src/utils/invite-code';
import { resolveLimit } from 'src/utils/pagination';
@Injectable()
export class AllowlistService {
constructor(private readonly allowlist: UserAllowlistRepository) {}
constructor(
private readonly allowlist: UserAllowlistRepository,
private readonly email: EmailRepository,
private readonly logger: LoggerRepository,
) {}
list(query: AllowlistListQueryDto): Promise<AllowlistListResponseDto> {
return this.allowlist.list({ cursor: query.cursor, limit: resolveLimit(query.limit) });
@@ -54,7 +62,7 @@ export class AllowlistService {
}
}
return { items };
return { items: await this.sendInviteEmails(items) };
}
async inviteBatch(dto: AllowlistInviteBatchRequestDto): Promise<AllowlistEntriesResponseDto> {
@@ -65,7 +73,46 @@ export class AllowlistService {
items.push(await this.allowlist.update(entry.id, { invited: true }));
}
return { items };
return { items: await this.sendInviteEmails(items) };
}
private async sendInviteEmails(items: AllowlistEntryDto[]): Promise<AllowlistEntryDto[]> {
const pending = items.filter((entry) => !entry.inviteEmailSentAt);
if (pending.length === 0) {
return items;
}
const messages: EmailMessage[] = [];
for (const entry of pending) {
const inviteUrl = new URL('/login/invite', env.WEB_BASE_URL).href;
messages.push({
to: entry.email,
tag: 'invite',
...(await renderInviteEmail({ inviteCode: entry.inviteCode, inviteUrl })),
});
}
const results = await this.email.sendBatch(messages).catch((error: unknown) => {
this.logger.error(error, 'Failed to send invite emails');
return null;
});
if (!results) {
return items;
}
const updated = new Map<string, AllowlistEntryDto>();
for (const [index, result] of results.entries()) {
const entry = pending[index];
if (result.errorCode === 0) {
updated.set(entry.id, await this.allowlist.update(entry.id, { inviteEmailSentAt: new Date() }));
} else {
this.logger.warn(
{ email: entry.email, errorCode: result.errorCode },
`Invite email rejected: ${result.message}`,
);
}
}
return items.map((entry) => updated.get(entry.id) ?? entry);
}
private async createEntry(email: string, invited: boolean) {
@@ -1,3 +1,4 @@
import { EmailMessage, EmailRepository } from '@common/server/email';
import { MetricService } from '@common/server/otel';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
@@ -12,6 +13,9 @@ const authCookie = ['yucca-admin-sub=admin', 'yucca-admin-access-token=token'];
describe('AllowlistController (e2e)', () => {
let app: INestApplication<App>;
const sendBatch = jest.fn((messages: EmailMessage[]) =>
Promise.resolve(messages.map((message) => ({ to: message.to, errorCode: 0, message: 'OK' }))),
);
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
@@ -23,6 +27,8 @@ describe('AllowlistController (e2e)', () => {
.useValue(newMetricServiceMock())
.overrideProvider(OidcRepository)
.useValue({ onModuleInit: jest.fn(), fetchUserInfo: jest.fn().mockResolvedValue({ sub: 'admin' }) })
.overrideProvider(EmailRepository)
.useValue({ send: jest.fn(), sendBatch })
.compile();
app = moduleFixture.createNestApplication();
@@ -37,6 +43,7 @@ describe('AllowlistController (e2e)', () => {
beforeEach(async () => {
await testUtils.resetDatabase();
sendBatch.mockClear();
});
describe('GET /allowlist', () => {
@@ -165,6 +172,41 @@ describe('AllowlistController (e2e)', () => {
]);
});
it('emails invited entries and records the send', async () => {
const { body } = await request(app.getHttpServer())
.post('/api/allowlist/invite')
.set('Cookie', authCookie)
.send({ emails: ['fresh@example.com'] })
.expect(201);
expect(sendBatch).toHaveBeenCalledTimes(1);
const [messages] = sendBatch.mock.calls[0];
expect(messages[0]).toEqual(
expect.objectContaining({ to: 'fresh@example.com', tag: 'invite', subject: "You're invited to FUTO Backups" }),
);
expect(messages[0].htmlBody).toContain(body.items[0].inviteCode);
expect(body.items[0].inviteEmailSentAt).toEqual(expect.any(String));
await expect(testUtils.getAllowlistEntry('fresh@example.com')).resolves.toEqual(
expect.objectContaining({ inviteEmailSentAt: expect.any(Date) }),
);
});
it('does not resend to entries that already got their invite email', async () => {
await testUtils.createAllowlistEntry({
email: 'already@example.com',
invited: true,
inviteEmailSentAt: new Date('2026-08-01'),
});
await request(app.getHttpServer())
.post('/api/allowlist/invite')
.set('Cookie', authCookie)
.send({ emails: ['already@example.com'] })
.expect(201);
expect(sendBatch).not.toHaveBeenCalled();
});
it('leaves already-invited entries untouched', async () => {
const invited = await testUtils.createAllowlistEntry({ email: 'already@example.com', invited: true });
+22
View File
@@ -1,6 +1,8 @@
import type { EmailRepository } from '@common/server/email';
import type { LoggerRepository, WideContextRepository } from '@common/server/otel';
import type { DatabaseRepository } from 'src/repositories/database.repository';
import type { OidcRepository } from 'src/repositories/oidc.repository';
import type { UserAllowlistRepository } from 'src/repositories/userAllowlist.repository';
export type RepositoryInterface<T extends object> = Pick<T, keyof T>;
@@ -20,6 +22,24 @@ export const newOidcRepositoryMock = (): jest.Mocked<RepositoryInterface<OidcRep
};
};
export const newEmailRepositoryMock = (): jest.Mocked<RepositoryInterface<EmailRepository>> => {
return {
send: jest.fn(),
sendBatch: jest.fn(),
};
};
export const newUserAllowlistRepositoryMock = (): jest.Mocked<RepositoryInterface<UserAllowlistRepository>> => {
return {
list: jest.fn(),
getByEmail: jest.fn(),
create: jest.fn(),
update: jest.fn(),
deleteByEmail: jest.fn(),
oldestStaged: jest.fn(),
};
};
export const newLoggerRepositoryMock = (): jest.Mocked<RepositoryInterface<LoggerRepository>> => {
return {
debug: jest.fn(),
@@ -45,6 +65,8 @@ export const newMocks = () => {
return {
database: newDatabaseRepositoryMock(),
oidc: newOidcRepositoryMock(),
email: newEmailRepositoryMock(),
allowlist: newUserAllowlistRepositoryMock(),
logger: newLoggerRepositoryMock(),
wideContext: newWideContextRepositoryMock(),
metrics: newMetricServiceMock(),
@@ -29,11 +29,13 @@ export const testUtils = {
email,
inviteCode,
invited = false,
inviteEmailSentAt,
createdAt,
}: {
email: string;
inviteCode?: string;
invited?: boolean;
inviteEmailSentAt?: Date;
createdAt?: Date;
}) => {
return getDb()
@@ -42,6 +44,7 @@ export const testUtils = {
email,
inviteCode: inviteCode ?? randomUUID().slice(0, 10).toUpperCase(),
invited,
...(inviteEmailSentAt ? { inviteEmailSentAt } : {}),
...(createdAt ? { createdAt } : {}),
})
.returningAll()
@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "userAllowlist" ADD "inviteEmailSentAt" timestamp with time zone;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "userAllowlist" DROP COLUMN "inviteEmailSentAt";`.execute(db);
}
@@ -20,6 +20,9 @@ export class UserAllowlistTable {
@Column({ type: 'timestamp with time zone', nullable: true })
inviteUsedAt!: Date | null;
@Column({ type: 'timestamp with time zone', nullable: true })
inviteEmailSentAt!: Date | null;
@Column({ type: 'timestamp with time zone', default: () => 'now()' })
createdAt!: Generated<Date>;
}
+3
View File
@@ -837,6 +837,9 @@ importers:
packages/yucca-admin-api:
dependencies:
'@common/emails':
specifier: workspace:^
version: link:../emails
'@common/server':
specifier: workspace:^
version: link:../common