Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
048c160
feat(db): add integration_installations schema and migration
richiemcilroy Jul 30, 2026
753a710
fix(web-backend): delete integration installations with organization
richiemcilroy Jul 30, 2026
be1a2ef
chore(env): add optional Slack OAuth and signing secrets
richiemcilroy Jul 30, 2026
4bfab41
feat(web): add integration installations repository
richiemcilroy Jul 30, 2026
ff3c411
feat(web): add public share video eligibility lookup
richiemcilroy Jul 30, 2026
320a4e2
feat(web): add Cap share URL oEmbed builder
richiemcilroy Jul 30, 2026
3b371d6
feat(web): add oEmbed API route
richiemcilroy Jul 30, 2026
af49416
feat(web): add shared share video metadata builder
richiemcilroy Jul 30, 2026
6c254a8
refactor(web): use shared metadata on share page
richiemcilroy Jul 30, 2026
7f9cbb9
feat(web): add Player.js receiver for embed control
richiemcilroy Jul 30, 2026
99fbd27
feat(web): add Player.js and minimal Slack embed mode
richiemcilroy Jul 30, 2026
bdf7fc2
feat(web): add Slack request signature verification
richiemcilroy Jul 30, 2026
b9a75c9
feat(web): add Slack OAuth state helpers
richiemcilroy Jul 30, 2026
91d31c2
feat(web): add Slack API client helpers
richiemcilroy Jul 30, 2026
c1b9db1
feat(web): add Slack installations adapter
richiemcilroy Jul 30, 2026
d3346fb
feat(web): add Slack link unfurl processing
richiemcilroy Jul 30, 2026
8eb9d8b
feat(web): add Slack OAuth install route
richiemcilroy Jul 30, 2026
c26dea9
feat(web): add Slack OAuth callback route
richiemcilroy Jul 30, 2026
bd0212c
feat(web): add Slack events webhook route
richiemcilroy Jul 30, 2026
8c19fde
feat(web): add organization Slack settings actions
richiemcilroy Jul 30, 2026
29203e4
feat(web): add Slack integration settings UI
richiemcilroy Jul 30, 2026
2b8a21e
feat(web): wire Slack into organization integrations page
richiemcilroy Jul 30, 2026
4e99fb8
chore(web): add Slack app manifest
richiemcilroy Jul 30, 2026
544df79
fix: preserve integration tenant ownership
richiemcilroy Jul 30, 2026
7736643
fix: durably queue Slack events
richiemcilroy Jul 30, 2026
d439e7e
fix: classify Slack unfurl failures
richiemcilroy Jul 31, 2026
3903e44
fix: recover exhausted Slack event retries
richiemcilroy Jul 31, 2026
fe9ce66
fix: address Slack integration review feedback
richiemcilroy Jul 31, 2026
ef213c3
fix: use canonical Slack install login route
richiemcilroy Jul 31, 2026
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
265 changes: 265 additions & 0 deletions apps/web/__tests__/unit/integration-installations-repository.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { Organisation, User } from "@cap/web-domain";
import { beforeEach, describe, expect, it, vi } from "vitest";

const databaseMocks = vi.hoisted(() => {
const state = { rows: [] as unknown[] };
const onDuplicateKeyUpdate = vi.fn(
async (_input: { set: Record<string, unknown> }) => undefined,
);
const values = vi.fn(() => ({ onDuplicateKeyUpdate }));
const insert = vi.fn(() => ({ values }));
const orderBy = vi.fn(async () => state.rows);
const limit = vi.fn(async () => state.rows);
const forUpdate = vi.fn(async () => state.rows);
const where = vi.fn(() => ({ orderBy, limit, for: forUpdate }));
const from = vi.fn(() => ({ where }));
const select = vi.fn(() => ({ from }));
const updateWhere = vi.fn(async () => undefined);
const set = vi.fn(() => ({ where: updateWhere }));
const update = vi.fn(() => ({ set }));
const transaction = vi.fn(
async (
callback: (tx: {
insert: typeof insert;
select: typeof select;
update: typeof update;
}) => Promise<unknown>,
) => callback({ insert, select, update }),
);
const deleteWhere = vi.fn(async () => undefined);
const deleteRows = vi.fn(() => ({ where: deleteWhere }));

return {
state,
onDuplicateKeyUpdate,
values,
insert,
orderBy,
limit,
forUpdate,
where,
from,
select,
updateWhere,
set,
update,
transaction,
deleteWhere,
deleteRows,
};
});

const cryptoMocks = vi.hoisted(() => ({
decrypt: vi.fn(),
encrypt: vi.fn(),
}));

vi.mock("@cap/database", () => ({
db: () => ({
insert: databaseMocks.insert,
select: databaseMocks.select,
update: databaseMocks.update,
delete: databaseMocks.deleteRows,
transaction: databaseMocks.transaction,
}),
}));

