trails/apps/journal/app/lib/connected-services/manager.test.ts
Ullrich Schäfer 6de516718d
Schema rename + ConnectedServiceManager foundation (groups 1-2)
Implements tasks 1.1-1.4 and 2.1-2.5 of deepen-connected-services:

DB:
- Rename journal.sync_connections -> journal.connected_services.
- Add credential_kind discriminator (oauth | web-login | device) and
  credentials JSONB (shape per kind), status column, and a unique index
  on (user_id, provider) lifting the previously app-only invariant
  into the DB.
- Idempotent backfill in 0002_connected_services.sql moves existing
  Wahoo rows' tokens into the JSONB blob with credential_kind='oauth'.

Code:
- New apps/journal/app/lib/connected-services/ module:
  - types.ts: ConnectedService, CredentialKind, OAuthCredentials,
    NeedsRelinkError, CredentialAdapter, ProviderOAuthConfig, etc.
  - credential-adapters/oauth.ts: standard OAuth2 refresh_token flow,
    revoke endpoint, 4xx -> NeedsRelinkError / 5xx -> transient.
  - manager.ts: ConnectedServiceManager (link, unlink, withFreshCredentials,
    markNeedsRelink). Centralizes credential lifecycle in one chokepoint.
  - registry.ts: ProviderManifest type + capability seam interfaces
    (Importer, RoutePusher, WebhookReceiver). Manifests register
    themselves at import time.

Tests:
- manager.test.ts (8 tests): refresh-on-expired, refresh-fail->needs_relink,
  ConnectionNotActiveError, link/unlink, revoke is best-effort.
- credential-adapters/oauth.test.ts (10 tests): refresh contract,
  refresh_token retention, 4xx vs 5xx behaviour, revoke.
- All 18 new tests pass.

Compatibility:
- apps/journal/app/lib/sync/connections.server.ts is now a thin shim
  translating the legacy TokenSet API onto the JSONB-shaped table so
  existing callers (routes, pushes.server.ts) keep working until tasks
  5.x migrate them to the manager. To be deleted in task 5.6.

Pre-existing journal test failures (12) are unrelated to this change:
they pre-date this PR and stem from a workspace resolution issue with
@trails-cool/fit (verified by running tests against main).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:14:38 +02:00

