W
File size: 5,025 Bytes
2b64d42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { migrateReplicaAccountsTo } from '../src/auth.js';

// Issue #67 — `accounts.json` used to live under per-replica `dataDir`
// (replica-${HOSTNAME}/), so each docker-compose upgrade orphaned the
// previous run's accounts. The migration helper unions all
// `replica-*/accounts.json` files under the shared dir and writes them
// to the shared path on startup.

const silentLogger = { warn() {}, error() {}, info() {} };

describe('migrateReplicaAccountsTo (issue #67)', () => {
  let tmp;
  let accountsFile;

  beforeEach(() => {
    tmp = mkdtempSync(join(tmpdir(), 'wfapi-mig-'));
    accountsFile = join(tmp, 'accounts.json');
  });

  afterEach(() => {
    try { rmSync(tmp, { recursive: true, force: true }); } catch {}
  });

  it('skips when no replica-* subdir exists', () => {
    const r = migrateReplicaAccountsTo({ sharedDir: tmp, accountsFile, logger: silentLogger });
    assert.equal(r.migrated, 0);
    assert.equal(r.skipped, true);
    assert.equal(existsSync(accountsFile), false);
  });

  it('skips when shared accounts.json already exists', () => {
    mkdirSync(join(tmp, 'replica-h1'));
    writeFileSync(join(tmp, 'replica-h1', 'accounts.json'), JSON.stringify([
      { apiKey: 'k1', email: 'a@b.com' },
    ]));
    writeFileSync(accountsFile, '[]');
    const r = migrateReplicaAccountsTo({ sharedDir: tmp, accountsFile, logger: silentLogger });
    assert.equal(r.migrated, 0);
    assert.equal(r.skipped, true);
    assert.equal(readFileSync(accountsFile, 'utf-8'), '[]');
  });

  it('migrates accounts from a single replica-*/accounts.json', () => {
    mkdirSync(join(tmp, 'replica-h1'));
    writeFileSync(join(tmp, 'replica-h1', 'accounts.json'), JSON.stringify([
      { apiKey: 'k1', email: 'a@b.com', method: 'api_key' },
      { apiKey: 'k2', email: 'b@b.com', method: 'api_key' },
    ]));

    const r = migrateReplicaAccountsTo({ sharedDir: tmp, accountsFile, logger: silentLogger });
    assert.equal(r.migrated, 2);
    assert.equal(r.scanned, 1);
    assert.equal(r.skipped, false);
    const out = JSON.parse(readFileSync(accountsFile, 'utf-8'));
    assert.equal(out.length, 2);
    assert.deepEqual(out.map(a => a.apiKey).sort(), ['k1', 'k2']);
  });

  it('unions multiple replica-* subdirs and dedupes by apiKey', () => {
    for (const host of ['h1', 'h2', 'h3']) mkdirSync(join(tmp, `replica-${host}`));
    writeFileSync(join(tmp, 'replica-h1', 'accounts.json'), JSON.stringify([
      { apiKey: 'k1', email: 'a@b.com' },
      { apiKey: 'k2', email: 'b@b.com' },
    ]));
    writeFileSync(join(tmp, 'replica-h2', 'accounts.json'), JSON.stringify([
      { apiKey: 'k2', email: 'b-second@b.com' }, // duplicate apiKey, should be ignored
      { apiKey: 'k3', email: 'c@b.com' },
    ]));
    writeFileSync(join(tmp, 'replica-h3', 'accounts.json'), JSON.stringify([
      { apiKey: 'k4', email: 'd@b.com' },
    ]));

    const r = migrateReplicaAccountsTo({ sharedDir: tmp, accountsFile, logger: silentLogger });
    assert.equal(r.migrated, 4);
    assert.equal(r.scanned, 3);
    const out = JSON.parse(readFileSync(accountsFile, 'utf-8'));
    assert.equal(out.length, 4);
    assert.deepEqual(out.map(a => a.apiKey).sort(), ['k1', 'k2', 'k3', 'k4']);
    // First-seen wins on duplicate apiKey
    assert.equal(out.find(a => a.apiKey === 'k2').email, 'b@b.com');
  });

  it('tolerates a corrupt replica-*/accounts.json without aborting other replicas', () => {
    mkdirSync(join(tmp, 'replica-bad'));
    mkdirSync(join(tmp, 'replica-good'));
    writeFileSync(join(tmp, 'replica-bad', 'accounts.json'), '{not json');
    writeFileSync(join(tmp, 'replica-good', 'accounts.json'), JSON.stringify([
      { apiKey: 'k1', email: 'a@b.com' },
    ]));

    const r = migrateReplicaAccountsTo({ sharedDir: tmp, accountsFile, logger: silentLogger });
    assert.equal(r.migrated, 1);
    assert.equal(r.scanned, 2);
    const out = JSON.parse(readFileSync(accountsFile, 'utf-8'));
    assert.equal(out.length, 1);
    assert.equal(out[0].apiKey, 'k1');
  });

  it('skips replica-* subdirs that have no accounts.json', () => {
    mkdirSync(join(tmp, 'replica-empty'));
    mkdirSync(join(tmp, 'replica-with-data'));
    writeFileSync(join(tmp, 'replica-with-data', 'accounts.json'), JSON.stringify([
      { apiKey: 'k1', email: 'a@b.com' },
    ]));

    const r = migrateReplicaAccountsTo({ sharedDir: tmp, accountsFile, logger: silentLogger });
    assert.equal(r.migrated, 1);
    assert.equal(r.scanned, 1);
  });

  it('does nothing when sharedDir does not exist', () => {
    const r = migrateReplicaAccountsTo({
      sharedDir: join(tmp, 'does-not-exist'),
      accountsFile,
      logger: silentLogger,
    });
    assert.equal(r.migrated, 0);
    assert.equal(r.skipped, true);
  });
});