vi.mock("@cap/database/crypto", () => cryptoMocks);
vi.mock("@cap/database/helpers", async (importOriginal) => {
const actual = await importOriginal<typeof import("@cap/database/helpers")>();
return {
...actual,
nanoId: () => "installation123",
};
});

import {
deleteIntegrationInstallation,
deleteIntegrationInstallationByExternalId,
getIntegrationCredentials,
listIntegrationInstallations,
saveIntegrationInstallation,
} from "@/lib/integrations/installations";

const organizationId = Organisation.OrganisationId.make("org123");
const installedByUserId = User.UserId.make("user123");

describe("integration installation repository", () => {
beforeEach(() => {
vi.clearAllMocks();
databaseMocks.state.rows = [];
cryptoMocks.encrypt.mockResolvedValue("encrypted-credentials");
cryptoMocks.decrypt.mockResolvedValue("{}");
});

it("updates credentials only after locking the owning tenant installation", async () => {
databaseMocks.state.rows = [
{
id: "existing-installation",
organizationId,
},
];

await saveIntegrationInstallation({
provider: "slack",
externalId: "T123",
displayName: "Cap",
organizationId,
installedByUserId,
credentials: { accessToken: "xoxb-token" },
metadata: { scopes: ["links:read"] },
});

expect(cryptoMocks.encrypt).toHaveBeenCalledWith(
JSON.stringify({ accessToken: "xoxb-token" }),
);
expect(databaseMocks.values).toHaveBeenCalledWith({
id: "installation123",
provider: "slack",
externalId: "T123",
displayName: "Cap",
organizationId,
installedByUserId,
encryptedCredentials: "encrypted-credentials",
metadata: { scopes: ["links:read"] },
});

expect(databaseMocks.onDuplicateKeyUpdate).toHaveBeenCalledOnce();
const [upsert] = databaseMocks.onDuplicateKeyUpdate.mock.calls[0] ?? [];
expect(upsert?.set).toEqual({
externalId: expect.anything(),
});
expect(upsert?.set).not.toHaveProperty("organizationId");
expect(databaseMocks.forUpdate).toHaveBeenCalledWith("update");
expect(databaseMocks.set).toHaveBeenCalledWith({
displayName: "Cap",
installedByUserId,
encryptedCredentials: "encrypted-credentials",
metadata: { scopes: ["links:read"] },
updatedAt: expect.any(Date),
});
expect(databaseMocks.updateWhere).toHaveBeenCalledOnce();
});

it("rejects an installation already owned by another organization", async () => {
databaseMocks.state.rows = [
{
id: "existing-installation",
organizationId: Organisation.OrganisationId.make("other-org"),
},
];

await expect(
saveIntegrationInstallation({
provider: "slack",
externalId: "T123",
displayName: "Cap",
organizationId,
installedByUserId,
credentials: { accessToken: "xoxb-token" },
metadata: { scopes: ["links:read"] },
}),
).rejects.toThrow(
"Integration is already connected to another organization",
);
expect(databaseMocks.set).not.toHaveBeenCalled();
});

it("ships a provider-agnostic schema with workload-matched indexes", () => {
const migration = readFileSync(
join(
process.cwd(),
"../../packages/database/migrations/0037_integration_installations.sql",
),
"utf8",
);

expect(migration).toContain("CREATE TABLE `integration_installations`");
expect(migration).toContain(
"CONSTRAINT `provider_external_id_idx` UNIQUE(`provider`,`externalId`)",
);
expect(migration).toContain(
"CREATE INDEX `organization_provider_display_name_idx` ON `integration_installations` (`organizationId`,`provider`,`displayName`)",
);
expect(migration).not.toContain("slack_installations");
expect(migration).not.toContain("encryptedBotToken");
});

it("lists only the non-secret installation summary shape", async () => {
const rows = [
{
id: "installation123",
externalId: "T123",
displayName: "Cap",
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-02T00:00:00Z"),
},
];
databaseMocks.state.rows = rows;

await expect(
listIntegrationInstallations({
organizationId,
provider: "slack",
}),
).resolves.toEqual(rows);
expect(databaseMocks.orderBy).toHaveBeenCalledOnce();
});

it("decrypts and parses credentials only after an exact lookup", async () => {
databaseMocks.state.rows = [
{ encryptedCredentials: "encrypted-credentials" },
];
cryptoMocks.decrypt.mockResolvedValue(
JSON.stringify({ accessToken: "xoxb-token" }),
);

await expect(
getIntegrationCredentials({
provider: "slack",
externalId: "T123",
}),
).resolves.toEqual({ accessToken: "xoxb-token" });
expect(cryptoMocks.decrypt).toHaveBeenCalledWith("encrypted-credentials");
expect(databaseMocks.limit).toHaveBeenCalledWith(1);

databaseMocks.state.rows = [];
await expect(
getIntegrationCredentials({
provider: "slack",
externalId: "missing",
}),
).resolves.toBeNull();
});

it("fails closed when encrypted credentials are malformed", async () => {
databaseMocks.state.rows = [
{ encryptedCredentials: "encrypted-credentials" },
];
cryptoMocks.decrypt.mockResolvedValue("not-json");

await expect(
getIntegrationCredentials({
provider: "slack",
externalId: "T123",
}),
).rejects.toThrow();
});

it("supports tenant-scoped disconnects and provider-scoped uninstalls", async () => {
await deleteIntegrationInstallation({
provider: "slack",
organizationId,
installationId: "installation123",
});
await deleteIntegrationInstallationByExternalId({
provider: "slack",
externalId: "T123",
});

expect(databaseMocks.deleteRows).toHaveBeenCalledTimes(2);
expect(databaseMocks.deleteWhere).toHaveBeenCalledTimes(2);
});
});
123 changes: 123 additions & 0 deletions apps/web/__tests__/unit/integration-installations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { Organisation, User } from "@cap/web-domain";
import { beforeEach, describe, expect, it, vi } from "vitest";