241 lines
6.8 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import {
ConnectionNotActiveError,
NeedsRelinkError,
type CredentialAdapter,
type OAuthCredentials,
type ProviderOAuthConfig,
} from "./types.ts";
import type { ProviderManifest } from "./registry.ts";
// ---- DB mock ----
type Row = {
id: string;
userId: string;
provider: string;
credentialKind: string;
credentials: unknown;
status: string;
providerUserId: string | null;
grantedScopes: string[];
createdAt: Date;
};
const rows: Row[] = [];
const mockDb = {
select: () => ({
from: () => ({
where: () => Promise.resolve(rows.slice()),
}),
}),
insert: () => ({
values: (v: Row) => {
rows.push(v);
return Promise.resolve();
},
}),
update: () => ({
set: (patch: Partial<Row>) => ({
where: (cond: unknown) => {
// The cond from drizzle is opaque to us; in this test we always
// patch the most recently-inserted row.
void cond;
const row = rows[rows.length - 1];
if (row) Object.assign(row, patch);
return Promise.resolve();
},
}),
}),
delete: () => ({
where: () => {
rows.length = 0;
return Promise.resolve();
},
}),
};
vi.mock("../db.ts", () => ({ getDb: () => mockDb }));
// ---- Manifest / adapter stubs ----
const refreshSpy = vi.fn();
const revokeSpy = vi.fn();
const isExpiredSpy = vi.fn();
const stubAdapter: CredentialAdapter<OAuthCredentials> = {
isExpired: (creds) => isExpiredSpy(creds),
refresh: (creds, cfg) => refreshSpy(creds, cfg),
revoke: (creds, cfg) => revokeSpy(creds, cfg),
};
const stubOauthConfig: ProviderOAuthConfig = {
tokenUrl: "https://example.test/oauth/token",
clientId: "cid",
clientSecret: "secret",
revokeUrl: "https://example.test/v1/permissions",
};
const stubManifest: ProviderManifest = {
id: "stub",
displayName: "Stub",
credentialKind: "oauth",
credentialAdapter: stubAdapter as CredentialAdapter,
oauthConfig: stubOauthConfig,
};
vi.mock("./registry.ts", async () => {
const actual = await vi.importActual<typeof import("./registry.ts")>("./registry.ts");
return {
...actual,
getManifest: (id: string) => (id === "stub" ? stubManifest : null),
};
});
// Import after mocks are wired.
const {
link,
unlink,
unlinkByUserProvider,
markNeedsRelink,
withFreshCredentials,
getService,
} = await import("./manager.ts");
beforeEach(() => {
rows.length = 0;
refreshSpy.mockReset();
revokeSpy.mockReset();
isExpiredSpy.mockReset();
});
describe("ConnectedServiceManager.link", () => {
it("inserts a row with the active status and supplied fields", async () => {
const svc = await link({
userId: "u1",
provider: "stub",
credentialKind: "oauth",
credentials: { access_token: "a", refresh_token: "r", expires_at: new Date().toISOString() },
providerUserId: "pu1",
grantedScopes: ["read"],
});
expect(svc.userId).toBe("u1");
expect(svc.provider).toBe("stub");
expect(svc.status).toBe("active");
expect(svc.grantedScopes).toEqual(["read"]);
expect(rows).toHaveLength(1);
});
});
describe("ConnectedServiceManager.withFreshCredentials", () => {
const freshCreds: OAuthCredentials = {
access_token: "a",
refresh_token: "r",
expires_at: new Date(Date.now() + 3600_000).toISOString(),
};
async function seed(status: "active" | "needs_relink" | "revoked" = "active") {
await link({
userId: "u1",
provider: "stub",
credentialKind: "oauth",
credentials: freshCreds,
});
if (status !== "active") rows[0]!.status = status;
return rows[0]!.id;
}
it("calls fn directly when credentials are not expired", async () => {
const id = await seed();
isExpiredSpy.mockReturnValue(false);
const result = await withFreshCredentials(id, async (creds) => {
expect(creds).toEqual(freshCreds);
return "ok";
});
expect(result).toBe("ok");
expect(refreshSpy).not.toHaveBeenCalled();
});
it("refreshes credentials and persists new blob when expired", async () => {
const id = await seed();
isExpiredSpy.mockReturnValue(true);
const newCreds: OAuthCredentials = {
access_token: "a2",
refresh_token: "r2",
expires_at: new Date(Date.now() + 7200_000).toISOString(),
};
refreshSpy.mockResolvedValue(newCreds);
const got = await withFreshCredentials(id, async (creds) => creds as OAuthCredentials);
expect(refreshSpy).toHaveBeenCalledWith(freshCreds, stubOauthConfig);
expect(got).toEqual(newCreds);
expect(rows[0]!.credentials).toEqual(newCreds);
});
it("flips status to needs_relink and re-throws when refresh raises NeedsRelinkError", async () => {
const id = await seed();
isExpiredSpy.mockReturnValue(true);
refreshSpy.mockRejectedValue(new NeedsRelinkError("revoked"));
await expect(
withFreshCredentials(id, async () => "never"),
).rejects.toBeInstanceOf(NeedsRelinkError);
expect(rows[0]!.status).toBe("needs_relink");
});
it("rejects with ConnectionNotActiveError when status is not active", async () => {
const id = await seed("needs_relink");
isExpiredSpy.mockReturnValue(false);
await expect(
withFreshCredentials(id, async () => "never"),
).rejects.toBeInstanceOf(ConnectionNotActiveError);
expect(refreshSpy).not.toHaveBeenCalled();
});
});
describe("ConnectedServiceManager.markNeedsRelink", () => {
it("flips the row's status", async () => {
await link({
userId: "u1",
provider: "stub",
credentialKind: "oauth",
credentials: { access_token: "a", refresh_token: "r", expires_at: new Date().toISOString() },
});
await markNeedsRelink(rows[0]!.id, "test");
expect(rows[0]!.status).toBe("needs_relink");
});
});
describe("ConnectedServiceManager.unlink", () => {
it("calls the credential adapter's revoke before deletion", async () => {
await link({
userId: "u1",
provider: "stub",
credentialKind: "oauth",
credentials: { access_token: "a", refresh_token: "r", expires_at: new Date().toISOString() },
});
revokeSpy.mockResolvedValue(undefined);
await unlink(rows[0]?.id ?? "missing");
expect(revokeSpy).toHaveBeenCalled();
});
it("swallows revoke failures and still deletes locally", async () => {
await link({
userId: "u1",
provider: "stub",
credentialKind: "oauth",
credentials: { access_token: "a", refresh_token: "r", expires_at: new Date().toISOString() },
});
const id = rows[0]!.id;
revokeSpy.mockRejectedValue(new Error("provider down"));
await expect(unlink(id)).resolves.toBeUndefined();
expect(rows).toHaveLength(0);
});
});
// Reference unused symbols to keep the linter happy under strict noUnused rules.
void unlinkByUserProvider;
void getService;