// End-to-end coverage for secret isolation *through the real HTTP API*.
//
// Complements tenant-isolation.node.test.ts (which already covers plain
// cross-org isolation at the org scope) by exercising the two-scope stack
// the cloud app actually ships: `[userOrgScope, orgScope]`. The harness
// builds the same shape `apps/cloud/src/services/executor.ts#createScopedExecutor`
// builds in production, and every request goes through `HttpApiClient` →
// `fetch` → the real `ProtectedCloudApi` → the real Drizzle/FumaDB path.
//
// Invariants the product is staking on:
//
//   1. Users in different orgs can't see each other's secret metadata.
//   2. Users in the same org can't see each other's user-scoped secret
//      metadata (per-user OAuth tokens etc. don't leak to co-workers).
//   3. Org-scoped secret metadata IS visible to every user in that org
//      — an admin writing a shared API key serves the whole tenant.
//   4. The same user id in different orgs gets distinct per-user scopes —
//      the userOrgScope id bakes in the org id on purpose.
//   5. secrets.set rejects a scope id outside the caller's executor stack.
//
// NOTE: "per-user override shadows org default" cross-scope co-existence
// is NOT covered here. `executor.secrets.set` currently deletes secret
// metadata rows across the full scope stack before re-inserting at the
// target scope (see executor.ts `secretsSet`), so an overrider writing
// at their user-org scope wipes the org-level default rather than
// shadowing it. If the product wants both rows to coexist, that's an
// SDK-level change — coverage for it belongs after the fix.

import { describe, expect, it } from "@effect/vitest";
import { Effect, Result } from "effect";

import { ScopeId, SecretId } from "@executor-js/sdk";

import { asUser, testUserOrgScopeId } from "./services/__test-harness__/api-harness";

const uniq = () => crypto.randomUUID().slice(0, 8);
const nextOrgId = () => `org_iso_${uniq()}`;
const nextUserId = () => `user_iso_${uniq()}`;