const integrationMocks = vi.hoisted(() => ({
deleteIntegrationInstallation: vi.fn(async () => undefined),
deleteIntegrationInstallationByExternalId: vi.fn(async () => undefined),
getIntegrationCredentials: vi.fn(),
listIntegrationInstallations: vi.fn(),
saveIntegrationInstallation: vi.fn(async () => undefined),
}));

vi.mock("@/lib/integrations/installations", () => integrationMocks);

import {
deleteSlackInstallation,
deleteSlackInstallationByTeamId,
getSlackInstallationToken,
listSlackInstallations,
saveSlackInstallation,
} from "@/lib/slack/installations";

const organizationId = Organisation.OrganisationId.make("org123");
const installedByUserId = User.UserId.make("user123");

describe("integration installations", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("stores Slack through the provider-agnostic installation boundary", async () => {
await saveSlackInstallation({
organizationId,
installedByUserId,
teamId: "T123",
teamName: "Cap",
enterpriseId: "E123",
botUserId: "U123",
accessToken: "xoxb-token",
scope: "links:read, links:write,links.embed:write",
});

expect(integrationMocks.saveIntegrationInstallation).toHaveBeenCalledWith({
provider: "slack",
externalId: "T123",
displayName: "Cap",
organizationId,
installedByUserId,
credentials: { accessToken: "xoxb-token" },
metadata: {
enterpriseId: "E123",
botUserId: "U123",
scopes: ["links:read", "links:write", "links.embed:write"],
},
});
});

it("maps generic installation summaries to Slack workspaces", async () => {
const createdAt = new Date("2026-01-01T00:00:00Z");
const updatedAt = new Date("2026-01-02T00:00:00Z");
integrationMocks.listIntegrationInstallations.mockResolvedValue([
{
id: "installation123",
externalId: "T123",
displayName: "Cap",
createdAt,
updatedAt,
},
]);

await expect(listSlackInstallations(organizationId)).resolves.toEqual([
{
id: "installation123",
teamId: "T123",
teamName: "Cap",
createdAt,
updatedAt,
},
]);
expect(integrationMocks.listIntegrationInstallations).toHaveBeenCalledWith({
organizationId,
provider: "slack",
});
});

it("reads only valid provider credentials", async () => {
integrationMocks.getIntegrationCredentials.mockResolvedValue({
accessToken: "xoxb-token",
});
await expect(getSlackInstallationToken("T123")).resolves.toBe("xoxb-token");

integrationMocks.getIntegrationCredentials.mockResolvedValue(null);
await expect(getSlackInstallationToken("T123")).resolves.toBeNull();

integrationMocks.getIntegrationCredentials.mockResolvedValue({
accessToken: 123,
});
await expect(getSlackInstallationToken("T123")).rejects.toThrow(
"Slack installation credentials are invalid",
);
});

it("keeps all deletion paths provider-scoped", async () => {
await deleteSlackInstallation({
organizationId,
installationId: "installation123",
});
await deleteSlackInstallationByTeamId("T123");

expect(integrationMocks.deleteIntegrationInstallation).toHaveBeenCalledWith(
{
provider: "slack",
organizationId,
installationId: "installation123",
},
);
expect(
integrationMocks.deleteIntegrationInstallationByExternalId,
).toHaveBeenCalledWith({
provider: "slack",
externalId: "T123",
});
});
});
Loading
Loading