Skip to content

Commit ebcf398

Browse files
authored
feat(passwords): open password-protected docx (#2547)
* feat(passwords): open pssword protected docx * fix(passwords): wire decrypted bytes through editor init, replaceFile, and type surface * chore: fix types * fix(passwords): add doc.open password to agentVisible allowlist and fix lint
1 parent 591177c commit ebcf398

67 files changed

Lines changed: 4970 additions & 55 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/cli/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,27 @@ superdoc save --in-place
4141
superdoc close
4242
```
4343

44+
## Encrypted Documents
45+
46+
Open password-protected `.docx` files with `--password` or the `SUPERDOC_DOC_PASSWORD` env var:
47+
48+
```bash
49+
# Explicit flag
50+
superdoc open ./secret.docx --password 'mypassword'
51+
52+
# Env var (preferred — avoids password in process listings)
53+
SUPERDOC_DOC_PASSWORD='mypassword' superdoc open ./secret.docx
54+
55+
# Via call
56+
superdoc call doc.open --input-json '{"doc":"./secret.docx","password":"mypassword"}'
57+
```
58+
59+
If the password is missing or incorrect, the CLI returns a structured error with one of these codes:
60+
- `DOCX_PASSWORD_REQUIRED` — encrypted file, no password supplied
61+
- `DOCX_PASSWORD_INVALID` — wrong password
62+
- `DOCX_ENCRYPTION_UNSUPPORTED` — recognized but unsupported encryption method
63+
- `DOCX_DECRYPTION_FAILED` — crypto failure or corrupt data
64+
4465
## Choosing the Right Command
4566

4667
### Which command should I use?

apps/cli/src/__tests__/cli.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,13 @@ const TEST_DIR = join(import.meta.dir, 'fixtures-cli');
5656
const STATE_DIR = join(TEST_DIR, 'state');
5757
const SAMPLE_DOC = join(TEST_DIR, 'sample.docx');
5858
const LIST_SAMPLE_DOC = join(TEST_DIR, 'lists-sample.docx');
59+
const ENCRYPTED_DOC = join(TEST_DIR, 'encrypted.docx');
5960
const CLI_PACKAGE_JSON_PATH = join(import.meta.dir, '../../package.json');
61+
const REPO_ROOT = join(import.meta.dir, '../../../..');
62+
const ENCRYPTED_FIXTURE_SOURCE = join(
63+
REPO_ROOT,
64+
'packages/super-editor/src/core/ooxml-encryption/fixtures/encrypted-advanced-text.docx',
65+
);
6066

6167
async function readCliPackageVersion(): Promise<string> {
6268
const raw = await readFile(CLI_PACKAGE_JSON_PATH, 'utf8');
@@ -205,6 +211,7 @@ describe('superdoc CLI', () => {
205211
await mkdir(TEST_DIR, { recursive: true });
206212
await copyFile(await resolveSourceDocFixture(), SAMPLE_DOC);
207213
await copyFile(await resolveListDocFixture(), LIST_SAMPLE_DOC);
214+
await copyFile(ENCRYPTED_FIXTURE_SOURCE, ENCRYPTED_DOC);
208215
cliPackageVersion = await readCliPackageVersion();
209216
});
210217

@@ -2379,4 +2386,87 @@ describe('superdoc CLI', () => {
23792386
const closeResult = await runCli(['close', '--discard']);
23802387
expect(closeResult.code).toBe(0);
23812388
});
2389+
2390+
// -- Encrypted document tests -----------------------------------------------
2391+
2392+
// Encrypted tests use 30s timeout — decryption + open is ~4s and can exceed
2393+
// bun's default 5s budget under full-suite load.
2394+
test('open encrypted doc with --password succeeds end-to-end', async () => {
2395+
const result = await runCli(['open', ENCRYPTED_DOC, '--password', 'test123']);
2396+
expect(result.code).toBe(0);
2397+
2398+
const envelope = parseJsonOutput<SuccessEnvelope<{ active: boolean }>>(result);
2399+
expect(envelope.data.active).toBe(true);
2400+
2401+
const closeResult = await runCli(['close', '--discard']);
2402+
expect(closeResult.code).toBe(0);
2403+
}, 30_000);
2404+
2405+
test('open encrypted doc without password returns DOCX_PASSWORD_REQUIRED', async () => {
2406+
const result = await runCli(['open', ENCRYPTED_DOC]);
2407+
expect(result.code).toBe(1);
2408+
2409+
const envelope = parseJsonOutput<ErrorEnvelope>(result);
2410+
expect(envelope.error.code).toBe('DOCX_PASSWORD_REQUIRED');
2411+
}, 30_000);
2412+
2413+
test('open encrypted doc with wrong password returns DOCX_PASSWORD_INVALID', async () => {
2414+
const result = await runCli(['open', ENCRYPTED_DOC, '--password', 'wrong']);
2415+
expect(result.code).toBe(1);
2416+
2417+
const envelope = parseJsonOutput<ErrorEnvelope>(result);
2418+
expect(envelope.error.code).toBe('DOCX_PASSWORD_INVALID');
2419+
}, 30_000);
2420+
2421+
test('call doc.open with --input-json password succeeds end-to-end', async () => {
2422+
const input = JSON.stringify({ doc: ENCRYPTED_DOC, password: 'test123' });
2423+
const result = await runCli(['call', 'doc.open', '--input-json', input]);
2424+
expect(result.code).toBe(0);
2425+
2426+
const closeResult = await runCli(['close', '--discard']);
2427+
expect(closeResult.code).toBe(0);
2428+
}, 30_000);
2429+
2430+
test('call doc.open with missing password returns DOCX_PASSWORD_REQUIRED', async () => {
2431+
const input = JSON.stringify({ doc: ENCRYPTED_DOC });
2432+
const result = await runCli(['call', 'doc.open', '--input-json', input]);
2433+
expect(result.code).toBe(1);
2434+
2435+
const envelope = parseJsonOutput<ErrorEnvelope>(result);
2436+
expect(envelope.error.code).toBe('DOCX_PASSWORD_REQUIRED');
2437+
}, 30_000);
2438+
2439+
// -- Env-fallback precedence tests ------------------------------------------
2440+
2441+
test('SUPERDOC_DOC_PASSWORD env var is used in direct CLI mode', async () => {
2442+
const prevEnv = process.env.SUPERDOC_DOC_PASSWORD;
2443+
try {
2444+
process.env.SUPERDOC_DOC_PASSWORD = 'test123';
2445+
// No --password flag — should fall back to env var in direct mode
2446+
const result = await runCli(['open', ENCRYPTED_DOC]);
2447+
expect(result.code).toBe(0);
2448+
2449+
const closeResult = await runCli(['close', '--discard']);
2450+
expect(closeResult.code).toBe(0);
2451+
} finally {
2452+
if (prevEnv != null) process.env.SUPERDOC_DOC_PASSWORD = prevEnv;
2453+
else delete process.env.SUPERDOC_DOC_PASSWORD;
2454+
}
2455+
}, 30_000);
2456+
2457+
test('explicit --password takes precedence over SUPERDOC_DOC_PASSWORD env var', async () => {
2458+
const prevEnv = process.env.SUPERDOC_DOC_PASSWORD;
2459+
try {
2460+
process.env.SUPERDOC_DOC_PASSWORD = 'wrong-env-password';
2461+
// Explicit password should override the (wrong) env password
2462+
const result = await runCli(['open', ENCRYPTED_DOC, '--password', 'test123']);
2463+
expect(result.code).toBe(0);
2464+
2465+
const closeResult = await runCli(['close', '--discard']);
2466+
expect(closeResult.code).toBe(0);
2467+
} finally {
2468+
if (prevEnv != null) process.env.SUPERDOC_DOC_PASSWORD = prevEnv;
2469+
else delete process.env.SUPERDOC_DOC_PASSWORD;
2470+
}
2471+
}, 30_000);
23822472
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* Subprocess worker for the collab password forwarding test.
3+
* Runs in isolation so mock.module doesn't leak across test files.
4+
*/
5+
import { mock } from 'bun:test';
6+
7+
// Track what gets passed to Editor.open
8+
let editorOpenCalled = false;
9+
let capturedPassword: string | undefined;
10+
11+
const MockEditor = {
12+
open: mock(async (_source: unknown, options: Record<string, unknown> = {}) => {
13+
editorOpenCalled = true;
14+
capturedPassword = options.password as string | undefined;
15+
return {
16+
destroy: () => {},
17+
exportDocument: async () => new Uint8Array(),
18+
};
19+
}),
20+
};
21+
22+
mock.module('superdoc/super-editor', () => ({
23+
Editor: MockEditor,
24+
BLANK_DOCX_BASE64: '',
25+
DocxEncryptionError: class DocxEncryptionError extends Error {
26+
code: string;
27+
constructor(code: string, message: string) {
28+
super(message);
29+
this.code = code;
30+
}
31+
},
32+
getDocumentApiAdapters: () => ({}),
33+
markdownToPmDoc: () => null,
34+
initPartsRuntime: () => ({ dispose: () => {} }),
35+
}));
36+
37+
mock.module('happy-dom', () => ({
38+
Window: class {
39+
document = {
40+
createElement: () => ({}),
41+
body: { appendChild: () => {}, innerHTML: '' },
42+
};
43+
close() {}
44+
},
45+
}));
46+
47+
const mockYArray = { observe: () => {}, toArray: () => [], toJSON: () => [], length: 0 };
48+
const mockYMap = { observe: () => {}, get: () => undefined, set: () => {} };
49+
const mockYDoc = {
50+
getXmlFragment: () => ({ toArray: () => [] }),
51+
getArray: () => mockYArray,
52+
getMap: () => mockYMap,
53+
transact: (fn: () => void) => fn(),
54+
};
55+
56+
mock.module('../../lib/collaboration', () => ({
57+
createCollaborationRuntime: () => ({
58+
waitForSync: async () => {},
59+
ydoc: mockYDoc,
60+
provider: { destroy: () => {} },
61+
dispose: () => {},
62+
}),
63+
}));
64+
65+
mock.module('../../lib/bootstrap', () => ({
66+
DEFAULT_BOOTSTRAP_SETTLING_MS: 0,
67+
waitForContentSettling: async () => {},
68+
detectRoomState: () => 'empty' as const,
69+
resolveBootstrapDecision: () => ({ action: 'seed' as const, source: 'doc' as const }),
70+
claimBootstrap: async () => ({ granted: true }),
71+
clearBootstrapMarker: () => {},
72+
writeBootstrapMarker: () => {},
73+
detectBootstrapRace: async () => ({ raceDetected: false }),
74+
}));
75+
76+
async function main() {
77+
const { openCollaborativeDocument } = await import('../../lib/document');
78+
const { join } = await import('path');
79+
80+
const repoRoot = join(import.meta.dir, '../../../../..');
81+
const encryptedDoc = join(
82+
repoRoot,
83+
'packages/super-editor/src/core/ooxml-encryption/fixtures/encrypted-advanced-text.docx',
84+
);
85+
86+
const io = {
87+
stdout: () => {},
88+
stderr: () => {},
89+
readStdinBytes: async () => new Uint8Array(),
90+
};
91+
92+
const profile = {
93+
documentId: 'test-doc-id',
94+
serverUrl: 'ws://localhost:1234',
95+
};
96+
97+
try {
98+
// Pass a real encrypted file path to exercise the file-backed seed branch:
99+
// openCollaborativeDocument → shouldSeed=true → docForEditor=encryptedDoc
100+
// → openDocument(encryptedDoc, ...) → Editor.open(buffer, { password })
101+
await openCollaborativeDocument(encryptedDoc, io, profile, {
102+
editorOpenOptions: { password: 'collab-test-secret' },
103+
});
104+
} catch {
105+
// May fail on export or other subsystem — we only care about Editor.open call
106+
}
107+
108+
console.log(JSON.stringify({ editorOpenCalled, capturedPassword }));
109+
}
110+
111+
main().catch((e) => {
112+
console.error(e);
113+
process.exit(1);
114+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Verifies that `openCollaborativeDocument` forwards
3+
* `editorOpenOptions.password` through to the inner `openDocument` → `Editor.open()` call
4+
* when seeding from an encrypted source document.
5+
*
6+
* This test runs in a subprocess to avoid `mock.module` side effects
7+
* contaminating other test files in the same bun process.
8+
*/
9+
import { describe, test, expect } from 'bun:test';
10+
import { join } from 'path';
11+
12+
const WORKER_SCRIPT = join(import.meta.dir, '_collab-password-worker.ts');
13+
14+
describe('openCollaborativeDocument password forwarding', () => {
15+
test('password reaches Editor.open() through the collaboration seed path', async () => {
16+
const proc = Bun.spawn(['bun', 'run', WORKER_SCRIPT], {
17+
cwd: join(import.meta.dir, '../../..'),
18+
stdout: 'pipe',
19+
stderr: 'pipe',
20+
});
21+
22+
const exitCode = await proc.exited;
23+
const stdout = await new Response(proc.stdout).text();
24+
const stderr = await new Response(proc.stderr).text();
25+
26+
if (exitCode !== 0) {
27+
throw new Error(`Worker failed (code ${exitCode}):\n${stderr || stdout}`);
28+
}
29+
30+
const result = JSON.parse(stdout.trim());
31+
expect(result.editorOpenCalled).toBe(true);
32+
expect(result.capturedPassword).toBe('collab-test-secret');
33+
});
34+
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* Verifies that host mode (SDK-driven) suppresses the SUPERDOC_DOC_PASSWORD
3+
* env fallback. Uses `invokeCommand()` — the real programmatic entry point
4+
* with proper stateDir and executionMode wiring.
5+
*/
6+
import { describe, test, expect, afterEach, beforeAll } from 'bun:test';
7+
import { invokeCommand } from '../../index';
8+
import { CliError } from '../../lib/errors';
9+
import { join } from 'path';
10+
import { mkdtemp, rm } from 'fs/promises';
11+
import { tmpdir } from 'os';
12+
13+
const REPO_ROOT = join(import.meta.dir, '../../../../..');
14+
const ENCRYPTED_DOC = join(
15+
REPO_ROOT,
16+
'packages/super-editor/src/core/ooxml-encryption/fixtures/encrypted-advanced-text.docx',
17+
);
18+
19+
const silentIo = {
20+
stdout: () => {},
21+
stderr: () => {},
22+
readStdinBytes: async () => new Uint8Array(),
23+
};
24+
25+
let testStateDir: string;
26+
27+
beforeAll(async () => {
28+
testStateDir = await mkdtemp(join(tmpdir(), 'superdoc-host-test-'));
29+
});
30+
31+
afterEach(async () => {
32+
await rm(testStateDir, { recursive: true, force: true });
33+
testStateDir = await mkdtemp(join(tmpdir(), 'superdoc-host-test-'));
34+
});
35+
36+
/** Call invokeCommand, catching CliErrors into a result object. */
37+
async function invokeExpectingResult(argv: string[], executionMode: 'oneshot' | 'host', stateDir: string) {
38+
try {
39+
const result = await invokeCommand(argv, {
40+
stateDir,
41+
executionMode,
42+
ioOverrides: silentIo,
43+
});
44+
return { code: result.execution?.code ?? 0, error: null };
45+
} catch (e) {
46+
if (e instanceof CliError) {
47+
return { code: 1, error: { code: e.code, message: e.message } };
48+
}
49+
throw e;
50+
}
51+
}
52+
53+
describe('host-mode env-fallback suppression', () => {
54+
const prevEnv = process.env.SUPERDOC_DOC_PASSWORD;
55+
56+
afterEach(() => {
57+
if (prevEnv != null) process.env.SUPERDOC_DOC_PASSWORD = prevEnv;
58+
else delete process.env.SUPERDOC_DOC_PASSWORD;
59+
});
60+
61+
test('env password is suppressed in host mode, returning DOCX_PASSWORD_REQUIRED', async () => {
62+
process.env.SUPERDOC_DOC_PASSWORD = 'test123';
63+
64+
const result = await invokeExpectingResult(['open', ENCRYPTED_DOC], 'host', testStateDir);
65+
66+
expect(result.code).toBe(1);
67+
expect(result.error).not.toBeNull();
68+
expect(result.error!.code).toBe('DOCX_PASSWORD_REQUIRED');
69+
}, 30_000);
70+
71+
test('env password IS used in direct CLI mode (oneshot)', async () => {
72+
process.env.SUPERDOC_DOC_PASSWORD = 'test123';
73+
74+
const result = await invokeExpectingResult(['open', ENCRYPTED_DOC], 'oneshot', testStateDir);
75+
76+
// If it failed, the error must NOT be a password error.
77+
if (result.code !== 0) {
78+
expect(result.error?.code).not.toBe('DOCX_PASSWORD_REQUIRED');
79+
expect(result.error?.code).not.toBe('DOCX_PASSWORD_INVALID');
80+
}
81+
82+
// Clean up if open succeeded
83+
if (result.code === 0) {
84+
await invokeExpectingResult(['close', '--discard'], 'oneshot', testStateDir);
85+
}
86+
}, 30_000);
87+
});

0 commit comments

Comments
 (0)