describe("cloud secret isolation (HTTP, user-org scope stack)", () => {
  it.effect("users in different orgs cannot read each other's org-scoped secrets", () =>
    Effect.gen(function* () {
      const orgA = nextOrgId();
      const orgB = nextOrgId();
      const alice = nextUserId();
      const charlie = nextUserId();
      const id = `sec_${uniq()}`;

      yield* asUser(alice, orgA, (client) =>
        client.secrets.set({
          params: { scopeId: ScopeId.make(orgA) },
          payload: {
            id: SecretId.make(id),
            name: "Shared",
            value: "alice-org-secret",
          },
        }),
      );

      const charlieStatus = yield* asUser(charlie, orgB, (client) =>
        client.secrets.status({
          params: { scopeId: ScopeId.make(orgB), secretId: SecretId.make(id) },
        }),
      );
      expect(charlieStatus.status).toBe("missing");

      const charlieList = yield* asUser(charlie, orgB, (client) =>
        client.secrets.list({ params: { scopeId: ScopeId.make(orgB) } }),
      );
      expect(charlieList.map((s) => s.id)).not.toContain(id);
    }),
  );

  it.effect("users in same org cannot read each other's user-scoped secrets", () =>
    Effect.gen(function* () {
      const orgId = nextOrgId();
      const aliceId = nextUserId();
      const bobId = nextUserId();
      const id = `sec_${uniq()}`;

      // Alice writes at her per-user scope — where OAuth tokens land.
      yield* asUser(aliceId, orgId, (client) =>
        client.secrets.set({
          params: { scopeId: ScopeId.make(testUserOrgScopeId(aliceId, orgId)) },
          payload: {
            id: SecretId.make(id),
            name: "Alice's token",
            value: "alice-token-value",
          },
        }),
      );

      // Bob is in the same org — his user-org scope differs. He should
      // not see the token in a list.
      const bobList = yield* asUser(bobId, orgId, (client) =>
        client.secrets.list({
          params: { scopeId: ScopeId.make(testUserOrgScopeId(bobId, orgId)) },
        }),
      );
      expect(bobList.map((s) => s.id)).not.toContain(id);

      const bobStatus = yield* asUser(bobId, orgId, (client) =>
        client.secrets.status({
          params: {
            scopeId: ScopeId.make(testUserOrgScopeId(bobId, orgId)),
            secretId: SecretId.make(id),
          },
        }),
      );
      expect(bobStatus.status).toBe("missing");

      // And Alice still sees her own token metadata.
      const aliceStatus = yield* asUser(aliceId, orgId, (client) =>
        client.secrets.status({
          params: {
            scopeId: ScopeId.make(testUserOrgScopeId(aliceId, orgId)),
            secretId: SecretId.make(id),
          },
        }),
      );
      expect(aliceStatus.status).toBe("resolved");
    }),
  );

  it.effect("org-scoped secrets are visible to every user in that org", () =>
    Effect.gen(function* () {
      const orgId = nextOrgId();
      const adminId = nextUserId();
      const memberId = nextUserId();
      const id = `sec_${uniq()}`;

      yield* asUser(adminId, orgId, (client) =>
        client.secrets.set({
          params: { scopeId: ScopeId.make(orgId) },
          payload: {
            id: SecretId.make(id),
            name: "Org API Key",
            value: "shared-org-key",
          },
        }),
      );

      const adminStatus = yield* asUser(adminId, orgId, (client) =>
        client.secrets.status({
          params: { scopeId: ScopeId.make(orgId), secretId: SecretId.make(id) },
        }),
      );
      const memberStatus = yield* asUser(memberId, orgId, (client) =>
        client.secrets.status({
          params: { scopeId: ScopeId.make(orgId), secretId: SecretId.make(id) },
        }),
      );
      expect(adminStatus.status).toBe("resolved");
      expect(memberStatus.status).toBe("resolved");
    }),
  );

  it.effect("same userId in different orgs gets distinct per-user scopes", () =>
    Effect.gen(function* () {
      const userId = nextUserId();
      const orgA = nextOrgId();
      const orgB = nextOrgId();
      const id = `sec_${uniq()}`;

      yield* asUser(userId, orgA, (client) =>
        client.secrets.set({
          params: { scopeId: ScopeId.make(testUserOrgScopeId(userId, orgA)) },
          payload: {
            id: SecretId.make(id),
            name: "A token",
            value: "value-in-a",
          },
        }),
      );

      // Same user id, different org → distinct user-org scope. The
      // secret written in org A must not be visible when the same user
      // logs into org B.
      const listInB = yield* asUser(userId, orgB, (client) =>
        client.secrets.list({
          params: { scopeId: ScopeId.make(testUserOrgScopeId(userId, orgB)) },
        }),
      );
      expect(listInB.map((s) => s.id)).not.toContain(id);

      const statusInB = yield* asUser(userId, orgB, (client) =>
        client.secrets.status({
          params: {
            scopeId: ScopeId.make(testUserOrgScopeId(userId, orgB)),
            secretId: SecretId.make(id),
          },
        }),
      );
      expect(statusInB.status).toBe("missing");

      // Sanity: the original write is still visible under the org-A
      // user-org scope.
      const statusInA = yield* asUser(userId, orgA, (client) =>
        client.secrets.status({
          params: {
            scopeId: ScopeId.make(testUserOrgScopeId(userId, orgA)),
            secretId: SecretId.make(id),
          },
        }),
      );
      expect(statusInA.status).toBe("resolved");
    }),
  );

  it.effect("secrets.set rejects a scope outside the executor's stack", () =>
    Effect.gen(function* () {
      const orgId = nextOrgId();
      const userId = nextUserId();
      const foreignOrg = nextOrgId();

      const result = yield* asUser(userId, orgId, (client) =>
        client.secrets
          .set({
            params: { scopeId: ScopeId.make(foreignOrg) },
            payload: {
              id: SecretId.make("wrong-scope"),
              name: "x",
              value: "should not land",
            },
          })
          .pipe(Effect.result),
      );
      expect(Result.isFailure(result)).toBe(true);

      // And nothing landed in the foreign org — a fresh session pointed
      // at that org must not see `wrong-scope`.
      const foreignUser = nextUserId();
      const leaked = yield* asUser(foreignUser, foreignOrg, (client) =>
        client.secrets.status({
          params: {
            scopeId: ScopeId.make(foreignOrg),
            secretId: SecretId.make("wrong-scope"),
          },
        }),
      );
      expect(leaked.status).toBe("missing");
    }),
  );
});
