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
164 changes: 162 additions & 2 deletions server/routes.migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,11 @@ describe("error_logs dedup column migration at startup", () => {
const app = makeMockApp();
await registerRoutes(app as any, app as any);

// db.execute should have been called 5 times:
// db.execute should have been called 11 times:
// 3 for the ALTER TABLE error_logs statements (first_occurrence, occurrence_count, deleted_at)
// 2 for the api_keys table creation (CREATE TABLE + CREATE INDEX)
expect(mockDbExecute).toHaveBeenCalledTimes(5);
// 6 for notification channel tables (3 CREATE TABLE + 2 indexes + 1 unique index)
expect(mockDbExecute).toHaveBeenCalledTimes(11);

// Verify specific DDL statements were issued (drizzle sql`` produces SQL objects)
const callStrings = mockDbExecute.mock.calls.map((c: any[]) => {
Expand All @@ -180,6 +181,9 @@ describe("error_logs dedup column migration at startup", () => {
});
expect(callStrings.some((s: string) => s.includes("api_keys"))).toBe(true);
expect(callStrings.some((s: string) => s.includes("api_keys_user_revoked_idx"))).toBe(true);
expect(callStrings.some((s: string) => s.includes("notification_channels"))).toBe(true);
expect(callStrings.some((s: string) => s.includes("delivery_log"))).toBe(true);
expect(callStrings.some((s: string) => s.includes("slack_connections"))).toBe(true);
});

it("still registers all route groups when migration succeeds", async () => {
Expand Down Expand Up @@ -255,4 +259,160 @@ describe("error_logs dedup column migration at startup", () => {
expect(getRoutes.length).toBeGreaterThan(0);
warnSpy.mockRestore();
});

it("issues DDL for notification_channels with correct columns and constraints", async () => {
vi.clearAllMocks();
mockDbExecute.mockResolvedValue({ rows: [] });
process.env.APP_OWNER_ID = "owner-123";

const { registerRoutes } = await import("./routes");
const app = makeMockApp();
await registerRoutes(app as any, app as any);

const callStrings = mockDbExecute.mock.calls.map((c: any[]) => {
try { return JSON.stringify(c[0]); } catch { return String(c[0]); }
});

// notification_channels CREATE TABLE includes required columns
const ncCreate = callStrings.find((s: string) =>
s.includes("CREATE TABLE IF NOT EXISTS notification_channels")
);
expect(ncCreate).toBeDefined();
expect(ncCreate).toContain("monitor_id");
expect(ncCreate).toContain("channel");
expect(ncCreate).toContain("enabled");
expect(ncCreate).toContain("config");
expect(ncCreate).toContain("JSONB");
expect(ncCreate).toContain("ON DELETE CASCADE");

// notification_channels indexes
expect(callStrings.some((s: string) => s.includes("notification_channels_monitor_idx"))).toBe(true);
expect(callStrings.some((s: string) => s.includes("notification_channels_monitor_channel_uniq"))).toBe(true);
});

it("issues DDL for delivery_log with correct columns and foreign keys", async () => {
vi.clearAllMocks();
mockDbExecute.mockResolvedValue({ rows: [] });
process.env.APP_OWNER_ID = "owner-123";

const { registerRoutes } = await import("./routes");
const app = makeMockApp();
await registerRoutes(app as any, app as any);

const callStrings = mockDbExecute.mock.calls.map((c: any[]) => {
try { return JSON.stringify(c[0]); } catch { return String(c[0]); }
});

const dlCreate = callStrings.find((s: string) =>
s.includes("CREATE TABLE IF NOT EXISTS delivery_log")
);
expect(dlCreate).toBeDefined();
expect(dlCreate).toContain("monitor_id");
expect(dlCreate).toContain("change_id");
expect(dlCreate).toContain("monitor_changes");
expect(dlCreate).toContain("status");
expect(dlCreate).toContain("attempt");
expect(dlCreate).toContain("ON DELETE CASCADE");

// delivery_log index
expect(callStrings.some((s: string) => s.includes("delivery_log_monitor_created_idx"))).toBe(true);
});

it("issues DDL for slack_connections with correct columns", async () => {
vi.clearAllMocks();
mockDbExecute.mockResolvedValue({ rows: [] });
process.env.APP_OWNER_ID = "owner-123";

const { registerRoutes } = await import("./routes");
const app = makeMockApp();
await registerRoutes(app as any, app as any);

const callStrings = mockDbExecute.mock.calls.map((c: any[]) => {
try { return JSON.stringify(c[0]); } catch { return String(c[0]); }
});

const scCreate = callStrings.find((s: string) =>
s.includes("CREATE TABLE IF NOT EXISTS slack_connections")
);
expect(scCreate).toBeDefined();
expect(scCreate).toContain("user_id");
expect(scCreate).toContain("team_id");
expect(scCreate).toContain("team_name");
expect(scCreate).toContain("bot_token");
// CHECK constraint enforces encrypted ciphertext format with minimum lengths
expect(scCreate).toContain("CHECK");
expect(scCreate).toContain("bot_token ~");
expect(scCreate).toMatch(/\[A-Za-z0-9\+\/=\]\{16,\}/);
expect(scCreate).toContain("scope");
expect(scCreate).toContain("UNIQUE");
});

it("logs error and continues when notification channel table creation fails", async () => {
vi.clearAllMocks();
const channelError = new Error("permission denied for schema public");
// error_logs ALTERs succeed (3), api_keys succeed (2), then channel tables fail
mockDbExecute
.mockResolvedValueOnce({ rows: [] }) // ALTER error_logs 1
.mockResolvedValueOnce({ rows: [] }) // ALTER error_logs 2
.mockResolvedValueOnce({ rows: [] }) // ALTER error_logs 3
.mockResolvedValueOnce({ rows: [] }) // CREATE api_keys
.mockResolvedValueOnce({ rows: [] }) // CREATE INDEX api_keys
.mockRejectedValueOnce(channelError); // notification_channels fails

const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
process.env.APP_OWNER_ID = "owner-123";

const { registerRoutes } = await import("./routes");
const app = makeMockApp();

// Should not throw
await registerRoutes(app as any, app as any);

// Should log the specific notification channel error
expect(errorSpy).toHaveBeenCalledWith(
"Could not ensure notification channel tables:",
channelError,
);

// Routes should still be registered
const getRoutes = Object.keys(registeredRoutes["get"] ?? {});
expect(getRoutes.length).toBeGreaterThan(0);
expect(getRoutes).toContain("/api/admin/error-logs");

errorSpy.mockRestore();
warnSpy.mockRestore();
});

it("registers channel routes even when notification channel tables fail to create", async () => {
vi.clearAllMocks();
const channelError = new Error("connection timeout");
// error_logs and api_keys succeed, channel tables fail
mockDbExecute
.mockResolvedValueOnce({ rows: [] }) // ALTER error_logs 1
.mockResolvedValueOnce({ rows: [] }) // ALTER error_logs 2
.mockResolvedValueOnce({ rows: [] }) // ALTER error_logs 3
.mockResolvedValueOnce({ rows: [] }) // CREATE api_keys
.mockResolvedValueOnce({ rows: [] }) // CREATE INDEX api_keys
.mockRejectedValueOnce(channelError); // notification_channels fails

const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
process.env.APP_OWNER_ID = "owner-123";

const { registerRoutes } = await import("./routes");
const app = makeMockApp();
await registerRoutes(app as any, app as any);

// Channel routes should still be registered (they use runtime channelTablesExist checks)
const getRoutes = Object.keys(registeredRoutes["get"] ?? {});
const putRoutes = Object.keys(registeredRoutes["put"] ?? {});
const deleteRoutes = Object.keys(registeredRoutes["delete"] ?? {});
expect(getRoutes).toContain("/api/monitors/:id/channels");
expect(putRoutes).toContain("/api/monitors/:id/channels/:channel");
expect(deleteRoutes).toContain("/api/monitors/:id/channels/:channel");

errorSpy.mockRestore();
warnSpy.mockRestore();
});
});
49 changes: 49 additions & 0 deletions server/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,55 @@ export async function registerRoutes(
console.error("Could not ensure api_keys table — API key routes will be disabled:", e);
}

// Ensure notification channel tables exist (notification_channels, delivery_log, slack_connections).
// Without this, channel management routes return 503 "not available yet"
// if schema:push has not been run after these tables were added to the schema.
try {
await db.execute(sql`
CREATE TABLE IF NOT EXISTS notification_channels (
id SERIAL PRIMARY KEY,
monitor_id INTEGER NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
channel TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
config JSONB NOT NULL, -- may contain webhook secrets; never log raw values
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
)
`);
await db.execute(sql`CREATE INDEX IF NOT EXISTS notification_channels_monitor_idx ON notification_channels(monitor_id)`);
await db.execute(sql`CREATE UNIQUE INDEX IF NOT EXISTS notification_channels_monitor_channel_uniq ON notification_channels(monitor_id, channel)`);

await db.execute(sql`
CREATE TABLE IF NOT EXISTS delivery_log (
id SERIAL PRIMARY KEY,
monitor_id INTEGER NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
change_id INTEGER NOT NULL REFERENCES monitor_changes(id),
channel TEXT NOT NULL,
status TEXT NOT NULL,
attempt INTEGER NOT NULL DEFAULT 1,
response JSONB,
delivered_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
)
`);
await db.execute(sql`CREATE INDEX IF NOT EXISTS delivery_log_monitor_created_idx ON delivery_log(monitor_id, created_at)`);

await db.execute(sql`
CREATE TABLE IF NOT EXISTS slack_connections (
id SERIAL PRIMARY KEY,
user_id TEXT NOT NULL UNIQUE REFERENCES users(id),
team_id TEXT NOT NULL,
team_name TEXT NOT NULL,
bot_token TEXT NOT NULL CHECK (bot_token ~ '^[A-Za-z0-9+/=]{16,}:[A-Za-z0-9+/=]{20,}:[A-Za-z0-9+/=]{22,}$'),
scope TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
)
`);
} catch (e) {
console.error("Could not ensure notification channel tables:", e);
}

// Setup Auth (must be before rate limiter so req.user is populated)
await setupAuth(app);

Expand Down
Loading