Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/node/db/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"reflect-metadata": "^0.2.2",
"zod": "3.25.67"
},
"version": "1.1.3",
"version": "1.2.0",
"exports": {
".": {
"import": "./dist/index.js",
Expand Down
13 changes: 13 additions & 0 deletions packages/node/db/src/__tests__/connection-string.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,17 @@ describe('MongoProvider._buildConnectionString', () => {
const caFile = params.get('tlsCAFile');
expect(caFile).toMatch(/saga-mongodb-ca-cert-from-content-test\.pem$/);
});

it('re-writing the CA file is idempotent across constructions (restart-safe)', () => {
const cfg = {
...baseConfig,
instanceName: 'restart-safe-test',
tls: true,
tlsCAContent: '-----BEGIN CERTIFICATE-----\nidempotent\n-----END CERTIFICATE-----\n',
};
// The first write leaves the file mode 0o400; constructing again (as a
// restarted process would) must not fail with EACCES.
new MongoProvider(cfg);
expect(() => new MongoProvider(cfg)).not.toThrow();
});
});
167 changes: 167 additions & 0 deletions packages/node/db/src/__tests__/mirror-alias.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { mockClient } from 'aws-sdk-client-mock';
import {
GetParameterCommand,
SSMClient,
} from '@aws-sdk/client-ssm';
import {
GetSecretValueCommand,
SecretsManagerClient,
} from '@aws-sdk/client-secrets-manager';
import { loadMongoConfigFromAws } from '../aws-mongo-loader.js';

const ssmMock = mockClient(SSMClient);
const smMock = mockClient(SecretsManagerClient);

beforeEach(() => {
ssmMock.reset();
smMock.reset();
});

/**
* Mirror reads from the existing refresh workflow's per-service paths:
* SSM: /mirror/current/mongodb-shared/{endpoint,port,replica-set-name,ca-secret-arn}
* Secret: /mirror/current/{project}-mongo-password
*
* This unifies the daily refresh workflow's rotation and consumer reads
* into a single source of truth instead of two parallel universes.
*
* The legacy ``env='staging'`` path is kept for one deprecation cycle —
* resolves to /shared/infra/staging/* and staging/mongodb-shared/* with a
* console.warn.
*/

function stubMirrorPaths() {
ssmMock
.on(GetParameterCommand, {
Name: '/mirror/current/mongodb-shared/endpoint',
})
.resolves({ Parameter: { Value: 'mirror-mongo.dev.internal' } })
.on(GetParameterCommand, { Name: '/mirror/current/mongodb-shared/port' })
.resolves({ Parameter: { Value: '27017' } })
.on(GetParameterCommand, {
Name: '/mirror/current/mongodb-shared/replica-set-name',
})
.resolves({ Parameter: { Value: 'saga-rs' } })
.on(GetParameterCommand, {
Name: '/mirror/current/mongodb-shared/ca-secret-arn',
})
.resolves({ Parameter: { Value: 'arn:mirror-ca' } });
smMock
.on(GetSecretValueCommand, { SecretId: 'arn:mirror-ca' })
.resolves({ SecretString: JSON.stringify({ ca_cert: 'MIRROR-CA' }) })
.on(GetSecretValueCommand, {
SecretId: '/mirror/current/sds-api-mongo-password',
})
.resolves({
SecretString: JSON.stringify({
username: 'sds_api_app',
password: 'mirror-pw',
}),
});
}

function stubStagingPaths() {
ssmMock
.on(GetParameterCommand, { Name: '/shared/infra/staging/mongodb-hosts' })
.resolves({ Parameter: { Value: 'h1:27017' } })
.on(GetParameterCommand, {
Name: '/shared/infra/staging/mongodb-replica-set-name',
})
.resolves({ Parameter: { Value: 'saga-rs' } })
.on(GetParameterCommand, {
Name: '/shared/infra/staging/mongodb-ca-secret-arn',
})
.resolves({ Parameter: { Value: 'arn:ca' } });
smMock
.on(GetSecretValueCommand, { SecretId: 'arn:ca' })
.resolves({ SecretString: JSON.stringify({ ca_cert: 'CA' }) })
.on(GetSecretValueCommand, {
SecretId: 'staging/mongodb-shared/sds-api-password',
})
.resolves({
SecretString: JSON.stringify({ username: 'sds_app', password: 'pw' }),
});
}

