@vellumai/assistant 0.4.48 → 0.4.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +2 -2
- package/README.md +2 -23
- package/docs/architecture/integrations.md +45 -41
- package/docs/architecture/keychain-broker.md +3 -3
- package/docs/runbook-trusted-contacts.md +3 -8
- package/hook-templates/debug-prompt-logger/hook.json +1 -1
- package/hook-templates/debug-prompt-logger/run.sh +1 -3
- package/package.json +1 -1
- package/src/__tests__/actor-token-service.test.ts +0 -1
- package/src/__tests__/anthropic-provider.test.ts +156 -0
- package/src/__tests__/approval-cascade.test.ts +810 -0
- package/src/__tests__/approval-primitive.test.ts +0 -1
- package/src/__tests__/approval-routes-http.test.ts +2 -0
- package/src/__tests__/assistant-attachments.test.ts +12 -34
- package/src/__tests__/assistant-feature-flag-guardrails.test.ts +76 -0
- package/src/__tests__/assistant-feature-flags-integration.test.ts +0 -1
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +2 -2
- package/src/__tests__/channel-guardian.test.ts +0 -2
- package/src/__tests__/channel-readiness-routes.test.ts +15 -6
- package/src/__tests__/channel-readiness-service.test.ts +10 -9
- package/src/__tests__/checker.test.ts +9 -29
- package/src/__tests__/computer-use-skill-manifest-regression.test.ts +1 -1
- package/src/__tests__/computer-use-tools.test.ts +2 -19
- package/src/__tests__/config-watcher.test.ts +0 -1
- package/src/__tests__/confirmation-request-guardian-bridge.test.ts +0 -1
- package/src/__tests__/context-image-dimensions.test.ts +332 -0
- package/src/__tests__/context-token-estimator.test.ts +196 -13
- package/src/__tests__/conversation-attention-store.test.ts +0 -1
- package/src/__tests__/conversation-attention-telegram.test.ts +0 -1
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +144 -0
- package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -0
- package/src/__tests__/credential-metadata-store.test.ts +64 -73
- package/src/__tests__/credential-security-invariants.test.ts +13 -7
- package/src/__tests__/credential-vault-unit.test.ts +280 -49
- package/src/__tests__/credential-vault.test.ts +138 -16
- package/src/__tests__/credentials-cli.test.ts +71 -0
- package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +0 -1
- package/src/__tests__/ephemeral-permissions.test.ts +3 -3
- package/src/__tests__/gateway-only-guard.test.ts +0 -1
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +0 -1
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +0 -1
- package/src/__tests__/guardian-routing-invariants.test.ts +0 -1
- package/src/__tests__/guardian-verification-voice-binding.test.ts +0 -1
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +0 -39
- package/src/__tests__/heartbeat-service.test.ts +0 -1
- package/src/__tests__/host-cu-proxy.test.ts +629 -0
- package/src/__tests__/host-shell-tool.test.ts +27 -15
- package/src/__tests__/http-user-message-parity.test.ts +1 -0
- package/src/__tests__/ingress-url-consistency.test.ts +14 -21
- package/src/__tests__/integration-status.test.ts +32 -51
- package/src/__tests__/intent-routing.test.ts +0 -1
- package/src/__tests__/invite-routes-http.test.ts +10 -9
- package/src/__tests__/keychain-broker-client.test.ts +11 -43
- package/src/__tests__/notification-routing-intent.test.ts +0 -1
- package/src/__tests__/oauth-cli.test.ts +373 -14
- package/src/__tests__/oauth-provider-profiles.test.ts +9 -9
- package/src/__tests__/oauth-scope-policy.test.ts +4 -6
- package/src/__tests__/oauth-store.test.ts +756 -0
- package/src/__tests__/onboarding-starter-tasks.test.ts +0 -1
- package/src/__tests__/provider-error-scenarios.test.ts +0 -1
- package/src/__tests__/provider-streaming.benchmark.test.ts +0 -1
- package/src/__tests__/public-ingress-urls.test.ts +15 -21
- package/src/__tests__/recording-handler.test.ts +3 -4
- package/src/__tests__/registry.test.ts +2 -2
- package/src/__tests__/runtime-events-sse.test.ts +55 -7
- package/src/__tests__/schedule-store.test.ts +0 -1
- package/src/__tests__/scheduler-recurrence.test.ts +0 -1
- package/src/__tests__/scoped-approval-grants.test.ts +0 -1
- package/src/__tests__/scoped-grant-security-matrix.test.ts +0 -1
- package/src/__tests__/secret-ingress-handler.test.ts +0 -1
- package/src/__tests__/send-endpoint-busy.test.ts +21 -6
- package/src/__tests__/sequence-store.test.ts +0 -1
- package/src/__tests__/session-init.benchmark.test.ts +4 -5
- package/src/__tests__/skill-include-graph.test.ts +66 -0
- package/src/__tests__/skill-load-feature-flag.test.ts +0 -1
- package/src/__tests__/skill-load-tool.test.ts +149 -1
- package/src/__tests__/skill-projection-feature-flag.test.ts +0 -1
- package/src/__tests__/skills-uninstall.test.ts +1 -1
- package/src/__tests__/skills.test.ts +3 -3
- package/src/__tests__/slack-channel-config.test.ts +67 -3
- package/src/__tests__/slack-share-routes.test.ts +17 -19
- package/src/__tests__/system-prompt.test.ts +0 -1
- package/src/__tests__/telegram-invite-adapter.test.ts +18 -22
- package/src/__tests__/terminal-tools.test.ts +4 -3
- package/src/__tests__/test-support/computer-use-skill-harness.ts +3 -2
- package/src/__tests__/tool-approval-handler.test.ts +0 -1
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +0 -1
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +0 -1
- package/src/__tests__/tool-executor-shell-integration.test.ts +0 -1
- package/src/__tests__/tool-executor.test.ts +0 -1
- package/src/__tests__/tool-grant-request-escalation.test.ts +0 -1
- package/src/__tests__/trust-store-pattern-matches.test.ts +29 -0
- package/src/__tests__/trust-store.test.ts +1 -22
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +0 -1
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +0 -1
- package/src/__tests__/twilio-routes.test.ts +0 -16
- package/src/__tests__/verification-control-plane-policy.test.ts +0 -1
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
- package/src/agent/ax-tree-compaction.test.ts +235 -0
- package/src/agent/loop.ts +76 -130
- package/src/calls/call-domain.ts +1 -6
- package/src/calls/relay-server.ts +9 -13
- package/src/calls/twilio-config.ts +2 -7
- package/src/calls/twilio-routes.ts +1 -2
- package/src/calls/voice-ingress-preflight.ts +1 -1
- package/src/cli/commands/browser-relay.ts +18 -12
- package/src/cli/commands/completions.ts +0 -3
- package/src/cli/commands/credentials.ts +101 -15
- package/src/cli/commands/oauth/apps.ts +255 -0
- package/src/cli/commands/oauth/connections.ts +299 -0
- package/src/cli/commands/oauth/index.ts +52 -0
- package/src/cli/commands/oauth/providers.ts +242 -0
- package/src/cli/commands/skills.ts +4 -338
- package/src/cli/program.ts +1 -5
- package/src/cli/reference.ts +1 -3
- package/src/config/assistant-feature-flags.ts +0 -3
- package/src/config/bundled-skills/_shared/CLI_RETRIEVAL_PATTERN.md +1 -1
- package/src/config/bundled-skills/computer-use/SKILL.md +3 -6
- package/src/config/bundled-skills/computer-use/TOOLS.json +22 -4
- package/src/config/bundled-skills/google-calendar/calendar-client.ts +21 -16
- package/src/config/bundled-skills/messaging/tools/shared.ts +1 -4
- package/src/config/bundled-skills/settings/SKILL.md +1 -1
- package/src/config/bundled-skills/settings/TOOLS.json +2 -8
- package/src/config/bundled-skills/settings/tools/voice-config-update.ts +5 -33
- package/src/config/env-registry.ts +14 -83
- package/src/config/env.ts +11 -50
- package/src/config/feature-flag-registry.json +16 -16
- package/src/config/loader.ts +0 -6
- package/src/config/schema.ts +3 -1
- package/src/config/skills.ts +21 -2
- package/src/context/image-dimensions.ts +229 -0
- package/src/context/token-estimator.ts +75 -12
- package/src/context/window-manager.ts +49 -10
- package/src/daemon/assistant-attachments.ts +1 -13
- package/src/daemon/handlers/config-ingress.ts +8 -33
- package/src/daemon/handlers/config-slack-channel.ts +49 -46
- package/src/daemon/handlers/config-telegram.ts +32 -16
- package/src/daemon/handlers/sessions.ts +10 -24
- package/src/daemon/handlers/shared.ts +0 -130
- package/src/daemon/host-cu-proxy.ts +401 -0
- package/src/daemon/lifecycle.ts +36 -68
- package/src/daemon/message-protocol.ts +3 -0
- package/src/daemon/message-types/computer-use.ts +2 -119
- package/src/daemon/message-types/host-cu.ts +19 -0
- package/src/daemon/message-types/messages.ts +3 -0
- package/src/daemon/server.ts +14 -21
- package/src/daemon/session-agent-loop-handlers.ts +2 -0
- package/src/daemon/session-attachments.ts +1 -2
- package/src/daemon/session-slash.ts +1 -1
- package/src/daemon/session-surfaces.ts +40 -28
- package/src/daemon/session-tool-setup.ts +2 -9
- package/src/daemon/session.ts +138 -15
- package/src/daemon/tool-side-effects.ts +2 -8
- package/src/daemon/watch-handler.ts +2 -2
- package/src/events/tool-metrics-listener.ts +2 -2
- package/src/hooks/manager.ts +1 -4
- package/src/inbound/public-ingress-urls.ts +7 -7
- package/src/logfire.ts +16 -5
- package/src/memory/conversation-key-store.ts +21 -0
- package/src/memory/db-init.ts +4 -0
- package/src/memory/migrations/149-oauth-tables.ts +60 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/schema/index.ts +1 -0
- package/src/memory/schema/oauth.ts +65 -0
- package/src/messaging/provider.ts +4 -4
- package/src/messaging/providers/gmail/client.ts +82 -2
- package/src/messaging/providers/gmail/people-client.ts +10 -10
- package/src/messaging/providers/telegram-bot/adapter.ts +17 -17
- package/src/messaging/providers/whatsapp/adapter.ts +11 -8
- package/src/messaging/registry.ts +2 -32
- package/src/notifications/copy-composer.ts +0 -5
- package/src/notifications/signal.ts +4 -5
- package/src/oauth/byo-connection.test.ts +126 -25
- package/src/oauth/byo-connection.ts +22 -6
- package/src/oauth/connect-orchestrator.ts +113 -57
- package/src/oauth/connect-types.ts +17 -23
- package/src/oauth/connection-resolver.ts +35 -11
- package/src/oauth/connection.ts +1 -1
- package/src/oauth/manual-token-connection.ts +104 -0
- package/src/oauth/oauth-store.ts +496 -0
- package/src/oauth/platform-connection.test.ts +29 -0
- package/src/oauth/platform-connection.ts +6 -5
- package/src/oauth/provider-behaviors.ts +124 -0
- package/src/oauth/scope-policy.ts +9 -2
- package/src/oauth/seed-providers.ts +161 -0
- package/src/oauth/token-persistence.ts +74 -78
- package/src/permissions/checker.ts +3 -3
- package/src/permissions/defaults.ts +0 -1
- package/src/permissions/prompter.ts +10 -1
- package/src/permissions/trust-store.ts +13 -0
- package/src/prompts/__tests__/build-cli-reference-section.test.ts +3 -1
- package/src/prompts/system-prompt.ts +28 -40
- package/src/providers/anthropic/client.ts +133 -24
- package/src/providers/retry.ts +1 -27
- package/src/runtime/auth/route-policy.ts +0 -3
- package/src/runtime/channel-reply-delivery.ts +0 -40
- package/src/runtime/gateway-client.ts +0 -7
- package/src/runtime/http-server.ts +8 -6
- package/src/runtime/http-types.ts +2 -2
- package/src/runtime/middleware/twilio-validation.ts +1 -11
- package/src/runtime/pending-interactions.ts +14 -12
- package/src/runtime/routes/channel-delivery-routes.ts +0 -1
- package/src/runtime/routes/conversation-routes.ts +73 -19
- package/src/runtime/routes/events-routes.ts +21 -11
- package/src/runtime/routes/host-cu-routes.ts +97 -0
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +12 -111
- package/src/runtime/routes/integrations/slack/share.ts +6 -7
- package/src/runtime/routes/log-export-routes.ts +126 -8
- package/src/runtime/routes/settings-routes.ts +55 -48
- package/src/runtime/routes/surface-action-routes.ts +1 -1
- package/src/runtime/routes/watch-routes.ts +128 -0
- package/src/schedule/integration-status.ts +10 -9
- package/src/security/credential-key.ts +0 -156
- package/src/security/keychain-broker-client.ts +5 -6
- package/src/security/oauth2.ts +1 -1
- package/src/security/token-manager.ts +119 -46
- package/src/skills/catalog-install.ts +358 -0
- package/src/skills/include-graph.ts +32 -0
- package/src/telegram/bot-username.ts +2 -3
- package/src/tools/browser/network-recorder.ts +1 -1
- package/src/tools/browser/network-recording-types.ts +1 -1
- package/src/tools/computer-use/definitions.ts +46 -11
- package/src/tools/computer-use/registry.ts +4 -5
- package/src/tools/credentials/broker.ts +1 -2
- package/src/tools/credentials/metadata-store.ts +17 -121
- package/src/tools/credentials/vault.ts +94 -167
- package/src/tools/registry.ts +2 -7
- package/src/tools/skills/load.ts +62 -3
- package/src/tools/watch/watch-state.ts +0 -12
- package/src/util/logger.ts +7 -41
- package/src/util/platform.ts +9 -28
- package/src/watcher/providers/google-calendar.ts +2 -1
- package/src/__tests__/computer-use-session-compaction.test.ts +0 -143
- package/src/__tests__/computer-use-session-lifecycle.test.ts +0 -322
- package/src/__tests__/computer-use-session-working-dir.test.ts +0 -166
- package/src/__tests__/computer-use-skill-baseline.test.ts +0 -78
- package/src/__tests__/computer-use-skill-endstate.test.ts +0 -105
- package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +0 -249
- package/src/__tests__/ride-shotgun-handler.test.ts +0 -452
- package/src/cli/commands/dev.ts +0 -129
- package/src/cli/commands/map.ts +0 -391
- package/src/cli/commands/oauth.ts +0 -77
- package/src/config/bundled-skills/computer-use/tools/computer-use-request-control.ts +0 -16
- package/src/daemon/computer-use-session.ts +0 -1026
- package/src/daemon/ride-shotgun-handler.ts +0 -569
- package/src/oauth/provider-base-urls.ts +0 -21
- package/src/oauth/provider-profiles.ts +0 -192
- package/src/prompts/computer-use-prompt.ts +0 -98
- package/src/runtime/routes/computer-use-routes.ts +0 -641
- package/src/runtime/telegram-streaming-delivery.test.ts +0 -729
- package/src/runtime/telegram-streaming-delivery.ts +0 -393
- package/src/tools/computer-use/request-computer-control.ts +0 -56
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
5
|
+
|
|
6
|
+
const testDir = mkdtempSync(join(tmpdir(), "oauth-store-test-"));
|
|
7
|
+
|
|
8
|
+
mock.module("../util/platform.js", () => ({
|
|
9
|
+
getDataDir: () => testDir,
|
|
10
|
+
isMacOS: () => process.platform === "darwin",
|
|
11
|
+
isLinux: () => process.platform === "linux",
|
|
12
|
+
isWindows: () => process.platform === "win32",
|
|
13
|
+
getPidPath: () => join(testDir, "test.pid"),
|
|
14
|
+
getDbPath: () => ":memory:",
|
|
15
|
+
getLogPath: () => join(testDir, "test.log"),
|
|
16
|
+
ensureDataDir: () => {},
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
mock.module("../util/logger.js", () => ({
|
|
20
|
+
getLogger: () =>
|
|
21
|
+
new Proxy({} as Record<string, unknown>, {
|
|
22
|
+
get: () => () => {},
|
|
23
|
+
}),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const mockDeleteSecureKeyAsync = mock(
|
|
27
|
+
(): Promise<"deleted" | "not-found" | "error"> =>
|
|
28
|
+
Promise.resolve("deleted" as const),
|
|
29
|
+
);
|
|
30
|
+
const mockSetSecureKeyAsync = mock(() => Promise.resolve(true));
|
|
31
|
+
/** Simulated secure key store for getSecureKey lookups. */
|
|
32
|
+
const secureKeyValues = new Map<string, string>();
|
|
33
|
+
mock.module("../security/secure-keys.js", () => ({
|
|
34
|
+
deleteSecureKeyAsync: mockDeleteSecureKeyAsync,
|
|
35
|
+
setSecureKeyAsync: mockSetSecureKeyAsync,
|
|
36
|
+
getSecureKey: (account: string) => secureKeyValues.get(account),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
import { initializeDb, resetDb, resetTestTables } from "../memory/db.js";
|
|
40
|
+
import {
|
|
41
|
+
createConnection,
|
|
42
|
+
deleteApp,
|
|
43
|
+
deleteConnection,
|
|
44
|
+
disconnectOAuthProvider,
|
|
45
|
+
getApp,
|
|
46
|
+
getAppByProviderAndClientId,
|
|
47
|
+
getConnection,
|
|
48
|
+
getConnectionByProvider,
|
|
49
|
+
getProvider,
|
|
50
|
+
isProviderConnected,
|
|
51
|
+
listConnections,
|
|
52
|
+
registerProvider,
|
|
53
|
+
seedProviders,
|
|
54
|
+
updateConnection,
|
|
55
|
+
upsertApp,
|
|
56
|
+
} from "../oauth/oauth-store.js";
|
|
57
|
+
|
|
58
|
+
initializeDb();
|
|
59
|
+
|
|
60
|
+
/** Seed a minimal provider row for FK satisfaction. */
|
|
61
|
+
function seedTestProvider(providerKey = "github"): void {
|
|
62
|
+
seedProviders([
|
|
63
|
+
{
|
|
64
|
+
providerKey,
|
|
65
|
+
authUrl: `https://${providerKey}.example.com/authorize`,
|
|
66
|
+
tokenUrl: `https://${providerKey}.example.com/token`,
|
|
67
|
+
defaultScopes: ["read"],
|
|
68
|
+
scopePolicy: {},
|
|
69
|
+
},
|
|
70
|
+
]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Create an app linked to the given provider. Returns the app row. */
|
|
74
|
+
async function createTestApp(providerKey = "github", clientId = "client-1") {
|
|
75
|
+
seedTestProvider(providerKey);
|
|
76
|
+
return await upsertApp(providerKey, clientId);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
resetDb();
|
|
81
|
+
initializeDb();
|
|
82
|
+
// Explicitly clear all OAuth tables to prevent cross-test state pollution.
|
|
83
|
+
// Delete in FK-dependency order: connections → apps → providers.
|
|
84
|
+
resetTestTables("oauth_connections", "oauth_apps", "oauth_providers");
|
|
85
|
+
mockDeleteSecureKeyAsync.mockClear();
|
|
86
|
+
mockSetSecureKeyAsync.mockClear();
|
|
87
|
+
secureKeyValues.clear();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterAll(() => {
|
|
91
|
+
resetDb();
|
|
92
|
+
try {
|
|
93
|
+
rmSync(testDir, { recursive: true });
|
|
94
|
+
} catch {
|
|
95
|
+
// best-effort cleanup
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Provider operations
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
describe("provider operations", () => {
|
|
104
|
+
describe("seedProviders", () => {
|
|
105
|
+
test("creates rows for new providers", () => {
|
|
106
|
+
seedProviders([
|
|
107
|
+
{
|
|
108
|
+
providerKey: "github",
|
|
109
|
+
authUrl: "https://github.com/login/oauth/authorize",
|
|
110
|
+
tokenUrl: "https://github.com/login/oauth/access_token",
|
|
111
|
+
defaultScopes: ["repo", "user"],
|
|
112
|
+
scopePolicy: { required: ["repo"] },
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
providerKey: "google",
|
|
116
|
+
authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
117
|
+
tokenUrl: "https://oauth2.googleapis.com/token",
|
|
118
|
+
defaultScopes: ["openid", "email"],
|
|
119
|
+
scopePolicy: {},
|
|
120
|
+
extraParams: { access_type: "offline" },
|
|
121
|
+
},
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
const gh = getProvider("github");
|
|
125
|
+
expect(gh).toBeDefined();
|
|
126
|
+
expect(gh!.providerKey).toBe("github");
|
|
127
|
+
expect(gh!.authUrl).toBe("https://github.com/login/oauth/authorize");
|
|
128
|
+
expect(gh!.tokenUrl).toBe("https://github.com/login/oauth/access_token");
|
|
129
|
+
expect(JSON.parse(gh!.defaultScopes)).toEqual(["repo", "user"]);
|
|
130
|
+
expect(JSON.parse(gh!.scopePolicy)).toEqual({ required: ["repo"] });
|
|
131
|
+
|
|
132
|
+
const goog = getProvider("google");
|
|
133
|
+
expect(goog).toBeDefined();
|
|
134
|
+
expect(goog!.providerKey).toBe("google");
|
|
135
|
+
expect(JSON.parse(goog!.extraParams!)).toEqual({
|
|
136
|
+
access_type: "offline",
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("updates existing provider rows with corrected seed data", () => {
|
|
141
|
+
seedProviders([
|
|
142
|
+
{
|
|
143
|
+
providerKey: "github",
|
|
144
|
+
authUrl: "https://github.com/login/oauth/authorize",
|
|
145
|
+
tokenUrl: "https://github.com/login/oauth/access_token",
|
|
146
|
+
defaultScopes: ["repo"],
|
|
147
|
+
scopePolicy: {},
|
|
148
|
+
baseUrl: "https://api.github.com",
|
|
149
|
+
},
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
const original = getProvider("github");
|
|
153
|
+
expect(original).toBeDefined();
|
|
154
|
+
expect(original!.baseUrl).toBe("https://api.github.com");
|
|
155
|
+
const originalCreatedAt = original!.createdAt;
|
|
156
|
+
|
|
157
|
+
// Re-seed with corrected values (simulates a code fix deployed on upgrade)
|
|
158
|
+
seedProviders([
|
|
159
|
+
{
|
|
160
|
+
providerKey: "github",
|
|
161
|
+
authUrl: "https://github.com/login/oauth/authorize-v2",
|
|
162
|
+
tokenUrl: "https://github.com/login/oauth/access_token-v2",
|
|
163
|
+
defaultScopes: ["repo", "user"],
|
|
164
|
+
scopePolicy: { required: ["repo"] },
|
|
165
|
+
baseUrl: "https://api.github.com/v2",
|
|
166
|
+
},
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
const row = getProvider("github");
|
|
170
|
+
expect(row).toBeDefined();
|
|
171
|
+
// Seed data should overwrite the existing row
|
|
172
|
+
expect(row!.authUrl).toBe("https://github.com/login/oauth/authorize-v2");
|
|
173
|
+
expect(row!.tokenUrl).toBe(
|
|
174
|
+
"https://github.com/login/oauth/access_token-v2",
|
|
175
|
+
);
|
|
176
|
+
expect(row!.baseUrl).toBe("https://api.github.com/v2");
|
|
177
|
+
expect(JSON.parse(row!.defaultScopes)).toEqual(["repo", "user"]);
|
|
178
|
+
expect(JSON.parse(row!.scopePolicy)).toEqual({ required: ["repo"] });
|
|
179
|
+
// createdAt should be preserved from the original insert
|
|
180
|
+
expect(row!.createdAt).toBe(originalCreatedAt);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("getProvider", () => {
|
|
185
|
+
test("returns the correct row", () => {
|
|
186
|
+
seedProviders([
|
|
187
|
+
{
|
|
188
|
+
providerKey: "github",
|
|
189
|
+
authUrl: "https://github.com/authorize",
|
|
190
|
+
tokenUrl: "https://github.com/token",
|
|
191
|
+
defaultScopes: ["repo"],
|
|
192
|
+
scopePolicy: {},
|
|
193
|
+
callbackTransport: "loopback",
|
|
194
|
+
loopbackPort: 8765,
|
|
195
|
+
},
|
|
196
|
+
]);
|
|
197
|
+
|
|
198
|
+
const row = getProvider("github");
|
|
199
|
+
expect(row).toBeDefined();
|
|
200
|
+
expect(row!.providerKey).toBe("github");
|
|
201
|
+
expect(row!.callbackTransport).toBe("loopback");
|
|
202
|
+
expect(row!.loopbackPort).toBe(8765);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("returns undefined for unknown keys", () => {
|
|
206
|
+
expect(getProvider("nonexistent")).toBeUndefined();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("registerProvider", () => {
|
|
211
|
+
test("creates a new row", () => {
|
|
212
|
+
const row = registerProvider({
|
|
213
|
+
providerKey: "linear",
|
|
214
|
+
authUrl: "https://linear.app/oauth/authorize",
|
|
215
|
+
tokenUrl: "https://api.linear.app/oauth/token",
|
|
216
|
+
defaultScopes: ["read"],
|
|
217
|
+
scopePolicy: {},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(row.providerKey).toBe("linear");
|
|
221
|
+
expect(row.authUrl).toBe("https://linear.app/oauth/authorize");
|
|
222
|
+
|
|
223
|
+
const fetched = getProvider("linear");
|
|
224
|
+
expect(fetched).toBeDefined();
|
|
225
|
+
expect(fetched!.providerKey).toBe("linear");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("throws for duplicate provider_key", () => {
|
|
229
|
+
registerProvider({
|
|
230
|
+
providerKey: "linear",
|
|
231
|
+
authUrl: "https://linear.app/oauth/authorize",
|
|
232
|
+
tokenUrl: "https://api.linear.app/oauth/token",
|
|
233
|
+
defaultScopes: ["read"],
|
|
234
|
+
scopePolicy: {},
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
expect(() =>
|
|
238
|
+
registerProvider({
|
|
239
|
+
providerKey: "linear",
|
|
240
|
+
authUrl: "https://linear.app/oauth/authorize",
|
|
241
|
+
tokenUrl: "https://api.linear.app/oauth/token",
|
|
242
|
+
defaultScopes: ["read"],
|
|
243
|
+
scopePolicy: {},
|
|
244
|
+
}),
|
|
245
|
+
).toThrow(/already exists.*linear/);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// App operations
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
describe("app operations", () => {
|
|
255
|
+
describe("upsertApp", () => {
|
|
256
|
+
test("creates a new app and returns it with a UUID", async () => {
|
|
257
|
+
seedTestProvider("github");
|
|
258
|
+
const app = await upsertApp("github", "client-abc");
|
|
259
|
+
|
|
260
|
+
expect(app.id).toBeTruthy();
|
|
261
|
+
// UUID v4 format check
|
|
262
|
+
expect(app.id).toMatch(
|
|
263
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
|
264
|
+
);
|
|
265
|
+
expect(app.providerKey).toBe("github");
|
|
266
|
+
expect(app.clientId).toBe("client-abc");
|
|
267
|
+
expect(app.createdAt).toBeGreaterThan(0);
|
|
268
|
+
expect(app.updatedAt).toBeGreaterThan(0);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("returns the existing app when called again with same (providerKey, clientId)", async () => {
|
|
272
|
+
seedTestProvider("github");
|
|
273
|
+
const first = await upsertApp("github", "client-abc");
|
|
274
|
+
const second = await upsertApp("github", "client-abc");
|
|
275
|
+
|
|
276
|
+
expect(second.id).toBe(first.id);
|
|
277
|
+
expect(second.createdAt).toBe(first.createdAt);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("stores clientSecret in secure storage on new app creation", async () => {
|
|
281
|
+
seedTestProvider("github");
|
|
282
|
+
const app = await upsertApp("github", "client-abc", "my-secret");
|
|
283
|
+
|
|
284
|
+
expect(mockSetSecureKeyAsync).toHaveBeenCalledTimes(1);
|
|
285
|
+
expect(mockSetSecureKeyAsync).toHaveBeenCalledWith(
|
|
286
|
+
`oauth_app/${app.id}/client_secret`,
|
|
287
|
+
"my-secret",
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("stores clientSecret in secure storage when upserting an existing app", async () => {
|
|
292
|
+
seedTestProvider("github");
|
|
293
|
+
const first = await upsertApp("github", "client-abc");
|
|
294
|
+
mockSetSecureKeyAsync.mockClear();
|
|
295
|
+
|
|
296
|
+
await upsertApp("github", "client-abc", "updated-secret");
|
|
297
|
+
|
|
298
|
+
expect(mockSetSecureKeyAsync).toHaveBeenCalledTimes(1);
|
|
299
|
+
expect(mockSetSecureKeyAsync).toHaveBeenCalledWith(
|
|
300
|
+
`oauth_app/${first.id}/client_secret`,
|
|
301
|
+
"updated-secret",
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("throws when setSecureKeyAsync returns false", async () => {
|
|
306
|
+
seedTestProvider("github");
|
|
307
|
+
mockSetSecureKeyAsync.mockResolvedValueOnce(false);
|
|
308
|
+
|
|
309
|
+
await expect(
|
|
310
|
+
upsertApp("github", "client-abc", "bad-secret"),
|
|
311
|
+
).rejects.toThrow("Failed to store client_secret in secure storage");
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe("getApp", () => {
|
|
316
|
+
test("returns the correct row by id", async () => {
|
|
317
|
+
const app = await createTestApp("github", "client-1");
|
|
318
|
+
const fetched = getApp(app.id);
|
|
319
|
+
|
|
320
|
+
expect(fetched).toBeDefined();
|
|
321
|
+
expect(fetched!.id).toBe(app.id);
|
|
322
|
+
expect(fetched!.providerKey).toBe("github");
|
|
323
|
+
expect(fetched!.clientId).toBe("client-1");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("returns undefined for unknown id", () => {
|
|
327
|
+
expect(getApp("nonexistent-id")).toBeUndefined();
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe("getAppByProviderAndClientId", () => {
|
|
332
|
+
test("returns the correct row", async () => {
|
|
333
|
+
const app = await createTestApp("github", "client-1");
|
|
334
|
+
const fetched = getAppByProviderAndClientId("github", "client-1");
|
|
335
|
+
|
|
336
|
+
expect(fetched).toBeDefined();
|
|
337
|
+
expect(fetched!.id).toBe(app.id);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("returns undefined for unknown combination", () => {
|
|
341
|
+
expect(
|
|
342
|
+
getAppByProviderAndClientId("github", "nonexistent"),
|
|
343
|
+
).toBeUndefined();
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe("deleteApp", () => {
|
|
348
|
+
test("removes the row and returns true", async () => {
|
|
349
|
+
const app = await createTestApp("github", "client-1");
|
|
350
|
+
const deleted = await deleteApp(app.id);
|
|
351
|
+
|
|
352
|
+
expect(deleted).toBe(true);
|
|
353
|
+
expect(getApp(app.id)).toBeUndefined();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("cleans up client_secret from secure storage", async () => {
|
|
357
|
+
const app = await createTestApp("github", "client-1");
|
|
358
|
+
mockDeleteSecureKeyAsync.mockClear();
|
|
359
|
+
|
|
360
|
+
await deleteApp(app.id);
|
|
361
|
+
|
|
362
|
+
expect(mockDeleteSecureKeyAsync).toHaveBeenCalledWith(
|
|
363
|
+
`oauth_app/${app.id}/client_secret`,
|
|
364
|
+
);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("returns false for nonexistent id", async () => {
|
|
368
|
+
expect(await deleteApp("nonexistent-id")).toBe(false);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("throws when deleteSecureKeyAsync returns error", async () => {
|
|
372
|
+
const app = await createTestApp("github", "client-1");
|
|
373
|
+
mockDeleteSecureKeyAsync.mockResolvedValueOnce("error");
|
|
374
|
+
|
|
375
|
+
await expect(deleteApp(app.id)).rejects.toThrow(
|
|
376
|
+
/failed to remove client_secret from secure storage/i,
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
// DB row should already be deleted (delete happens before secure key cleanup)
|
|
380
|
+
expect(getApp(app.id)).toBeUndefined();
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
// Connection operations
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
describe("connection operations", () => {
|
|
390
|
+
describe("createConnection", () => {
|
|
391
|
+
test("creates a row with status='active'", async () => {
|
|
392
|
+
const app = await createTestApp("github", "client-1");
|
|
393
|
+
const conn = createConnection({
|
|
394
|
+
oauthAppId: app.id,
|
|
395
|
+
providerKey: "github",
|
|
396
|
+
grantedScopes: ["repo", "user"],
|
|
397
|
+
hasRefreshToken: true,
|
|
398
|
+
accountInfo: "user@example.com",
|
|
399
|
+
label: "Primary GitHub",
|
|
400
|
+
metadata: { login: "octocat" },
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
expect(conn.id).toBeTruthy();
|
|
404
|
+
expect(conn.oauthAppId).toBe(app.id);
|
|
405
|
+
expect(conn.providerKey).toBe("github");
|
|
406
|
+
expect(conn.status).toBe("active");
|
|
407
|
+
expect(JSON.parse(conn.grantedScopes)).toEqual(["repo", "user"]);
|
|
408
|
+
expect(conn.hasRefreshToken).toBe(1);
|
|
409
|
+
expect(conn.accountInfo).toBe("user@example.com");
|
|
410
|
+
expect(conn.label).toBe("Primary GitHub");
|
|
411
|
+
expect(JSON.parse(conn.metadata!)).toEqual({ login: "octocat" });
|
|
412
|
+
expect(conn.createdAt).toBeGreaterThan(0);
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
describe("getConnection", () => {
|
|
417
|
+
test("returns the correct row", async () => {
|
|
418
|
+
const app = await createTestApp("github", "client-1");
|
|
419
|
+
const conn = createConnection({
|
|
420
|
+
oauthAppId: app.id,
|
|
421
|
+
providerKey: "github",
|
|
422
|
+
grantedScopes: ["repo"],
|
|
423
|
+
hasRefreshToken: false,
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const fetched = getConnection(conn.id);
|
|
427
|
+
expect(fetched).toBeDefined();
|
|
428
|
+
expect(fetched!.id).toBe(conn.id);
|
|
429
|
+
expect(fetched!.providerKey).toBe("github");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("returns undefined for unknown id", () => {
|
|
433
|
+
expect(getConnection("nonexistent-id")).toBeUndefined();
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
describe("getConnectionByProvider", () => {
|
|
438
|
+
test("returns the most recent active connection", async () => {
|
|
439
|
+
const app = await createTestApp("github", "client-1");
|
|
440
|
+
|
|
441
|
+
// Create two connections with explicit timestamps so ordering is deterministic
|
|
442
|
+
createConnection({
|
|
443
|
+
oauthAppId: app.id,
|
|
444
|
+
providerKey: "github",
|
|
445
|
+
grantedScopes: ["repo"],
|
|
446
|
+
hasRefreshToken: false,
|
|
447
|
+
createdAt: 1000,
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
const conn2 = createConnection({
|
|
451
|
+
oauthAppId: app.id,
|
|
452
|
+
providerKey: "github",
|
|
453
|
+
grantedScopes: ["repo", "user"],
|
|
454
|
+
hasRefreshToken: true,
|
|
455
|
+
createdAt: 2000,
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
const result = getConnectionByProvider("github");
|
|
459
|
+
expect(result).toBeDefined();
|
|
460
|
+
expect(result!.id).toBe(conn2.id);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("skips connections with status='revoked'", async () => {
|
|
464
|
+
const app = await createTestApp("github", "client-1");
|
|
465
|
+
|
|
466
|
+
const conn1 = createConnection({
|
|
467
|
+
oauthAppId: app.id,
|
|
468
|
+
providerKey: "github",
|
|
469
|
+
grantedScopes: ["repo"],
|
|
470
|
+
hasRefreshToken: false,
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
const conn2 = createConnection({
|
|
474
|
+
oauthAppId: app.id,
|
|
475
|
+
providerKey: "github",
|
|
476
|
+
grantedScopes: ["repo", "user"],
|
|
477
|
+
hasRefreshToken: true,
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// Revoke the most recent connection
|
|
481
|
+
updateConnection(conn2.id, { status: "revoked" });
|
|
482
|
+
|
|
483
|
+
const result = getConnectionByProvider("github");
|
|
484
|
+
expect(result).toBeDefined();
|
|
485
|
+
expect(result!.id).toBe(conn1.id);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("skips connections with status='expired'", async () => {
|
|
489
|
+
const app = await createTestApp("github", "client-1");
|
|
490
|
+
|
|
491
|
+
const conn = createConnection({
|
|
492
|
+
oauthAppId: app.id,
|
|
493
|
+
providerKey: "github",
|
|
494
|
+
grantedScopes: ["repo"],
|
|
495
|
+
hasRefreshToken: false,
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
updateConnection(conn.id, { status: "expired" });
|
|
499
|
+
|
|
500
|
+
const result = getConnectionByProvider("github");
|
|
501
|
+
expect(result).toBeUndefined();
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("returns undefined when no active connections exist", () => {
|
|
505
|
+
expect(getConnectionByProvider("github")).toBeUndefined();
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
describe("isProviderConnected", () => {
|
|
510
|
+
test("returns true when active connection has an access token in secure storage", async () => {
|
|
511
|
+
const app = await createTestApp("github", "client-1");
|
|
512
|
+
const conn = createConnection({
|
|
513
|
+
oauthAppId: app.id,
|
|
514
|
+
providerKey: "github",
|
|
515
|
+
grantedScopes: ["repo"],
|
|
516
|
+
hasRefreshToken: false,
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
secureKeyValues.set(`oauth_connection/${conn.id}/access_token`, "tok");
|
|
520
|
+
|
|
521
|
+
expect(isProviderConnected("github")).toBe(true);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("returns false when active connection exists but access token is missing", async () => {
|
|
525
|
+
const app = await createTestApp("github", "client-1");
|
|
526
|
+
createConnection({
|
|
527
|
+
oauthAppId: app.id,
|
|
528
|
+
providerKey: "github",
|
|
529
|
+
grantedScopes: ["repo"],
|
|
530
|
+
hasRefreshToken: false,
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
// No secure key set — simulates failed token write
|
|
534
|
+
expect(isProviderConnected("github")).toBe(false);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test("returns false when no connection exists", () => {
|
|
538
|
+
expect(isProviderConnected("github")).toBe(false);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test("returns false when connection is revoked even with token in store", async () => {
|
|
542
|
+
const app = await createTestApp("github", "client-1");
|
|
543
|
+
const conn = createConnection({
|
|
544
|
+
oauthAppId: app.id,
|
|
545
|
+
providerKey: "github",
|
|
546
|
+
grantedScopes: ["repo"],
|
|
547
|
+
hasRefreshToken: false,
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
updateConnection(conn.id, { status: "revoked" });
|
|
551
|
+
secureKeyValues.set(`oauth_connection/${conn.id}/access_token`, "tok");
|
|
552
|
+
|
|
553
|
+
expect(isProviderConnected("github")).toBe(false);
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
describe("updateConnection", () => {
|
|
558
|
+
test("modifies specific fields", async () => {
|
|
559
|
+
const app = await createTestApp("github", "client-1");
|
|
560
|
+
const conn = createConnection({
|
|
561
|
+
oauthAppId: app.id,
|
|
562
|
+
providerKey: "github",
|
|
563
|
+
grantedScopes: ["repo"],
|
|
564
|
+
hasRefreshToken: false,
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
const updated = updateConnection(conn.id, {
|
|
568
|
+
status: "revoked",
|
|
569
|
+
label: "Revoked account",
|
|
570
|
+
grantedScopes: ["repo", "user", "gist"],
|
|
571
|
+
hasRefreshToken: true,
|
|
572
|
+
metadata: { reason: "user-requested" },
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
expect(updated).toBe(true);
|
|
576
|
+
|
|
577
|
+
const fetched = getConnection(conn.id);
|
|
578
|
+
expect(fetched).toBeDefined();
|
|
579
|
+
expect(fetched!.status).toBe("revoked");
|
|
580
|
+
expect(fetched!.label).toBe("Revoked account");
|
|
581
|
+
expect(JSON.parse(fetched!.grantedScopes)).toEqual([
|
|
582
|
+
"repo",
|
|
583
|
+
"user",
|
|
584
|
+
"gist",
|
|
585
|
+
]);
|
|
586
|
+
expect(fetched!.hasRefreshToken).toBe(1);
|
|
587
|
+
expect(JSON.parse(fetched!.metadata!)).toEqual({
|
|
588
|
+
reason: "user-requested",
|
|
589
|
+
});
|
|
590
|
+
expect(fetched!.updatedAt).toBeGreaterThanOrEqual(conn.createdAt);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("updates oauthAppId to a different app", async () => {
|
|
594
|
+
const app1 = await createTestApp("github", "client-1");
|
|
595
|
+
const app2 = await upsertApp("github", "client-2");
|
|
596
|
+
|
|
597
|
+
const conn = createConnection({
|
|
598
|
+
oauthAppId: app1.id,
|
|
599
|
+
providerKey: "github",
|
|
600
|
+
grantedScopes: ["repo"],
|
|
601
|
+
hasRefreshToken: false,
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
expect(getConnection(conn.id)!.oauthAppId).toBe(app1.id);
|
|
605
|
+
|
|
606
|
+
const updated = updateConnection(conn.id, { oauthAppId: app2.id });
|
|
607
|
+
expect(updated).toBe(true);
|
|
608
|
+
|
|
609
|
+
const fetched = getConnection(conn.id);
|
|
610
|
+
expect(fetched).toBeDefined();
|
|
611
|
+
expect(fetched!.oauthAppId).toBe(app2.id);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("returns false for nonexistent id", () => {
|
|
615
|
+
expect(updateConnection("nonexistent-id", { status: "revoked" })).toBe(
|
|
616
|
+
false,
|
|
617
|
+
);
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
describe("listConnections", () => {
|
|
622
|
+
test("returns all connections when no filter is given", async () => {
|
|
623
|
+
const ghApp = await createTestApp("github", "client-1");
|
|
624
|
+
seedTestProvider("google");
|
|
625
|
+
const googApp = await upsertApp("google", "client-2");
|
|
626
|
+
|
|
627
|
+
createConnection({
|
|
628
|
+
oauthAppId: ghApp.id,
|
|
629
|
+
providerKey: "github",
|
|
630
|
+
grantedScopes: ["repo"],
|
|
631
|
+
hasRefreshToken: false,
|
|
632
|
+
});
|
|
633
|
+
createConnection({
|
|
634
|
+
oauthAppId: googApp.id,
|
|
635
|
+
providerKey: "google",
|
|
636
|
+
grantedScopes: ["email"],
|
|
637
|
+
hasRefreshToken: true,
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
const all = listConnections();
|
|
641
|
+
expect(all).toHaveLength(2);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test("filters by provider key", async () => {
|
|
645
|
+
const ghApp = await createTestApp("github", "client-1");
|
|
646
|
+
seedTestProvider("google");
|
|
647
|
+
const googApp = await upsertApp("google", "client-2");
|
|
648
|
+
|
|
649
|
+
createConnection({
|
|
650
|
+
oauthAppId: ghApp.id,
|
|
651
|
+
providerKey: "github",
|
|
652
|
+
grantedScopes: ["repo"],
|
|
653
|
+
hasRefreshToken: false,
|
|
654
|
+
});
|
|
655
|
+
createConnection({
|
|
656
|
+
oauthAppId: googApp.id,
|
|
657
|
+
providerKey: "google",
|
|
658
|
+
grantedScopes: ["email"],
|
|
659
|
+
hasRefreshToken: true,
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
const ghConns = listConnections("github");
|
|
663
|
+
expect(ghConns).toHaveLength(1);
|
|
664
|
+
expect(ghConns[0].providerKey).toBe("github");
|
|
665
|
+
|
|
666
|
+
const googConns = listConnections("google");
|
|
667
|
+
expect(googConns).toHaveLength(1);
|
|
668
|
+
expect(googConns[0].providerKey).toBe("google");
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test("returns empty array when no connections exist", () => {
|
|
672
|
+
expect(listConnections()).toEqual([]);
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
describe("deleteConnection", () => {
|
|
677
|
+
test("removes the row and returns true", async () => {
|
|
678
|
+
const app = await createTestApp("github", "client-1");
|
|
679
|
+
const conn = createConnection({
|
|
680
|
+
oauthAppId: app.id,
|
|
681
|
+
providerKey: "github",
|
|
682
|
+
grantedScopes: ["repo"],
|
|
683
|
+
hasRefreshToken: false,
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
const deleted = deleteConnection(conn.id);
|
|
687
|
+
expect(deleted).toBe(true);
|
|
688
|
+
expect(getConnection(conn.id)).toBeUndefined();
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test("returns false for nonexistent id", () => {
|
|
692
|
+
expect(deleteConnection("nonexistent-id")).toBe(false);
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
// ---------------------------------------------------------------------------
|
|
698
|
+
// disconnectOAuthProvider
|
|
699
|
+
// ---------------------------------------------------------------------------
|
|
700
|
+
|
|
701
|
+
describe("disconnectOAuthProvider", () => {
|
|
702
|
+
test("returns 'not-found' when no connection exists for the provider", async () => {
|
|
703
|
+
const result = await disconnectOAuthProvider("github");
|
|
704
|
+
expect(result).toBe("not-found");
|
|
705
|
+
expect(mockDeleteSecureKeyAsync).not.toHaveBeenCalled();
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
test("returns 'disconnected' and deletes connection row and secure keys when connection exists", async () => {
|
|
709
|
+
const app = await createTestApp("github", "client-1");
|
|
710
|
+
const conn = createConnection({
|
|
711
|
+
oauthAppId: app.id,
|
|
712
|
+
providerKey: "github",
|
|
713
|
+
grantedScopes: ["repo"],
|
|
714
|
+
hasRefreshToken: true,
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
const result = await disconnectOAuthProvider("github");
|
|
718
|
+
expect(result).toBe("disconnected");
|
|
719
|
+
|
|
720
|
+
// Verify secure keys were deleted
|
|
721
|
+
expect(mockDeleteSecureKeyAsync).toHaveBeenCalledTimes(2);
|
|
722
|
+
expect(mockDeleteSecureKeyAsync).toHaveBeenCalledWith(
|
|
723
|
+
`oauth_connection/${conn.id}/access_token`,
|
|
724
|
+
);
|
|
725
|
+
expect(mockDeleteSecureKeyAsync).toHaveBeenCalledWith(
|
|
726
|
+
`oauth_connection/${conn.id}/refresh_token`,
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
// Verify connection row was deleted
|
|
730
|
+
expect(getConnection(conn.id)).toBeUndefined();
|
|
731
|
+
});
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
// ---------------------------------------------------------------------------
|
|
735
|
+
// FK constraint enforcement
|
|
736
|
+
// ---------------------------------------------------------------------------
|
|
737
|
+
|
|
738
|
+
describe("FK constraints", () => {
|
|
739
|
+
test("creating an app with a nonexistent provider_key fails", async () => {
|
|
740
|
+
await expect(
|
|
741
|
+
upsertApp("nonexistent-provider", "client-1"),
|
|
742
|
+
).rejects.toThrow();
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
test("creating a connection with a nonexistent oauth_app_id fails", () => {
|
|
746
|
+
seedTestProvider("github");
|
|
747
|
+
expect(() =>
|
|
748
|
+
createConnection({
|
|
749
|
+
oauthAppId: "nonexistent-app-id",
|
|
750
|
+
providerKey: "github",
|
|
751
|
+
grantedScopes: ["repo"],
|
|
752
|
+
hasRefreshToken: false,
|
|
753
|
+
}),
|
|
754
|
+
).toThrow();
|
|
755
|
+
});
|
|
756
|
+
});
|