describe("env='mirror' reads from refresh workflow paths", () => {
it('uses /mirror/current/* SSM + /mirror/current/{project}-mongo-password secret', async () => {
stubMirrorPaths();

const config = await loadMongoConfigFromAws({
scope: 'shared',
env: 'mirror',
project: 'sds-api',
instanceName: 'sds-mirror',
});

expect(config.username).toBe('sds_api_app');
expect(config.password).toBe('mirror-pw');
expect(config.hosts).toEqual(['mirror-mongo.dev.internal:27017']);
expect(config.replicaSet).toBe('saga-rs');
expect(config.authSource).toBe('sds_api_db');
expect(config.tls).toBe(true);
expect(config.tlsCAContent).toBe('MIRROR-CA');
});

it("env='staging' still works but emits a deprecation warning", async () => {
stubStagingPaths();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

await loadMongoConfigFromAws({
scope: 'shared',
env: 'staging',
project: 'sds-api',
instanceName: 'sds-staging',
});

expect(warn).toHaveBeenCalledWith(
expect.stringContaining("env='staging' is deprecated"),
);
warn.mockRestore();
});

it("env='prod' uses /shared/infra/prod/* paths unchanged", async () => {
ssmMock
.on(GetParameterCommand, { Name: '/shared/infra/prod/mongodb-hosts' })
.resolves({ Parameter: { Value: 'p1:27017' } })
.on(GetParameterCommand, {
Name: '/shared/infra/prod/mongodb-replica-set-name',
})
.resolves({ Parameter: { Value: 'saga-rs' } })
.on(GetParameterCommand, {
Name: '/shared/infra/prod/mongodb-ca-secret-arn',
})
.resolves({ Parameter: { Value: 'arn:prod-ca' } });
smMock
.on(GetSecretValueCommand, { SecretId: 'arn:prod-ca' })
.resolves({ SecretString: JSON.stringify({ ca_cert: 'PROD-CA' }) })
.on(GetSecretValueCommand, {
SecretId: 'prod/mongodb-shared/sds-api-password',
})
.resolves({
SecretString: JSON.stringify({ username: 'sds_app', password: 'pw' }),
});

const config = await loadMongoConfigFromAws({
scope: 'shared',
env: 'prod',
project: 'sds-api',
instanceName: 'sds-prod',
});

expect(config.hosts).toEqual(['p1:27017']);
});

it('rejects unknown envs', async () => {
await expect(
loadMongoConfigFromAws({
scope: 'shared',
// @ts-expect-error — testing runtime validation of invalid env
env: 'preprod',
project: 'sds-api',
instanceName: 'i',
}),
).rejects.toThrow(/scope='shared'/);
});
});
138 changes: 138 additions & 0 deletions packages/node/db/src/__tests__/mongo-provider.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import 'reflect-metadata';
import { describe, it, expect, vi, beforeEach } from 'vitest';

// ---------------------------------------------------------------------------
// Mock the mongodb driver so we can drive connect()/ping() without a real DB.
// `new MongoClient(...)` returns a single shared `instance`; its connect/db/
// close are individually controllable mocks.
// ---------------------------------------------------------------------------
const { MongoClientMock, connectMock, commandMock, closeMock, instance } =
vi.hoisted(() => {
const commandMock = vi.fn();
const closeMock = vi.fn();
const connectMock = vi.fn();
const instance = {
connect: connectMock,
close: closeMock,
db: vi.fn(() => ({ command: commandMock })),
};
const MongoClientMock = vi.fn(() => instance);
return { MongoClientMock, connectMock, commandMock, closeMock, instance };
});

vi.mock('mongodb', () => ({ MongoClient: MongoClientMock }));

// vi.mock/vi.hoisted are hoisted above these imports.
import { MongoProvider } from '../mongo-provider.js';
import type { MongoProviderConfig } from '../mongo-provider-config.js';

const baseConfig: MongoProviderConfig = {
configType: 'MONGO',
instanceName: 'unit',
hosts: ['localhost:27017'],
database: 'app_db',
};

const authError = (code: number) => Object.assign(new Error('auth failed'), { code });

beforeEach(() => {
vi.clearAllMocks();
MongoClientMock.mockReset();
connectMock.mockReset();
commandMock.mockReset();
closeMock.mockReset();
MongoClientMock.mockReturnValue(instance);
connectMock.mockResolvedValue(instance);
commandMock.mockResolvedValue({ ok: 1 });
closeMock.mockResolvedValue(undefined);
});

describe('MongoProvider lifecycle', () => {
it('tracks isConnected across connect/disconnect via a flag (not a private driver field)', async () => {
const provider = new MongoProvider(baseConfig);
expect(provider.isConnected()).toBe(false);

await provider.connect();
expect(provider.isConnected()).toBe(true);

await provider.disconnect();
expect(provider.isConnected()).toBe(false);
});

it('connect() is idempotent', async () => {
const provider = new MongoProvider(baseConfig);
await provider.connect();
await provider.connect();
expect(MongoClientMock).toHaveBeenCalledTimes(1);
});
});

describe('MongoProvider.ping', () => {
it('returns false before connect', async () => {
expect(await new MongoProvider(baseConfig).ping()).toBe(false);
});

it('returns true when admin.ping succeeds', async () => {
const provider = new MongoProvider(baseConfig);
await provider.connect();
expect(await provider.ping()).toBe(true);
expect(commandMock).toHaveBeenCalledWith({ ping: 1 });
});

it('returns false (does not throw) when the server is unreachable', async () => {
const provider = new MongoProvider(baseConfig);
await provider.connect();
commandMock.mockRejectedValueOnce(new Error('no primary available'));
expect(await provider.ping()).toBe(false);
});
});

describe('MongoProvider.connect retry', () => {
it('retries a transient failure and succeeds on a later attempt', async () => {
vi.useFakeTimers();
connectMock
.mockRejectedValueOnce(new Error('ETIMEDOUT'))
.mockResolvedValueOnce(instance);

const provider = new MongoProvider(baseConfig);
const connecting = provider.connect();
await vi.runAllTimersAsync();
await connecting;

expect(provider.isConnected()).toBe(true);
expect(MongoClientMock).toHaveBeenCalledTimes(2); // a fresh client per attempt
vi.useRealTimers();
});

it('gives up after maxRetries and rethrows', async () => {
vi.useFakeTimers();
connectMock.mockRejectedValue(new Error('cluster down'));

const provider = new MongoProvider(baseConfig);
const captured = provider.connect().catch((e: unknown) => e);
await vi.runAllTimersAsync();
const err = await captured;

expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toBe('cluster down');
expect(provider.isConnected()).toBe(false);
expect(MongoClientMock).toHaveBeenCalledTimes(3);
vi.useRealTimers();
});

it('does NOT retry a non-retryable auth error (AuthenticationFailed=18)', async () => {
connectMock.mockRejectedValue(authError(18));

const provider = new MongoProvider(baseConfig);
await expect(provider.connect()).rejects.toThrow('auth failed');
expect(MongoClientMock).toHaveBeenCalledTimes(1); // failed fast, no retry
});

it('does NOT retry Unauthorized=13 either', async () => {
connectMock.mockRejectedValue(authError(13));

const provider = new MongoProvider(baseConfig);
await expect(provider.connect()).rejects.toThrow('auth failed');
expect(MongoClientMock).toHaveBeenCalledTimes(1);
});
});
Loading
Loading